diff options
| author | Adam Soltys <[email protected]> | 2026-09-09 22:46:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-09 22:46:20 -0700 |
| commit | 95cd3fa81097e288e4920bb5029cfd23f7cae98f (patch) | |
| tree | 1ab5418098542f77de2d5ee1ddfac9984d1f758e | |
| parent | 9f2ce90f494a5b415bec0898e3185437126fc7d9 (diff) | |
| download | coinosv3-95cd3fa81097e288e4920bb5029cfd23f7cae98f.tar.gz coinosv3-95cd3fa81097e288e4920bb5029cfd23f7cae98f.zip | |
tools: CDP main-thread profiler for the live app (laptop or phone over adb).
cdp-profile.js samples the page through the remote-debugging port around a
reload / simulated refocus / manual action, and can serve a local dist into
the real tab (service worker bypassed) to profile a fix before it ships.
cdp-callers.js answers "who calls X" from the saved profile.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
| -rw-r--r-- | tools/cdp-callers.js | 19 | ||||
| -rw-r--r-- | tools/cdp-profile.js | 84 |
2 files changed, 103 insertions, 0 deletions
diff --git a/tools/cdp-callers.js b/tools/cdp-callers.js new file mode 100644 index 0000000..2573df4 --- /dev/null +++ b/tools/cdp-callers.js @@ -0,0 +1,19 @@ +// Who calls a hot function? Aggregates ancestor chains (inclusive-time +// weighted) for every profile node named <fnName>, from a .cpuprofile saved +// by tools/cdp-profile.js --out. +const [file, name, depthS] = process.argv.slice(2); const depth = Number(depthS || 6); +const p = JSON.parse(await Bun.file(file).text()); +const nodes = new Map(p.nodes.map((n) => [n.id, n])); const parent = new Map(); +for (const n of p.nodes) for (const c of n.children || []) parent.set(c, n.id); +const self = new Map(); for (let i = 0; i < p.samples.length; i++) self.set(p.samples[i], (self.get(p.samples[i]) || 0) + (p.timeDeltas[i + 1] || 0)); +const key = (n) => { const f = n.callFrame; return `${f.functionName || '(anon)'}@${f.url.replace(/^https?:\/\/[^/]+\//, '').slice(0, 22)}:${f.lineNumber + 1}:${f.columnNumber + 1}`; }; +const chains = new Map(); +for (const n of p.nodes) { + if (n.callFrame.functionName !== name) continue; + // inclusive time of this node = sum self of subtree + let total = 0; const stack = [n.id]; while (stack.length) { const id = stack.pop(); total += self.get(id) || 0; for (const c of nodes.get(id).children || []) stack.push(c); } + if (!total) continue; + const path = []; let pid = parent.get(n.id); while (pid != null && path.length < depth) { path.push(key(nodes.get(pid))); pid = parent.get(pid); } + const k = path.join(' <- '); chains.set(k, (chains.get(k) || 0) + total); +} +for (const [k, v] of [...chains].sort((a, b) => b[1] - a[1]).slice(0, 8)) console.log((v / 1000).toFixed(1).padStart(7) + 'ms ' + k); diff --git a/tools/cdp-profile.js b/tools/cdp-profile.js new file mode 100644 index 0000000..814b968 --- /dev/null +++ b/tools/cdp-profile.js @@ -0,0 +1,84 @@ +// Main-thread CPU profile of the live app through Chrome's remote debugging +// port (laptop: chrome --remote-debugging-port=9222; phone: adb forward +// tcp:9223 localabstract:chrome_devtools_remote — the page must be +// foreground/unlocked, a frozen background tab never answers). +// bun tools/cdp-profile.js --port 9222 --mode reload --seconds 10 +// --mode refocus simulate background→foreground (Page.setWebLifecycleState) +// --mode wait start sampling, then do the action by hand (or adb) +// --serve dist answer same-origin requests from a local build (SW bypassed) +// so an unreleased fix can be profiled in the real tab +// --out f.cpuprofile keep the raw profile for tools/cdp-callers.js +// Prints busy-per-250ms buckets, self time by file/function, inclusive time. +const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[i + 1] : true] : []).filter(Boolean)); +const port = args.port || 9222, mode = args.mode || 'idle', seconds = Number(args.seconds || 8), match = args.match || 'coinos.io'; +const list = await (await fetch(`http://localhost:${port}/json/list`)).json(); +const page = list.find((t) => t.type === 'page' && t.url.includes(match)); +if (!page) { console.error('no page matching', match); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); const events = []; +ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { const { res, rej } = pending.get(d.id); pending.delete(d.id); d.error ? rej(new Error(JSON.stringify(d.error))) : res(d.result); } else if (d.method) events.push(d); }; +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +await new Promise((r) => (ws.onopen = r)); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await send('Runtime.enable'); await send('Page.enable'); await send('Profiler.enable'); +await send('Profiler.setSamplingInterval', { interval: Number(args.interval || 500) }); +if (args.serve) { + // serve the local build into the live tab: bypass the SW, answer every same-origin request from dist/ + const fs = await import('fs'); const path = await import('path'); + const types = { '.js': 'text/javascript', '.html': 'text/html', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.svg': 'image/svg+xml' }; + await send('Network.enable'); await send('Network.setBypassServiceWorker', { bypass: true }); + await send('Fetch.enable', { patterns: [{ urlPattern: `https://${new URL(page.url).host}/*`, requestStage: 'Request' }] }); + const origOnMessage = ws.onmessage; + ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d.method === 'Fetch.requestPaused') { + const u = new URL(d.params.request.url); let rel = u.pathname === '/' ? '/index.html' : u.pathname; + if (u.pathname === '/sw.js') { send('Fetch.continueRequest', { requestId: d.params.requestId }); return; } + const f = path.join(args.serve, rel); + if (fs.existsSync(f) && fs.statSync(f).isFile()) { + const body = fs.readFileSync(f).toString('base64'); + send('Fetch.fulfillRequest', { requestId: d.params.requestId, responseCode: 200, responseHeaders: [{ name: 'Content-Type', value: types[path.extname(f)] || 'application/octet-stream' }, { name: 'Cache-Control', value: 'no-store' }], body }); + } else send('Fetch.continueRequest', { requestId: d.params.requestId }); + return; } origOnMessage(m); }; +} +const t0 = Date.now(); +await send('Profiler.start'); +if (mode === 'reload') { await send('Page.reload', { ignoreCache: false }); } +else if (mode === 'refocus') { + await send('Emulation.setFocusEmulationEnabled', { enabled: false }).catch(() => {}); + await send('Page.setWebLifecycleState', { state: 'frozen' }); await sleep(Number(args.away || 3000)); + await send('Page.setWebLifecycleState', { state: 'active' }); + await send('Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => {}); +} else if (mode === 'wait') { console.log('profiler running; do the action now'); } +await sleep(seconds * 1000); +const { profile } = await send('Profiler.stop'); +if (args.serve) { await send('Fetch.disable'); await send('Network.setBypassServiceWorker', { bypass: false }); } +ws.close(); +// ---- analysis +const nodes = new Map(profile.nodes.map((n) => [n.id, n])); +const parent = new Map(); for (const n of profile.nodes) for (const c of n.children || []) parent.set(c, n.id); +const self = new Map(); const total = new Map(); +const dt = profile.timeDeltas; const samples = profile.samples; +let t = profile.startTime; const buckets = new Map(); let busy = 0, all = 0; const first = profile.startTime; +const isIdle = (n) => ['(idle)', '(program)', '(garbage collector)', '(root)'].includes(n.callFrame.functionName) && !n.callFrame.url; +for (let i = 0; i < samples.length; i++) { + t += dt[i]; const n = nodes.get(samples[i]); const d = dt[i + 1] || 0; all += d; + const idle = n.callFrame.functionName === '(idle)'; + if (!idle) { busy += d; const b = Math.floor((t - first) / 1000 / 250); buckets.set(b, (buckets.get(b) || 0) + d); } + self.set(n.id, (self.get(n.id) || 0) + d); + const seen = new Set(); let p = n.id; while (p != null) { const key = fnKey(nodes.get(p)); if (!seen.has(key)) { seen.add(key); total.set(key, (total.get(key) || 0) + d); } p = parent.get(p); } +} +function fnKey(n) { const f = n.callFrame; return `${f.functionName || '(anon)'} ${f.url.replace(/^https?:\/\/[^/]+/, '')}:${f.lineNumber + 1}:${f.columnNumber + 1}`; } +const selfByFn = new Map(); for (const [nid, ms] of self) { const k = fnKey(nodes.get(nid)); selfByFn.set(k, (selfByFn.get(k) || 0) + ms); } +const selfByUrl = new Map(); for (const [nid, ms] of self) { const f = nodes.get(nid).callFrame; const k = f.url ? f.url.replace(/^https?:\/\/[^/]+/, '') : f.functionName; selfByUrl.set(k, (selfByUrl.get(k) || 0) + ms); } +const ms = (us) => (us / 1000).toFixed(1); +console.log(`mode=${mode} page=${page.url} sampled ${ms(all)}ms wall, busy ${ms(busy)}ms (${(100 * busy / all).toFixed(0)}%)`); +console.log('\n== busy per 250ms bucket (ms) =='); +const maxB = Math.max(...buckets.keys()); let line = ''; +for (let b = 0; b <= maxB; b++) { const v = (buckets.get(b) || 0) / 1000; line += v.toFixed(0).padStart(4); if ((b + 1) % 16 === 0) { console.log(`${(b - 15) * 0.25}s:`.padStart(7) + line); line = ''; } } +if (line) console.log(`${(Math.floor(maxB / 16) * 16) * 0.25}s:`.padStart(7) + line); +console.log('\n== self time by url =='); +for (const [k, v] of [...selfByUrl].sort((a, b) => b[1] - a[1]).slice(0, 12)) console.log(ms(v).padStart(8), k); +console.log('\n== top self time by function =='); +for (const [k, v] of [...selfByFn].sort((a, b) => b[1] - a[1]).slice(0, Number(args.top || 40))) console.log(ms(v).padStart(8), k); +console.log('\n== top total (inclusive) time by function =='); +for (const [k, v] of [...total].sort((a, b) => b[1] - a[1]).slice(0, Number(args.top || 40))) { if (k.startsWith('(root)') || k.startsWith('(program)') || k.startsWith('(idle)')) continue; console.log(ms(v).padStart(8), k); } +if (args.out) await Bun.write(args.out, JSON.stringify(profile)); |
