From 10c5162811db6b6676504f114e1e6a4821351994 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 3 Oct 2026 13:54:24 +0800 Subject: [PATCH] perf: parse, chunk and hash documents in a worker thread and add startup and document-parse benchmarks --- build/run-document-parse-perf.cjs | 109 +++++ build/run-startup-perf.cjs | 360 +++++++++++++++++ electron.vite.config.ts | 1 + package.json | 2 + src/main/document-parse-client.ts | 375 ++++++++++++++++++ src/main/document-parse-errors.ts | 28 ++ src/main/document-parse-worker.test.ts | 284 +++++++++++++ src/main/document-parse-worker.ts | 101 +++++ src/main/document-parsing-service.ts | 16 +- src/main/index.ts | 46 ++- src/main/knowledge/knowledge-service.ts | 22 +- src/main/knowledge/url-importer.ts | 5 +- src/main/startup-marks.ts | 32 ++ .../support/document-parse-worker-fixture.ts | 130 ++++++ tests/support/startup-perf-driver.mjs | 309 +++++++++++++++ 15 files changed, 1787 insertions(+), 33 deletions(-) create mode 100644 build/run-document-parse-perf.cjs create mode 100644 build/run-startup-perf.cjs create mode 100644 src/main/document-parse-client.ts create mode 100644 src/main/document-parse-errors.ts create mode 100644 src/main/document-parse-worker.test.ts create mode 100644 src/main/document-parse-worker.ts create mode 100644 src/main/startup-marks.ts create mode 100644 tests/support/document-parse-worker-fixture.ts create mode 100644 tests/support/startup-perf-driver.mjs diff --git a/build/run-document-parse-perf.cjs b/build/run-document-parse-perf.cjs new file mode 100644 index 00000000..6a7f33cc --- /dev/null +++ b/build/run-document-parse-perf.cjs @@ -0,0 +1,109 @@ +// Document parse benchmark (PERF-15/16, P7). Bundles the production parser +// client and worker with esbuild, then imports synthetic large documents +// (hash -> parse -> chunk, as Knowledge import does) twice per round: inline on +// the main thread, then through the parse worker. Records the main event-loop +// max block with perf_hooks.monitorEventLoopDelay. +// +// node build/run-document-parse-perf.cjs +// GB_DOCPERF_ROUNDS=3 GB_DOCPERF_NODE=1 (skip Electron) GB_DOCPERF_OUTPUT=file.json +const { buildSync } = require('esbuild') +const { mkdirSync, rmSync, writeFileSync } = require('node:fs') +const { join, resolve } = require('node:path') +const { monitorEventLoopDelay, performance } = require('node:perf_hooks') + +const root = resolve(__dirname, '..') + +// Measure on the project's Electron (the production runtime), like perf:knowledge. +if (process.env.GB_DOCPERF_NODE !== '1') { + let electron + let version + try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined } + if (typeof electron === 'string' && process.versions.electron !== version) { + const { status } = require('node:child_process').spawnSync(electron, [__filename], { + stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } + }) + process.exit(status ?? 1) + } +} + +const rounds = Number(process.env.GB_DOCPERF_ROUNDS || 3) +// Under node_modules so the external pdfjs-dist resolves from the repository. +const directory = join(root, 'node_modules', '.cache', `goodbuddy-document-parse-perf-${process.pid}`) +mkdirSync(directory, { recursive: true }) +const harness = join(directory, 'harness.cjs') +const workerBundle = join(directory, 'document-parse-worker.cjs') +const harnessSource = join(directory, 'harness.ts') +writeFileSync(harnessSource, [ + `export * from ${JSON.stringify(join(root, 'src/main/document-parse-client.ts'))}`, + `export * from ${JSON.stringify(join(root, 'tests/support/document-parse-worker-fixture.ts'))}` +].join('\n')) +for (const [entry, outfile] of [[harnessSource, harness], [join(root, 'src/main/document-parse-worker.ts'), workerBundle]]) { + buildSync({ + entryPoints: [entry], bundle: true, platform: 'node', format: 'cjs', target: 'node22', + outfile, logLevel: 'error', external: ['pdfjs-dist', 'electron', 'esbuild'] + }) +} +const { + DocumentParseWorkerClient, chunkDocumentOffMain, parseDocumentOffMain, sha256OffMain, + createDocx, createLargePdf, sentenceGenerator +} = require(harness) + +const settings = { + version: 1, mode: 'structure', targetCharacters: 1600, overlapCharacters: 100, + parentCharacters: 1600, childCharacters: 400, contextualIndexingEnabled: false +} +const round = value => Math.round(value * 10) / 10 + +async function importOnce(name, buffer, client) { + await sha256OffMain(buffer, client) + const parsed = await parseDocumentOffMain(name, buffer, undefined, client) + return (await chunkDocumentOffMain(parsed, settings, client)).length +} + +async function measure(name, buffer, client) { + await importOnce(name, buffer, client) // warm modules / worker + await new Promise(resolve => setTimeout(resolve, 50)) + const histogram = monitorEventLoopDelay({ resolution: 5 }) + histogram.enable() + // The histogram only measures from its first timer tick on; a block that + // starts before that tick (the inline path is all microtasks) is not seen. + await new Promise(resolve => setTimeout(resolve, 30)) + const started = performance.now() + const chunks = await importOnce(name, buffer, client) + const wallMs = performance.now() - started + await new Promise(resolve => setTimeout(resolve, 20)) + histogram.disable() + return { maxMs: round(histogram.max / 1e6), p99Ms: round(histogram.percentile(99) / 1e6), wallMs: round(wallMs), chunks } +} + +async function main() { + console.log(`[docperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`) + const sentence = sentenceGenerator(11) + const inputs = [ + { name: 'large.pdf', buffer: createLargePdf(500, 60) }, + { name: 'large.docx', buffer: createDocx(Array.from({ length: 4000 }, () => sentence(25))) }, + { name: 'large.md', buffer: Buffer.from(Array.from({ length: 4000 }, (_, index) => `${index % 50 === 0 ? '## Section\n' : ''}${sentence(60)}.`).join('\n')) } + ] + const client = new DocumentParseWorkerClient(workerBundle) + const results = [] + try { + for (const input of inputs) { + for (let index = 0; index < rounds; index += 1) { + // Alternate inline / worker within each round (P7). + const inline = await measure(input.name, input.buffer, undefined) + const worker = await measure(input.name, input.buffer, client) + if (worker.chunks !== inline.chunks) throw new Error(`chunk count mismatch for ${input.name}`) + results.push({ input: input.name, bytes: input.buffer.byteLength, round: index + 1, inline, worker }) + console.log(`[docperf] ${input.name} round ${index + 1}: Main loop max ${inline.maxMs} -> ${worker.maxMs} ms; wall ${inline.wallMs} / ${worker.wallMs} ms; ${inline.chunks} chunks`) + } + } + } finally { + client.close() + } + const output = process.env.GB_DOCPERF_OUTPUT + if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-document-parse-benchmark', node: process.version, results }, null, 2)) + console.table(results.map(r => ({ input: r.input, round: r.round, 'loop max inline/worker ms': `${r.inline.maxMs} / ${r.worker.maxMs}`, 'wall inline/worker ms': `${r.inline.wallMs} / ${r.worker.wallMs}` }))) +} +main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => { + rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) +}) diff --git a/build/run-startup-perf.cjs b/build/run-startup-perf.cjs new file mode 100644 index 00000000..ca5239d5 --- /dev/null +++ b/build/run-startup-perf.cjs @@ -0,0 +1,360 @@ +// Cold-start benchmark supervisor (PERF-01). +// Launches the current production build (out/, from `npm run build:bundle`) +// GB_PERF_STARTUP_RUNS times (default 5) per variant through +// tests/support/startup-perf-driver.mjs, each launch in a fresh temporary +// user-data directory, and reports median / p95 per startup phase. +// +// Variants (interleaved each round, per docs P7): +// first-run empty user-data dir (creates databases, shows release notes) +// empty copy of a prepared profile: release notes seen, startup update +// check off, 0 conversations +// seeded-300 same as empty plus GB_PERF_STARTUP_SEED (default 300) +// synthetic conversations, reusing the PERF-11 seed shape +// +// Isolation: temporary profile/session/logs, a loopback fake model on an +// ephemeral port (GB_PERF_MODEL_PORT to pin one; 11434 is left alone), all +// non-loopback requests blocked in the driver. Output has phase names, times +// and counts only. +// +// Env: GB_PERF_STARTUP_RUNS, GB_PERF_STARTUP_SEED, GB_PERF_STARTUP_VARIANTS +// (comma list), GB_PERF_STARTUP_SKIP_BUILD=1, GB_PERF_STARTUP_PROFILE=1 (one +// extra CPU-profiled launch per variant, excluded from the table), +// GB_PERF_OUTPUT (report directory), GB_PERF_KEEP_PROFILE=1, +// GB_PERF_STARTUP_APP_ROOT (checkout/worktree to build and launch; default .). +const { spawn, spawnSync, execFileSync } = require('node:child_process') +const { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } = require('node:fs') +const { createServer } = require('node:http') +const { cpus, tmpdir, totalmem } = require('node:os') +const { join, resolve } = require('node:path') +const { npmInvocation } = require('./npm-invocation.cjs') + +const root = resolve(__dirname, '..') +// The app under test. Defaults to this checkout; point it at a temporary +// worktree to measure a baseline or a clean HEAD while this tree is mid-edit. +const appRoot = resolve(process.env.GB_PERF_STARTUP_APP_ROOT || root) +const runs = Math.max(1, Number(process.env.GB_PERF_STARTUP_RUNS || 5)) +const seedCount = Number(process.env.GB_PERF_STARTUP_SEED || 300) +const seededName = `seeded-${seedCount}` +const variants = (process.env.GB_PERF_STARTUP_VARIANTS || `first-run,empty,${seededName}`).split(',').map(v => v.trim()).filter(Boolean) +const requestedPort = Number(process.env.GB_PERF_MODEL_PORT || 0) +const launchTimeoutMs = Number(process.env.GB_PERF_STARTUP_LAUNCH_TIMEOUT_MS || 150_000) +const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1' +const cpuProfile = process.env.GB_PERF_STARTUP_PROFILE === '1' + +// ------------------------------------------------------------------ build + +function newestMtime(path) { + let newest = 0 + const stack = [path] + while (stack.length) { + const current = stack.pop() + let info + try { info = statSync(current) } catch { continue } + if (info.isDirectory()) { + for (const entry of readdirSync(current)) if (entry !== 'node_modules') stack.push(join(current, entry)) + } else if (!/\.test\.tsx?$/.test(current)) { + newest = Math.max(newest, info.mtimeMs) + } + } + return newest +} + +function ensureBuild() { + const outputs = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html'].map(file => join(appRoot, file)) + const missing = outputs.some(file => !existsSync(file)) + const builtAt = missing ? 0 : Math.min(...outputs.map(file => statSync(file).mtimeMs)) + const sourceAt = Math.max(...['src/main', 'src/preload', 'src/renderer', 'src/shared', 'electron.vite.config.ts', 'package.json'].map(p => newestMtime(join(appRoot, p)))) + const stale = missing || sourceAt > builtAt + if (!stale) return { rebuilt: false } + if (process.env.GB_PERF_STARTUP_SKIP_BUILD === '1') { + if (missing) throw new Error('Missing production build and GB_PERF_STARTUP_SKIP_BUILD=1') + console.log('[startup] build is older than src/, measuring it anyway (GB_PERF_STARTUP_SKIP_BUILD=1)') + return { rebuilt: false, stale: true } + } + console.log(`[startup] ${missing ? 'no production build' : 'production build is older than src/'}; running npm run build:bundle`) + const npm = npmInvocation(appRoot) + const result = spawnSync(npm.command, [...npm.prefixArgs, 'run', 'build:bundle'], { cwd: appRoot, stdio: 'inherit', shell: npm.prefixArgs.length === 0 && process.platform === 'win32' }) + if (result.status !== 0) throw new Error(`npm run build:bundle failed (${result.status})`) + return { rebuilt: true } +} + +// -------------------------------------------------------------- fake model + +function startModelServer(ledger) { + const server = createServer((request, response) => { + ledger.push({ method: request.method, path: request.url }) + if (request.method === 'GET' && request.url?.endsWith('/models')) { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] })) + return + } + if (request.method === 'POST' && request.url?.endsWith('/chat/completions')) { + request.resume() + request.on('end', () => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ id: 'perf', object: 'chat.completion', model: 'qwen3', choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'ok' } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })) + }) + return + } + response.writeHead(404).end() + }) + return new Promise((resolvePromise, reject) => { + server.once('error', reject) + server.listen(requestedPort, '127.0.0.1', () => resolvePromise(server)) + }) +} + +// ------------------------------------------------------------------ launch + +function baseEnvironment(modelPort, workspace) { + const environment = { ...process.env, GB_PERF_ROOT: appRoot, GB_PERF_STARTUP_MARKS: '1' } + delete environment.ELECTRON_RUN_AS_NODE + delete environment.ELECTRON_RENDERER_URL + for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key] + environment.GOODBUDDY_MODEL_API_KEY = 'perf-loopback' + environment.GOODBUDDY_MODEL_BASE_URL = `http://127.0.0.1:${modelPort}/v1` + environment.GOODBUDDY_MODEL_NAME = 'qwen3' + environment.GOODBUDDY_WORKSPACE = workspace + return environment +} + +async function launch({ directory, environment, mode = 'measure', seed = 0, expectConversations = 0, profilePath }) { + const resultPath = join(directory, 'startup-result.json') + const env = { ...environment, GB_STARTUP_DIR: directory, GB_STARTUP_RESULT: resultPath, GB_STARTUP_MODE: mode, GB_STARTUP_SEED: String(seed), GB_STARTUP_EXPECT_CONVERSATIONS: String(expectConversations) } + if (profilePath) env.GB_STARTUP_CPU_PROFILE = profilePath + const stderr = [] + const spawnAt = Date.now() + const child = spawn(require('electron'), [join(root, 'tests', 'support', 'startup-perf-driver.mjs')], { cwd: root, env, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: false }) + child.stderr.on('data', chunk => { if (stderr.length < 200) stderr.push(String(chunk)) }) + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }) + else child.kill('SIGKILL') + }, launchTimeoutMs) + const exit = await new Promise(resolvePromise => child.once('exit', code => resolvePromise(code))) + const exitAt = Date.now() + clearTimeout(timer) + const report = existsSync(resultPath) ? JSON.parse(readFileSync(resultPath, 'utf8')) : { status: 'failed', error: timedOut ? 'launch timeout' : 'driver exited without a report' } + if (exit !== 0 || timedOut) report.status = 'failed' + report.spawnAt = spawnAt + report.exitMs = exitAt - spawnAt + // stderr is kept only for failures, and only its first line (no user data exists in these profiles). + if (report.status !== 'passed') report.stderrHead = stderr.join('').split(/\r?\n/).filter(Boolean).slice(0, 8) + return report +} + +// --------------------------------------------------------------- analysis + +function phases(report) { + const t = report.times + const r = report.renderer + const s = report.spawnAt + const marks = Object.fromEntries((report.startupMarks ?? []).map(m => [m.name, m.at])) + const d = (from, to) => (from === undefined || from === null || to === undefined || to === null) ? null : to - from + const result = { + 'spawn -> driver start (Electron boot)': d(s, t.driverStart), + 'driver start -> Main import done': d(t.driverStart, t.mainImportEnd), + 'spawn -> app ready': d(s, t.appReady), + 'app ready -> BrowserWindow created': d(t.appReady, t.windowCreated), + 'window created -> loadMainWindow': d(t.windowCreated, marks['main:load-main-window']), + 'loadMainWindow -> ready-to-show': d(marks['main:load-main-window'], t.readyToShow), + 'window created -> ready-to-show': d(t.windowCreated, t.readyToShow), + 'ready-to-show -> first visible frame': d(t.readyToShow, r.firstVisibleFrame), + 'ready-to-show -> composer interactive': d(t.readyToShow, r.composerInteractive), + 'spawn -> ready-to-show': d(s, t.readyToShow), + 'spawn -> first visible frame': d(s, r.firstVisibleFrame), + // Only reported by Chromium when the first paint happens while visible. + 'spawn -> first contentful paint': d(s, r.firstContentfulPaint), + 'spawn -> composer present': d(s, r.composerPresent), + 'spawn -> composer interactive': d(s, r.composerInteractive), + 'spawn -> sidebar populated (seeded)': d(s, r.sidebarPopulated), + 'composer interactive -> sidebar populated': d(r.composerInteractive, r.sidebarPopulated) + } + // Main phases (GB_PERF_STARTUP_MARKS spans; parallel spans overlap). + const main = { + 'main: module import (index.js eval)': d(t.mainImportStart, marks['main:module-evaluated']), + 'main: whenReady -> window created': d(marks['main:when-ready'], marks['main:create-window:end']), + 'main: createMainWindow()': d(marks['main:create-window:start'], marks['main:create-window:end']), + 'main: tray': d(marks['main:create-window:end'], marks['main:tray-built']) + } + for (const m of report.startupMarks ?? []) { + if (!m.name.endsWith(':start')) continue + const base = m.name.slice(0, -':start'.length) + if (base === 'main:create-window') continue + main[`${base.replace(/^main:/, 'main: ')}`] = d(m.at, marks[`${base}:end`]) + // Synchronous part of the span: from the call until the first await yields. + if (marks[`${base}:sync-end`] !== undefined) main[`${base.replace(/^main:/, 'main: ')} (sync part)`] = d(m.at, marks[`${base}:sync-end`]) + } + main['main: whenReady -> loadMainWindow (total)'] = d(marks['main:when-ready'], marks['main:load-main-window']) + // Critical path in Main: consecutive top-level marks (spans that run in + // parallel inside the prerequisites are excluded), so each gap is the time + // Main spent between two points of the sequential startup function. + const nested = /^main:(knowledge-init|knowledge-gateway-start|runtime-hydrate|assistant-db-init)/ + const sequence = (report.startupMarks ?? []).filter(m => !nested.test(m.name) && !m.name.endsWith(':sync-end')).sort((a, b) => a.at - b.at) + for (let index = 1; index < sequence.length; index += 1) { + main[`gap: ${sequence[index - 1].name} -> ${sequence[index].name}`] = sequence[index].at - sequence[index - 1].at + } + const renderer = { + 'renderer: navigation start -> DOMContentLoaded': d(r.navigationStart, r.domContentLoaded), + 'renderer: DOMContentLoaded -> composer present': d(r.domContentLoaded, r.composerPresent), + 'renderer: navigation start -> composer present': d(r.navigationStart, r.composerPresent) + } + return { ...result, ...main, ...renderer } +} + +function percentile(values, p) { + if (!values.length) return null + const sorted = [...values].sort((a, b) => a - b) + // Nearest rank; with 5 samples p95 is the maximum. + return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))] +} + +function summarize(reportsByVariant) { + const rows = new Map() + for (const [variant, reports] of Object.entries(reportsByVariant)) { + const passed = reports.filter(r => r.status === 'passed') + const perRun = passed.map(phases) + for (const name of new Set(perRun.flatMap(Object.keys))) { + const values = perRun.map(p => p[name]).filter(v => typeof v === 'number' && Number.isFinite(v)) + const row = rows.get(name) ?? {} + row[variant] = values.length ? { median: percentile(values, 50), p95: percentile(values, 95), n: values.length } : null + rows.set(name, row) + } + } + return rows +} + +function sourceInfo() { + try { + const commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: appRoot, encoding: 'utf8' }).trim() + const dirtyFiles = execFileSync('git', ['status', '--porcelain'], { cwd: appRoot, encoding: 'utf8' }).split('\n').filter(Boolean).length + return { commit, dirty: dirtyFiles > 0, dirtyFiles, appRoot: appRoot === root ? '.' : 'GB_PERF_STARTUP_APP_ROOT' } + } catch { + return { commit: null } + } +} + +const fmt = v => (v === null || v === undefined ? '-' : Math.round(v).toString()) + +// ------------------------------------------------------------------- main + +async function main() { + const build = ensureBuild() + const runDirectory = mkdtempSync(join(tmpdir(), 'goodbuddy-startup-perf-')) + const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts')) + mkdirSync(outputDirectory, { recursive: true }) + const workspace = join(runDirectory, 'workspace') + mkdirSync(workspace, { recursive: true }) + const ledger = [] + let server + try { + server = await startModelServer(ledger) + } catch (error) { + throw new Error(`Cannot listen on 127.0.0.1:${requestedPort} for the fake model (${error.code ?? error.message}); set GB_PERF_MODEL_PORT to a free port or leave it unset for an ephemeral port`, { cause: error }) + } + const modelPort = server.address().port + const environment = baseEnvironment(modelPort, workspace) + const templates = {} + const prepareReports = {} + try { + // Templates are prepared once and copied per launch, so every measured + // launch starts from an identical, never-used-before directory. + for (const variant of variants) { + if (variant === 'first-run') continue + const seed = variant === 'empty' ? 0 : variant === seededName ? seedCount : null + if (seed === null) throw new Error(`Unknown variant ${variant}`) + const directory = join(runDirectory, `template-${variant}`) + mkdirSync(directory, { recursive: true }) + console.log(`[startup] preparing ${variant} profile${seed ? ` (${seed} conversations)` : ''}`) + const report = await launch({ directory, environment, mode: 'prepare', seed }) + prepareReports[variant] = { status: report.status, error: report.error, seeded: report.seeded, exitMs: report.exitMs, stderrHead: report.stderrHead } + if (report.status !== 'passed') throw new Error(`Preparing ${variant} failed: ${report.error ?? 'unknown'}\n${(report.stderrHead ?? []).join('\n')}`) + rmSync(join(directory, 'startup-result.json'), { force: true }) + templates[variant] = directory + } + + const fresh = (variant, label) => { + const directory = join(runDirectory, `${label}-${variant}`) + if (templates[variant]) { + cpSync(templates[variant], directory, { recursive: true }) + } else { + mkdirSync(directory, { recursive: true }) + } + return directory + } + + const expectedFor = variant => (variant === seededName ? seedCount : 0) + const results = Object.fromEntries(variants.map(v => [v, []])) + // A discarded warm-up launch per invocation so the first measured launch + // does not also pay for loading Electron binaries from a cold disk cache. + console.log('[startup] warm-up launch (discarded)') + await launch({ directory: fresh(variants.includes('empty') ? 'empty' : variants[0], 'warmup'), environment }) + for (let round = 0; round < runs; round += 1) { + for (const variant of variants) { + const directory = fresh(variant, `run${round + 1}`) + const report = await launch({ directory, environment, expectConversations: expectedFor(variant) }) + report.round = round + 1 + results[variant].push(report) + const p = report.status === 'passed' ? phases(report) : {} + console.log(`[startup] round ${round + 1} ${variant}: ${report.status}, ready ${fmt(p['spawn -> app ready'])} ms, ready-to-show ${fmt(p['spawn -> ready-to-show'])} ms, first frame ${fmt(p['spawn -> first visible frame'])} ms, composer interactive ${fmt(p['spawn -> composer interactive'])} ms${report.error ? `, error ${report.error}` : ''}`) + if (!keepProfile) rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + } + } + + const profiles = {} + if (cpuProfile) { + for (const variant of variants) { + const directory = fresh(variant, 'profiled') + const profilePath = join(outputDirectory, `main-startup-${variant}.cpuprofile`) + const report = await launch({ directory, environment, profilePath, expectConversations: expectedFor(variant) }) + profiles[variant] = { status: report.status, cpuProfile: report.cpuProfile, phases: report.status === 'passed' ? phases(report) : null, file: profilePath } + console.log(`[startup] CPU-profiled ${variant}: ${report.status} -> ${profilePath}`) + } + } + + const table = summarize(results) + const report = { + schemaVersion: 1, + kind: 'goodbuddy-startup-benchmark', + source: { ...sourceInfo(), build }, + environment: { platform: process.platform, arch: process.arch, electron: require('electron/package.json').version, cpu: `${cpus()[0]?.model?.trim()} x${cpus().length}`, memoryGB: Math.round(totalmem() / 1073741824) }, + config: { runs, variants, seedConversations: seedCount, percentile: 'nearest-rank' }, + prepare: prepareReports, + phases: Object.fromEntries(table), + runs: Object.fromEntries(Object.entries(results).map(([variant, reports]) => [variant, reports.map(r => ({ + round: r.round, status: r.status, error: r.error, stderrHead: r.stderrHead, exitMs: r.exitMs, + phases: r.status === 'passed' ? phases(r) : null, + renderer: r.renderer ? { domNodes: r.renderer.domNodes, conversationRows: r.renderer.conversationRows, scriptResources: r.renderer.scriptResources, scriptBytes: r.renderer.scriptBytes, jsHeapMB: r.renderer.jsHeapMB, startupDialog: r.renderer.startupDialog !== undefined, composerPresentBeforeProbe: r.renderer.alreadyAtInstall === true, longAnimationFrames: r.renderer.longAnimationFrames && { count: r.renderer.longAnimationFrames.count, totalMs: r.renderer.longAnimationFrames.totalMs, maxMs: r.renderer.longAnimationFrames.maxMs, scriptMs: r.renderer.longAnimationFrames.scriptMs } } : null, + mainEventLoopDelay: r.mainEventLoopDelay, + blockedRequests: r.blockedRequests && { fetch: r.blockedRequests.fetch.length, renderer: r.blockedRequests.renderer.length, hosts: [...new Set([...r.blockedRequests.fetch, ...r.blockedRequests.renderer])] } + }))])), + cpuProfiles: profiles, + modelRequests: { boundary: 'loopback fake model only; non-loopback requests blocked', total: ledger.length, paths: [...new Set(ledger.map(e => `${e.method} ${e.path}`))] } + } + const resultPath = join(outputDirectory, 'startup-result.json') + writeFileSync(resultPath, JSON.stringify(report, null, 2)) + + console.log(`\nGoodBuddy cold-start benchmark: ${runs} runs per variant, ms (median / p95, nearest rank)`) + console.log(`source ${report.source.commit ?? '?'}${report.source.dirty ? ` (dirty, ${report.source.dirtyFiles} files)` : ''}, Electron ${report.environment.electron}, ${report.environment.cpu}, ${report.environment.memoryGB} GB`) + console.table(Object.fromEntries([...table].map(([name, row]) => [name, Object.fromEntries(variants.map(v => [v, row[v] ? `${fmt(row[v].median)} / ${fmt(row[v].p95)}${row[v].n < runs ? ` (n=${row[v].n})` : ''}` : '-']))]))) + const failed = Object.values(results).flat().filter(r => r.status !== 'passed').length + if (failed) console.log(`${failed} launch(es) failed; see report`) + const blocked = Object.values(results).flat().flatMap(r => [...(r.blockedRequests?.fetch ?? []), ...(r.blockedRequests?.renderer ?? [])]) + console.log(`blocked non-loopback requests: ${blocked.length}${blocked.length ? ` (${[...new Set(blocked)].join(', ')})` : ''}; fake model requests: ${ledger.length}`) + console.log(`\nreport: ${resultPath}`) + process.exitCode = failed ? 1 : 0 + } finally { + await new Promise(resolvePromise => server.close(() => resolvePromise())) + if (!keepProfile) { + for (const entry of readdirSync(runDirectory)) { + if (entry !== 'artifacts') rmSync(join(runDirectory, entry), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + } + } + } +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4f62eb7f..edc217d6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -134,6 +134,7 @@ export default defineConfig({ 'assistant-storage-worker': resolve('src/main/assistant-storage-worker.ts'), 'execution-stats-worker': resolve('src/main/execution-stats-worker.ts'), 'readonly-query-worker': resolve('src/main/readonly-query-worker.ts'), + 'document-parse-worker': resolve('src/main/document-parse-worker.ts'), 'wechat-sidecar': resolve( 'src/main/channels/wechat-sidecar.ts' ), diff --git a/package.json b/package.json index a9f053ae..904a0650 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,10 @@ "test:watch": "vitest", "perf:app": "node build/run-app-perf.cjs", "perf:check": "node build/check-perf-thresholds.cjs", + "perf:document-parse": "node build/run-document-parse-perf.cjs", "perf:knowledge": "node build/run-knowledge-perf.cjs", "perf:main": "node build/run-main-perf.cjs", + "perf:startup": "node build/run-startup-perf.cjs", "eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts", "build": "npm run typecheck && npm run build:bundle", "build:bundle": "electron-vite build && node build/check-control-plane-package-installer.cjs", diff --git a/src/main/document-parse-client.ts b/src/main/document-parse-client.ts new file mode 100644 index 00000000..263c972a --- /dev/null +++ b/src/main/document-parse-client.ts @@ -0,0 +1,375 @@ +import { createHash } from 'node:crypto' +import { Worker } from 'node:worker_threads' +import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts' +import { + assertDocumentBuffer, + chunkDocumentAdvanced, + DocumentTextUnavailableError, + extractPdfTextPages, + parseDocument, + type DocumentChunk, + type ParsedDocument, + type PdfTextExtraction, + type PdfTextExtractionOptions +} from './knowledge/document-parser' +import { extractPptxPages, type PptxPage } from './knowledge/pptx-parser' +import type { + DocumentParseOperation, + DocumentParseRequest, + DocumentParseResponse, + SerializedDocumentParseError +} from './document-parse-errors' + +export type { DocumentParseOperation } from './document-parse-errors' + +// PERF-15/16: pure document work (PDF / Office / text parsing, chunking, +// hashing) runs in a single worker thread with a FIFO queue, so Main only +// orchestrates. Database writes stay on the caller's side, in the same order. +// Same fallback rules as readonly-query-reader: if the worker cannot start or +// crashes, the call runs inline (the previous behaviour) and the worker is +// retried after a backoff. Aborting a running task terminates the worker. + +/** Worker infrastructure failure; callers fall back to the inline path. */ +export class DocumentParseWorkerUnavailableError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'DocumentParseWorkerUnavailableError' + } +} + +type Task = { + id: number + op: DocumentParseOperation + args: unknown[] + transfer: ArrayBuffer[] + resolve: (value: unknown) => void + reject: (error: unknown) => void + cleanup: () => void +} + +const MAX_QUEUED = 32 +const RESTART_BACKOFF_MS = 30_000 + +type PdfExceptionConstructor = new (message: string) => Error +type PdfExceptionClasses = Partial> +const pdfExceptionNames = [ + 'AbortException', + 'InvalidPDFException', + 'PasswordException', + 'ResponseException', + 'UnknownErrorException' +] as const + +let pdfExceptions: Promise | undefined + +/** PDF.js exception classes, loaded only once a PDF error crosses the boundary. */ +function loadPdfExceptions(): Promise { + pdfExceptions ??= import('pdfjs-dist/legacy/build/pdf.mjs').then( + (pdfjs) => { + const module = pdfjs as unknown as Record + return Object.fromEntries(pdfExceptionNames.flatMap((name) => + typeof module[name] === 'function' ? [[name, module[name] as PdfExceptionConstructor]] : [])) + }, + () => ({}) + ) + return pdfExceptions +} + +/** + * Rebuilds an error with the same class, name, message, cause and primitive + * fields as the one thrown inline (stack traces differ by nature). + */ +export async function deserializeDocumentParseError(error: SerializedDocumentParseError): Promise { + let result: Error + if (error.name === 'DocumentTextUnavailableError') { + result = new DocumentTextUnavailableError(error.message) + } else if ((pdfExceptionNames as readonly string[]).includes(error.name)) { + const Constructor = (await loadPdfExceptions())[error.name] + result = Constructor ? new Constructor(error.message) : new Error(error.message) + } else { + const Constructor = error.name === 'RangeError' ? RangeError + : error.name === 'TypeError' ? TypeError : Error + result = new Constructor(error.message) + } + if (result.name !== error.name) result.name = error.name + if (error.cause) { + Object.defineProperty(result, 'cause', { + value: await deserializeDocumentParseError(error.cause), writable: true, configurable: true + }) + } + for (const [key, value] of Object.entries(error.extra ?? {})) { + Object.defineProperty(result, key, { value, writable: true, configurable: true, enumerable: true }) + } + return result +} + +export class DocumentParseWorkerClient { + private worker?: Worker + private nextId = 0 + private disabledUntil = 0 + private closed = false + private active?: Task + private readonly queue: Task[] = [] + + constructor( + private readonly workerPath: string, + private readonly now: () => number = Date.now + ) {} + + /** False while closed, backing off after a crash, or when the queue is full. */ + get available(): boolean { + return !this.closed && this.now() >= this.disabledUntil && + this.queue.length < MAX_QUEUED + } + + get pendingCount(): number { + return this.queue.length + (this.active ? 1 : 0) + } + + call( + op: DocumentParseOperation, + args: unknown[], + transfer: ArrayBuffer[] = [], + signal?: AbortSignal + ): Promise { + if (this.closed) return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker closed')) + if (signal?.aborted) return Promise.reject(signal.reason) + if (this.now() < this.disabledUntil) { + return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker is backing off')) + } + if (this.queue.length >= MAX_QUEUED) { + return Promise.reject(new DocumentParseWorkerUnavailableError('Document parse worker is busy')) + } + return new Promise((resolve, reject) => { + const task: Task = { + id: ++this.nextId, + op, + args, + transfer, + resolve: resolve as (value: unknown) => void, + reject, + cleanup: () => signal?.removeEventListener('abort', onAbort) + } + const onAbort = (): void => { + if (this.active === task) { + // The task is CPU-bound in the worker; stop it by terminating the worker. + this.active = undefined + const worker = this.worker + this.worker = undefined + if (worker) void worker.terminate() + } else { + const index = this.queue.indexOf(task) + if (index < 0) return + this.queue.splice(index, 1) + } + task.cleanup() + reject(signal!.reason) + this.pump() + } + signal?.addEventListener('abort', onAbort, { once: true }) + this.queue.push(task) + this.pump() + }) + } + + private pump(): void { + if (this.active || this.closed) return + const task = this.queue.shift() + if (!task) { + this.worker?.unref() + return + } + let worker: Worker + try { + worker = this.ensureWorker() + } catch (error) { + this.disabledUntil = this.now() + RESTART_BACKOFF_MS + this.failAll(task, new DocumentParseWorkerUnavailableError('Document parse worker failed to start', { cause: error })) + return + } + this.active = task + // Keep the process alive only while a task is outstanding. + worker.ref() + try { + worker.postMessage( + { id: task.id, op: task.op, args: task.args } satisfies DocumentParseRequest, + task.transfer + ) + } catch (error) { + this.active = undefined + task.cleanup() + task.reject(new DocumentParseWorkerUnavailableError('Document parse task could not be posted', { cause: error })) + this.pump() + } + } + + private failAll(first: Task | undefined, error: Error): void { + const tasks = [...(first ? [first] : []), ...this.queue.splice(0)] + for (const task of tasks) { + task.cleanup() + task.reject(error) + } + } + + private ensureWorker(): Worker { + if (this.worker) return this.worker + const worker = new Worker(this.workerPath) + this.worker = worker + worker.on('message', (message: DocumentParseResponse) => { + const task = this.active + if (this.worker !== worker || !task || task.id !== message.id) return + this.active = undefined + task.cleanup() + if ('error' in message) { + void deserializeDocumentParseError(message.error).then(task.reject) + } else { + task.resolve(message.result) + } + this.pump() + }) + const failed = (error: Error): void => { + // Ignore exits of workers that were terminated on purpose (abort, close). + if (this.worker !== worker) return + this.worker = undefined + this.disabledUntil = this.now() + RESTART_BACKOFF_MS + const active = this.active + this.active = undefined + this.failAll(active, new DocumentParseWorkerUnavailableError(error.message, { cause: error })) + void worker.terminate() + } + worker.once('error', failed) + worker.once('exit', (code) => failed(new Error(`Document parse worker exited (${code})`))) + worker.unref() + return worker + } + + /** Test hook: simulate a crash by terminating the worker. */ + async terminateWorkerForTest(): Promise { + await this.worker?.terminate() + } + + /** Test hook: end the post-crash backoff. */ + resetBackoffForTest(): void { + this.disabledUntil = 0 + } + + close(): void { + this.closed = true + const worker = this.worker + this.worker = undefined + const active = this.active + this.active = undefined + this.failAll(active, new DocumentParseWorkerUnavailableError('Document parse worker closed')) + if (worker) void worker.terminate() + } +} + +let sharedClient: DocumentParseWorkerClient | undefined + +/** Enables the parse worker for this process (Main calls it once at startup). */ +export function configureDocumentParseWorker(workerPath: string | undefined): void { + sharedClient?.close() + sharedClient = workerPath ? new DocumentParseWorkerClient(workerPath) : undefined +} + +export function closeDocumentParseWorker(): void { + sharedClient?.close() + sharedClient = undefined +} + +/** A private copy of the bytes that can be transferred without detaching the caller's buffer. */ +function transferableCopy(buffer: Uint8Array): ArrayBuffer { + return Uint8Array.prototype.slice.call(buffer).buffer as ArrayBuffer +} + +/** + * Runs `op` in the worker when available, otherwise inline. Worker + * infrastructure failures fall back to the inline path; operation errors and + * aborts propagate unchanged. An already-aborted signal also runs inline, so + * validation and abort errors surface in exactly the inline order. + */ +export async function runOffMain( + client: DocumentParseWorkerClient | undefined, + op: DocumentParseOperation, + request: () => { args: unknown[]; transfer?: ArrayBuffer[] }, + signal: AbortSignal | undefined, + inline: () => T | Promise +): Promise { + if (!client?.available || signal?.aborted) return inline() + const { args, transfer } = request() + try { + return await client.call(op, args, transfer, signal) + } catch (error) { + if (error instanceof DocumentParseWorkerUnavailableError && !signal?.aborted) return inline() + throw error + } +} + +export function parseDocumentOffMain( + name: string, + buffer: Buffer, + signal?: AbortSignal, + client: DocumentParseWorkerClient | undefined = sharedClient +): Promise { + // Cheap size checks stay inline so oversized input is never copied. + try { + assertDocumentBuffer(buffer) + } catch (error) { + return Promise.reject(error) + } + return runOffMain(client, 'parseDocument', () => { + const bytes = transferableCopy(buffer) + return { args: [name, bytes], transfer: [bytes] } + }, signal, () => parseDocument(name, buffer, signal)) +} + +export function extractPdfTextPagesOffMain( + buffer: Buffer, + options: PdfTextExtractionOptions = {}, + client: DocumentParseWorkerClient | undefined = sharedClient +): Promise { + const { signal, ...limits } = options + return runOffMain(client, 'extractPdfTextPages', () => { + const bytes = transferableCopy(buffer) + return { args: [bytes, limits], transfer: [bytes] } + }, signal, () => extractPdfTextPages(buffer, options)) +} + +export function extractPptxPagesOffMain( + buffer: Buffer, + client: DocumentParseWorkerClient | undefined = sharedClient +): Promise { + return runOffMain(client, 'extractPptxPages', () => { + const bytes = transferableCopy(buffer) + return { args: [bytes], transfer: [bytes] } + }, undefined, () => extractPptxPages(buffer)) +} + +/** + * Not cancellable on purpose: the inline call it replaces was synchronous, so + * an abort during chunking keeps surfacing at the caller's next checkpoint. + */ +export function chunkDocumentOffMain( + document: ParsedDocument, + settings: KnowledgeChunkingSettings, + client: DocumentParseWorkerClient | undefined = sharedClient +): Promise { + return runOffMain(client, 'chunkDocument', () => ({ + // Only the fields chunking reads; images (Buffers) would otherwise be cloned. + args: [{ title: document.title, sourceFormat: document.sourceFormat, content: '', sections: document.sections, warnings: [] }, settings] + }), undefined, () => chunkDocumentAdvanced(document, settings)) +} + +/** Hex SHA-256 of `buffer`, computed in the worker for large inputs. */ +export function sha256OffMain( + buffer: Uint8Array, + client: DocumentParseWorkerClient | undefined = sharedClient +): Promise { + const inline = (): string => createHash('sha256').update(buffer).digest('hex') + // Small inputs hash faster than a round trip. + if (buffer.byteLength < 1024 * 1024) return Promise.resolve().then(inline) + return runOffMain(client, 'sha256', () => { + const bytes = transferableCopy(buffer) + return { args: [bytes], transfer: [bytes] } + }, undefined, inline) +} diff --git a/src/main/document-parse-errors.ts b/src/main/document-parse-errors.ts new file mode 100644 index 00000000..9f5a2eaa --- /dev/null +++ b/src/main/document-parse-errors.ts @@ -0,0 +1,28 @@ +// Wire format shared by document-parse-client (Main) and document-parse-worker. + +export type DocumentParseOperation = + | 'parseDocument' + | 'extractPdfTextPages' + | 'extractPptxPages' + | 'chunkDocument' + | 'sha256' + +export type DocumentParseRequest = { + id: number + op: DocumentParseOperation + args: unknown[] +} + +export type SerializedDocumentParseError = { + name: string + message: string + /** Primitive own properties some error types carry (PDF.js, Node). */ + extra?: Record + cause?: SerializedDocumentParseError +} + +export type DocumentParseResponse = + | { id: number; result: unknown } + | { id: number; error: SerializedDocumentParseError } + +export const documentParseErrorExtraKeys = ['code', 'status', 'missing', 'details', 'extraDelay'] as const diff --git a/src/main/document-parse-worker.test.ts b/src/main/document-parse-worker.test.ts new file mode 100644 index 00000000..0bafe9ef --- /dev/null +++ b/src/main/document-parse-worker.test.ts @@ -0,0 +1,284 @@ +// @vitest-environment node +import { createHash } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import { afterAll, beforeAll, describe, expect, it, onTestFinished } from 'vitest' +import { strToU8, zipSync } from 'fflate' +import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts' +import { + buildDocumentParseWorker, + createDocx, + createLargePdf, + createPptx, + createTextPdf, + createXlsx, + sentenceGenerator +} from '../../tests/support/document-parse-worker-fixture' +import { createImagePptx } from '../../tests/support/pptx-fixture' +import { + chunkDocumentOffMain, + DocumentParseWorkerClient, + DocumentParseWorkerUnavailableError, + extractPdfTextPagesOffMain, + extractPptxPagesOffMain, + parseDocumentOffMain, + sha256OffMain +} from './document-parse-client' +import { + chunkDocumentAdvanced, + DocumentTextUnavailableError, + extractPdfTextPages, + parseDocument +} from './knowledge/document-parser' +import { extractPptxPages } from './knowledge/pptx-parser' + +let workerPath: string +let dispose: () => Promise +let client: DocumentParseWorkerClient + +beforeAll(async () => { + ({ workerPath, dispose } = await buildDocumentParseWorker()) + client = new DocumentParseWorkerClient(workerPath) +}, 60_000) +afterAll(async () => { + client?.close() + await dispose?.() +}) + +const sentence = sentenceGenerator(3) +const markdown = [ + '# GoodBuddy', + '知识内容。'.repeat(400), + '## 安装', + ...Array.from({ length: 40 }, (_, index) => `Step ${index}: ${sentence(30)}.`), + '### 配置\r\n' + sentence(200), + '# 附录', + sentence(600) +].join('\n') + +/** Fixture corpus: every format the parser accepts, plus edge cases. */ +const fixtures: Array<{ name: string; buffer: Buffer }> = [ + { name: 'notes.md', buffer: Buffer.from(markdown) }, + { name: 'plain.txt', buffer: Buffer.from(` ${sentence(5_000)} `) }, + { name: 'data.json', buffer: Buffer.from(JSON.stringify({ items: Array.from({ length: 200 }, (_, id) => ({ id, text: sentence(8) })) }, null, 2)) }, + { name: 'page.html', buffer: Buffer.from('

安全标题

网页正文

ab
') }, + { name: 'large.txt', buffer: Buffer.from('x'.repeat(5_000_100)) }, + { name: 'sample.pdf', buffer: createTextPdf([['PDF body text']]) }, + { name: 'multi.pdf', buffer: createTextPdf([['first line', 'second (line)'], [], ['third page', 'ends.']]) }, + { name: 'big.pdf', buffer: createLargePdf(40, 40) }, + { name: 'sample.docx', buffer: createDocx(['文档正文', '第二段', ''], [[['h1', 'h2'], ['v1', 'v2']]]) }, + { name: 'sample.xlsx', buffer: createXlsx([[['name', 'qty', 'note'], ['apple', '3', 'fresh']], [['b', '7', 'inline']]]) }, + { name: 'shared.xlsx', buffer: Buffer.from(zipSync({ 'xl/sharedStrings.xml': strToU8('表格内容') })) }, + { name: 'sample.pptx', buffer: createPptx(['幻灯片内容', 'second slide', 'third']) }, + { name: 'image.pptx', buffer: createPptx(['with image', 'two'], true) } +] + +const failureFixtures: Array<{ name: string; buffer: Buffer }> = [ + { name: 'archive.zip', buffer: Buffer.from('not supported') }, + { name: 'empty.txt', buffer: Buffer.alloc(0) }, + { name: 'large.txt', buffer: Buffer.alloc(20 * 1024 * 1024 + 1) }, + { name: 'expanded.docx', buffer: Buffer.from(zipSync({ 'word/document.xml': new Uint8Array(11 * 1024 * 1024) })) }, + { name: 'invalid.txt', buffer: Buffer.from([0xc3, 0x28]) }, + { name: 'nulls.txt', buffer: Buffer.from('a\0b\0c\0d') }, + { name: 'blank.md', buffer: Buffer.from(' \n\n ') }, + { name: 'broken.pdf', buffer: Buffer.from('%PDF-1.4 not really') }, + { name: 'empty.pdf', buffer: createTextPdf([[]]) }, + { name: 'corrupt.xlsx', buffer: Buffer.from('PK not a zip') } +] + +const chunkingModes: KnowledgeChunkingSettings[] = [ + { version: 1, mode: 'fixed', targetCharacters: 500, overlapCharacters: 50, parentCharacters: 4_800, childCharacters: 900, contextualIndexingEnabled: false }, + { version: 1, mode: 'structure', targetCharacters: 800, overlapCharacters: 80, parentCharacters: 4_800, childCharacters: 900, contextualIndexingEnabled: true }, + { version: 1, mode: 'parent-child', targetCharacters: 1_600, overlapCharacters: 100, parentCharacters: 1_600, childCharacters: 400, contextualIndexingEnabled: false } +] + +/** Byte-level comparison: JSON (key order) plus deep equality (undefined fields, typed arrays). */ +function expectIdentical(actual: unknown, expected: unknown): void { + expect(JSON.stringify(actual)).toBe(JSON.stringify(expected)) + expect(isDeepStrictEqual(normalizeBytes(actual), normalizeBytes(expected))).toBe(true) +} + +function normalizeBytes(value: unknown): unknown { + if (value instanceof Uint8Array) return { bytes: Buffer.from(value).toString('hex') } + if (Array.isArray(value)) return value.map(normalizeBytes) + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, normalizeBytes(item)])) + } + return value +} + +async function settle(run: () => Promise): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> { + try { + return { ok: true, value: await run() } + } catch (error) { + return { ok: false, error } + } +} + +function describeError(error: unknown): unknown { + return error instanceof Error + ? { constructor: error.constructor, name: error.name, message: error.message, + own: Object.entries(error).filter(([key]) => key !== 'stack'), + unavailable: error instanceof DocumentTextUnavailableError, + cause: describeError(error.cause) } + : error +} + +describe('document parse worker equivalence', () => { + it('parses every fixture to output identical to the inline parser', async () => { + for (const fixture of fixtures) { + const inline = await parseDocument(fixture.name, fixture.buffer) + const before = Buffer.from(fixture.buffer) + const offMain = await parseDocumentOffMain(fixture.name, fixture.buffer, undefined, client) + expectIdentical(offMain, inline) + // The caller's buffer is not detached or modified by the transfer. + expect(fixture.buffer.equals(before)).toBe(true) + } + }, 60_000) + + it('chunks every fixture identically in all chunking modes', async () => { + // large.txt (5M chars, no break points) is excluded: splitNatural's backwards + // boundary scan makes it take minutes on either path. + for (const fixture of fixtures.filter((item) => item.name !== 'large.txt')) { + const parsed = await parseDocument(fixture.name, fixture.buffer) + for (const settings of chunkingModes) { + const inline = await settle(async () => chunkDocumentAdvanced(parsed, settings)) + const offMain = await settle(() => chunkDocumentOffMain(parsed, settings, client)) + expect(offMain.ok).toBe(inline.ok) + if (inline.ok && offMain.ok) expectIdentical(offMain.value, inline.value) + else expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error)) + } + } + }, 60_000) + + it('extracts PDF pages and PPTX pages identically', async () => { + // Pre-existing PDF.js behaviour on the inline path: cancelling a text stream + // at the character limit can leave a stray "Controller is already closed". + const strayPdfRejection = (reason: unknown): void => { + if ((reason as { code?: string })?.code !== 'ERR_INVALID_STATE') throw reason + } + process.on('unhandledRejection', strayPdfRejection) + onTestFinished(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + process.removeListener('unhandledRejection', strayPdfRejection) + }) + for (const fixture of fixtures.filter((item) => item.name.endsWith('.pdf'))) { + for (const options of [{}, { maximumCharacters: 50 }, { maximumPages: 1 }]) { + const inline = await settle(() => extractPdfTextPages(fixture.buffer, options)) + const offMain = await settle(() => extractPdfTextPagesOffMain(fixture.buffer, options, client)) + if (inline.ok && offMain.ok) expectIdentical(offMain.value, inline.value) + else expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error)) + } + } + for (const buffer of [createPptx(['a', 'b'], true), createImagePptx('native', 3), createPptx(['plain'])]) { + const inline = extractPptxPages(buffer) + const offMain = await extractPptxPagesOffMain(buffer, client) + expectIdentical(offMain, inline) + for (const page of offMain) { + for (const image of page.images) expect(image.data).toBeInstanceOf(Uint8Array) + } + } + }, 60_000) + + it('hashes identically', async () => { + for (const buffer of [Buffer.alloc(0), Buffer.from('abc'), createLargePdf(40, 40), Buffer.alloc(3 * 1024 * 1024, 7)]) { + expect(await sha256OffMain(buffer, client)).toBe(createHash('sha256').update(buffer).digest('hex')) + } + }) + + it('maps errors to the same types and messages as the inline parser', async () => { + for (const fixture of failureFixtures) { + const inline = await settle(() => parseDocument(fixture.name, fixture.buffer)) + const offMain = await settle(() => parseDocumentOffMain(fixture.name, fixture.buffer, undefined, client)) + expect(inline.ok, fixture.name).toBe(false) + expect(offMain.ok, fixture.name).toBe(false) + expect(describeError(offMain.ok ? undefined : offMain.error), fixture.name) + .toEqual(describeError(inline.ok ? undefined : inline.error)) + } + const tooManySections = { + title: 'Too many', sourceFormat: '.txt', content: '', warnings: [], + sections: Array.from({ length: 10_001 }, (_, index) => ({ locator: `s-${index}`, content: 'content' })) + } + const inline = await settle(async () => chunkDocumentAdvanced(tooManySections, chunkingModes[0]!)) + const offMain = await settle(() => chunkDocumentOffMain(tooManySections, chunkingModes[0]!, client)) + expect(describeError(offMain.ok ? undefined : offMain.error)).toEqual(describeError(inline.ok ? undefined : inline.error)) + const range = await settle(() => extractPdfTextPagesOffMain(createTextPdf([['x']]), { maximumPages: 0 }, client)) + expect(range.ok ? undefined : range.error).toBeInstanceOf(RangeError) + }) +}) + +describe('document parse worker lifecycle', () => { + it('cancels a running parse by terminating the worker and keeps serving later tasks', async () => { + const local = new DocumentParseWorkerClient(workerPath) + try { + const big = createLargePdf(400, 50) + const controller = new AbortController() + const reason = new Error('文档解析已取消') + const running = parseDocumentOffMain('big.pdf', big, controller.signal, local) + const queued = parseDocumentOffMain('notes.md', Buffer.from(markdown), undefined, local) + const queuedCancelled = new AbortController() + const queuedAbort = expect(parseDocumentOffMain('plain.txt', Buffer.from('x'), queuedCancelled.signal, local)).rejects.toBe(reason) + queuedCancelled.abort(reason) + await new Promise((resolve) => setTimeout(resolve, 30)) + controller.abort(reason) + await expect(running).rejects.toBe(reason) + await queuedAbort + expectIdentical(await queued, await parseDocument('notes.md', Buffer.from(markdown))) + expect(local.pendingCount).toBe(0) + } finally { + local.close() + } + }, 60_000) + + it('rejects with the abort reason when already aborted, without starting the worker', async () => { + const local = new DocumentParseWorkerClient(workerPath) + try { + const controller = new AbortController() + const reason = new Error('cancelled') + controller.abort(reason) + await expect(parseDocumentOffMain('a.txt', Buffer.from('a'), controller.signal, local)).rejects.toBe(reason) + } finally { + local.close() + } + }) + + it('falls back to the inline path when the worker crashes or cannot start', async () => { + const local = new DocumentParseWorkerClient(workerPath) + try { + const running = local.call('parseDocument', ['big.pdf', Uint8Array.from(createLargePdf(200, 50)).buffer]) + await new Promise((resolve) => setTimeout(resolve, 20)) + await local.terminateWorkerForTest() + await expect(running).rejects.toBeInstanceOf(DocumentParseWorkerUnavailableError) + expect(local.available).toBe(false) + // While backing off, calls run inline with identical output. + const buffer = Buffer.from(markdown) + expectIdentical(await parseDocumentOffMain('notes.md', buffer, undefined, local), await parseDocument('notes.md', buffer)) + local.resetBackoffForTest() + expect(local.available).toBe(true) + expectIdentical(await parseDocumentOffMain('notes.md', buffer, undefined, local), await parseDocument('notes.md', buffer)) + } finally { + local.close() + } + + const missing = new DocumentParseWorkerClient(`${workerPath}.missing.cjs`) + try { + const buffer = createDocx(['fallback']) + expectIdentical(await parseDocumentOffMain('a.docx', buffer, undefined, missing), await parseDocument('a.docx', buffer)) + expect(missing.available).toBe(false) + } finally { + missing.close() + } + }, 60_000) + + it('runs tasks one at a time in FIFO order', async () => { + const local = new DocumentParseWorkerClient(workerPath) + try { + const names = fixtures.slice(0, 6) + const results = await Promise.all(names.map((fixture) => parseDocumentOffMain(fixture.name, fixture.buffer, undefined, local))) + for (const [index, fixture] of names.entries()) { + expectIdentical(results[index], await parseDocument(fixture.name, fixture.buffer)) + } + } finally { + local.close() + } + }, 60_000) +}) diff --git a/src/main/document-parse-worker.ts b/src/main/document-parse-worker.ts new file mode 100644 index 00000000..60cf589e --- /dev/null +++ b/src/main/document-parse-worker.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto' +import { parentPort } from 'node:worker_threads' +import type { KnowledgeChunkingSettings } from '../shared/knowledge-contracts' +import { + chunkDocumentAdvanced, + extractPdfTextPages, + parseDocument, + type ParsedDocument +} from './knowledge/document-parser' +import { extractPptxPages } from './knowledge/pptx-parser' +import { + documentParseErrorExtraKeys, + type DocumentParseRequest, + type DocumentParseResponse, + type SerializedDocumentParseError +} from './document-parse-errors' + +// PERF-15/16: CPU-heavy, pure document work (PDF / Office / text parsing, +// chunking, hashing) runs here so Main only orchestrates. Every handler calls +// the same production function as the inline path, so results are identical. +// No database access and no file system access happen in this worker. + +type Handler = (args: unknown[]) => Promise | unknown + +const asBuffer = (value: unknown): Buffer => { + const bytes = value as ArrayBuffer + return Buffer.from(bytes, 0, bytes.byteLength) +} + +const handlers: Record = { + parseDocument: ([name, bytes]) => parseDocument(name as string, asBuffer(bytes)), + extractPdfTextPages: ([bytes, options]) => extractPdfTextPages( + asBuffer(bytes), + options as { maximumPages?: number; maximumCharacters?: number } + ), + extractPptxPages: ([bytes]) => extractPptxPages(asBuffer(bytes)), + chunkDocument: ([document, settings]) => chunkDocumentAdvanced( + document as ParsedDocument, + settings as KnowledgeChunkingSettings + ), + sha256: ([bytes]) => createHash('sha256').update(asBuffer(bytes)).digest('hex') +} + +/** ArrayBuffers backing typed arrays in the result (PPTX images); transferred, not copied. */ +function transferables(value: unknown): ArrayBuffer[] { + const buffers = new Set() + if (Array.isArray(value)) { + for (const page of value as Array<{ images?: Array<{ data?: unknown }> }>) { + for (const image of page?.images ?? []) { + const data = image?.data + if ( + data instanceof Uint8Array && + data.buffer instanceof ArrayBuffer && + data.byteOffset === 0 && + data.byteLength === data.buffer.byteLength + ) { + buffers.add(data.buffer) + } + } + } + } + return [...buffers] +} + +function serializeError(error: unknown, depth = 0): SerializedDocumentParseError { + if (!(error instanceof Error)) return { name: 'Error', message: String(error) } + const extra: Record = {} + for (const key of documentParseErrorExtraKeys) { + const value = (error as unknown as Record)[key] + if (value !== undefined && (typeof value !== 'object' || value === null)) extra[key] = value + } + return { + name: error.name, + message: error.message, + ...(Object.keys(extra).length > 0 ? { extra } : {}), + ...(error.cause !== undefined && depth < 3 ? { cause: serializeError(error.cause, depth + 1) } : {}) + } +} + +// PDF.js can leave a stray rejection ("Controller is already closed") after a +// text stream is cancelled at the character limit. Unhandled, it would kill +// this thread and push the next tasks onto the inline fallback. Task errors are +// awaited by the handler and still reported through the response. +process.on('unhandledRejection', () => undefined) + +parentPort!.on('message', (message: DocumentParseRequest) => { + void (async () => { + let response: DocumentParseResponse + let transfer: ArrayBuffer[] = [] + try { + const handler = Object.hasOwn(handlers, message.op) ? handlers[message.op] : undefined + if (!handler) throw new Error(`Unknown document parse operation: ${message.op}`) + const result = await handler(message.args) + transfer = message.op === 'extractPptxPages' ? transferables(result) : [] + response = { id: message.id, result } + } catch (error) { + response = { id: message.id, error: serializeError(error) } + } + parentPort!.postMessage(response, transfer) + })() +}) diff --git a/src/main/document-parsing-service.ts b/src/main/document-parsing-service.ts index c73fc9af..1b766571 100644 --- a/src/main/document-parsing-service.ts +++ b/src/main/document-parsing-service.ts @@ -18,12 +18,14 @@ import { HttpDocumentOcr } from './http-document-ocr' import { hasExtractedDocumentText } from './document-extracted-text' import { renderOcrPdf, renderSelectedOcrPdf } from './render-ocr-pdf' import type { DocumentResultStorage } from './document-result-storage' -import { extractPptxPages } from './knowledge/pptx-parser' +import { + extractPdfTextPagesOffMain, + extractPptxPagesOffMain, + parseDocumentOffMain +} from './document-parse-client' import { assertDocumentBuffer, DocumentTextUnavailableError, - extractPdfTextPages, - parseDocument, type ParsedDocument, type ParsedSection, type PdfTextPage @@ -279,10 +281,10 @@ export class DocumentParsingService { return this.parsePptx(name, buffer, purpose, operation, signal) } if (extname(name).toLowerCase() !== '.pdf') { - return parseDocument(name, buffer, signal) + return parseDocumentOffMain(name, buffer, signal) } - const extracted = await extractPdfTextPages(buffer, { signal }) + const extracted = await extractPdfTextPagesOffMain(buffer, { signal }) const { pages } = extracted ensureNotAborted(signal) const mode = effectiveOcrMode(settings, purpose) @@ -517,7 +519,7 @@ export class DocumentParsingService { const mode = effectiveOcrMode(settings, purpose) if (mode === 'disabled') { try { - return await parseDocument(name, buffer, signal) + return await parseDocumentOffMain(name, buffer, signal) } catch (error) { if (error instanceof DocumentTextUnavailableError) { throw new DocumentTextUnavailableError( @@ -527,7 +529,7 @@ export class DocumentParsingService { throw error } } - const pages = extractPptxPages(buffer) + const pages = await extractPptxPagesOffMain(buffer) ensureNotAborted(signal) const native: ParsedSection[] = pages .filter((page) => page.content) diff --git a/src/main/index.ts b/src/main/index.ts index 0fa95913..0b83fc23 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,4 @@ +import { startupMark, startupSpan } from './startup-marks' import { localInferenceService } from './local-inference-service' import { NativeClientCoordinator } from './agent/native-client-coordinator' import { createEmbeddingUtilityTransport } from './knowledge/embedding-utility-transport' @@ -98,6 +99,7 @@ import { DocumentParsingSettingsStore } from './document-parsing-settings-store' import { DocumentOcrModelManager } from './document-ocr-model-manager' import { DocumentOcrBroker } from './document-ocr-broker' import { DocumentParsingService } from './document-parsing-service' +import { configureDocumentParseWorker } from './document-parse-client' import { DocumentResultStorage } from './document-result-storage' import { ConversationAttachmentStorage } from './conversation-attachment-storage' import { ReleaseNotesService } from './release-notes-service' @@ -553,6 +555,7 @@ function buildTray(): Tray { const storageUpgradeController = new AbortController() let storageUpgrade: Promise | undefined +startupMark('main:module-evaluated') if (hasSingleInstanceLock) { app.on('second-instance', () => { if (mainWindow) { @@ -561,6 +564,7 @@ if (hasSingleInstanceLock) { }) void app.whenReady().then(async () => { + startupMark('main:when-ready') session.defaultSession.setPermissionRequestHandler( (webContents, permission, callback, details) => { const mediaTypes = @@ -582,15 +586,18 @@ if (hasSingleInstanceLock) { details.mediaType === 'audio' ) + startupMark('main:create-window:start') mainWindow = createMainWindow(() => isQuitting, observeDesktopFailure) + startupMark('main:create-window:end') registerDesktopNotificationActivation(mainWindow) tray = buildTray() + startupMark('main:tray-built') storageUpgrade = prepareAssistantStorage( mainWindow, join(app.getPath('userData'), 'assistant.sqlite'), storageUpgradeController.signal ) - await storageUpgrade + await startupSpan('main:storage-upgrade-check', () => storageUpgrade!) if (storageUpgradeController.signal.aborted) return const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir() const secureCipher = { @@ -670,7 +677,7 @@ if (hasSingleInstanceLock) { }, baseEnvironment: buildCredentialFilteredUserEnvironment() }) - await startupLocalToolEnvironmentService.initialize() + await startupSpan('main:local-tool-environment', () => startupLocalToolEnvironmentService.initialize()) localToolEnvironmentService = startupLocalToolEnvironmentService const sshHostStore = new SshHostStore( @@ -682,11 +689,11 @@ if (hasSingleInstanceLock) { resourcesPath: process.resourcesPath, packaged: app.isPackaged }) - const controlPlanePackageInstaller = await readFile( + const controlPlanePackageInstaller = await startupSpan('main:read-control-plane-installer', () => readFile( resolveControlPlanePackageInstallerPath({ appPath: app.getAppPath() }) - ) + )) const agentPackageManager = new AgentPackageManager({ userDataPath: app.getPath('userData'), desktopVersion: app.getVersion(), @@ -728,7 +735,7 @@ if (hasSingleInstanceLock) { return selection.provider === 'continue' ? resolved.continueModelProfile : resolved.opencodeModelProfile } }) - await startupManagedRemoteExecutionServices.initialize() + await startupSpan('main:managed-remote-init', () => startupManagedRemoteExecutionServices.initialize()) managedRemoteExecutionServices = startupManagedRemoteExecutionServices const beginRemoteHostInvalidation = (hostId: string): void => { @@ -777,10 +784,10 @@ if (hasSingleInstanceLock) { ) }) const [initialRuntimeSettings, initialResolvedSettings] = - await Promise.all([ + await startupSpan('main:runtime-settings-load', () => Promise.all([ settingsStore.getPublicSettings(), settingsStore.getResolvedSettings() - ]) + ])) globalTlsPolicy = new GlobalTlsPolicy(app) globalTlsPolicy.install() const capabilityService = new CapabilityService( @@ -836,6 +843,7 @@ if (hasSingleInstanceLock) { (await applicationSettingsStore.get()).modelDownloadSource }) documentOcrBroker = new DocumentOcrBroker(mainWindow) + configureDocumentParseWorker(join(app.getAppPath(), 'out/main/document-parse-worker.js')) documentParsingService = new DocumentParsingService( documentParsingSettingsStore, documentOcrModelManager, @@ -1208,7 +1216,8 @@ if (hasSingleInstanceLock) { resolved.target ) } - const configuredRuntime = await runStartupPrerequisites({ + startupMark('main:services-constructed') + const configuredRuntime = await startupSpan('main:prerequisites', () => runStartupPrerequisites({ prepareDeepSeekHome: async () => { await mkdir(deepSeekHarnessHome, { recursive: true, @@ -1216,7 +1225,7 @@ if (hasSingleInstanceLock) { }) }, initializeKnowledgeAndGateway: async () => { - await startupKnowledgeService.initialize() + await startupSpan('main:knowledge-init', () => startupKnowledgeService.initialize()) const embeddingProvider = await createEmbeddingProvider( initialResolvedSettings, startupEmbeddingModelManager @@ -1242,11 +1251,12 @@ if (hasSingleInstanceLock) { () => undefined ) ]) - await startupKnowledgeGateway.start() + await startupSpan('main:knowledge-gateway-start', () => startupKnowledgeGateway.start()) }, hydrateConfiguredRuntime: () => - createConfiguredRuntime(initialResolvedSettings), + startupSpan('main:runtime-hydrate', () => createConfiguredRuntime(initialResolvedSettings)), initializeAssistant: () => { + startupMark('main:assistant-db-init:start') startupAssistantDatabase.initialize(defaultWorkspace) startupAssistantDatabase.enableReadonlyWorker(readonlyQueryWorkerPath) imageGenerationService!.initialize() @@ -1259,13 +1269,14 @@ if (hasSingleInstanceLock) { initialRuntimeSettings ) ) + startupMark('main:assistant-db-init:end') } - }) + })) const [initialSubagentRuntime, initialSubagentProfileRuntimes] = - await Promise.all([ + await startupSpan('main:subagent-runtimes', () => Promise.all([ createSubagentRuntime(initialResolvedSettings), createSubagentProfileRuntimes(initialResolvedSettings) - ]) + ])) const subagentService = new SubagentService( initialSubagentRuntime, startupAssistantDatabase, @@ -1285,8 +1296,10 @@ if (hasSingleInstanceLock) { createSelectedStatusRuntime, (conversationId) => localRuntimeRegistry.releaseConversation(conversationId) ) + startupMark('main:attachments-reconcile:start') conversationAttachmentStorage = new ConversationAttachmentStorage(app.getPath('userData'), documentParsingService.results!) conversationAttachmentStorage.reconcile((conversationId, kind, ownerId) => startupAssistantDatabase.hasAttachmentOwner(conversationId, kind, ownerId), true) + startupMark('main:attachments-reconcile:end') const contextManager = new ContextManager({ parseDocument: documentParsingService.parse, assets: conversationAttachmentStorage, @@ -1308,7 +1321,7 @@ if (hasSingleInstanceLock) { }, process.platform ) - await shortcutSettingsService.initialize() + await startupSpan('main:shortcuts-init', () => shortcutSettingsService.initialize()) let runtimeReconfigurationQueue: Promise = Promise.resolve() let runtimeReconfigurationClosing = false @@ -1513,6 +1526,7 @@ if (hasSingleInstanceLock) { obsidianService, launchEnvironmentProvider: startupLocalToolEnvironmentService.launchEnvironmentProvider }) }) + startupMark('main:ipc-register:start') removeIpcHandlers = registerIpcHandlers( mainWindow, runtime, @@ -1581,7 +1595,9 @@ if (hasSingleInstanceLock) { mainWindow, startupFeedbackService ) + startupMark('main:ipc-register:end') loadMainWindow(mainWindow) + startupMark('main:load-main-window') removeDeviceSharingIpc = registerDeviceSharingIpc(mainWindow, new DeviceSharingService(join(app.getPath('userData'), 'device-sharing-settings.json'), app.getVersion())) setImmediate(() => { diff --git a/src/main/knowledge/knowledge-service.ts b/src/main/knowledge/knowledge-service.ts index c3975162..ec712c6b 100644 --- a/src/main/knowledge/knowledge-service.ts +++ b/src/main/knowledge/knowledge-service.ts @@ -21,11 +21,14 @@ import { } from 'node:path' import { buildChunkContextPrefix, - chunkDocumentAdvanced, - parseDocument, supportedDocumentExtensions, type ParsedDocument } from './document-parser' +import { + chunkDocumentOffMain, + parseDocumentOffMain, + sha256OffMain +} from '../document-parse-client' import type { DocumentResultStorage } from '../document-result-storage' import { parsedCompleteness } from '../document-result-storage' import { @@ -282,7 +285,7 @@ export class KnowledgeService { this.urlImporter = options.urlImporter ?? new UrlImporter() this.documentParser = options.parseDocument ?? - ((name, buffer) => parseDocument(name, buffer)) + ((name, buffer) => parseDocumentOffMain(name, buffer)) this.embeddingProvider = options.embeddingProvider this.rerankProvider = options.rerankProvider const embeddingBatchSize = options.embeddingBatchSize ?? 16 @@ -769,7 +772,7 @@ export class KnowledgeService { const documentId = input.id ?? randomUUID() const imagesOnly = parsedCompleteness(parsed) === 'images-only' if (imagesOnly && this.database.getDocument(documentId)?.metadata.status === 'ready') throw new Error('未提取到可索引文字,保留上次正文与索引') - const chunks = imagesOnly ? [] : this.createDocumentChunks(parsed, library) + const chunks = imagesOnly ? [] : await this.createDocumentChunks(parsed, library) let embeddingReplacement: PreparedEmbeddingReplacement | undefined let embeddingFailure: PreparedDocumentPublication['embeddingFailure'] const embeddingProvider = this.embeddingProvider @@ -2484,7 +2487,7 @@ export class KnowledgeService { const prepared = await this.prepareDocumentPublication( { ...document, - checksum: createHash('sha256').update(buffer).digest('hex'), + checksum: await sha256OffMain(buffer), metadata: { ...document.metadata, status: 'ready', @@ -3137,7 +3140,7 @@ export class KnowledgeService { progress: 35, message: '正在解析文档内容' }) - const checksum = createHash('sha256').update(buffer).digest('hex') + const checksum = await sha256OffMain(buffer) const previous = existing.find( (document) => document.externalId === file.relativePath ) @@ -3835,11 +3838,12 @@ export class KnowledgeService { } } - private createDocumentChunks( + private async createDocumentChunks( parsed: ParsedDocument, library: KnowledgeBase - ): ReplaceChunkInput[] { - const chunks = chunkDocumentAdvanced(parsed, library.chunkingSettings) + ): Promise { + // Pure CPU work runs off Main; chunk ids are still assigned here. + const chunks = await chunkDocumentOffMain(parsed, library.chunkingSettings) const ids = chunks.map(() => randomUUID()) return chunks.map((chunk) => ({ id: ids[chunk.position], diff --git a/src/main/knowledge/url-importer.ts b/src/main/knowledge/url-importer.ts index 14468aec..f8f91ba6 100644 --- a/src/main/knowledge/url-importer.ts +++ b/src/main/knowledge/url-importer.ts @@ -1,7 +1,8 @@ import { lookup as dnsLookup } from 'node:dns/promises' import { request as httpRequest } from 'node:http' import { request as httpsRequest } from 'node:https' -import { parseDocument, type ParsedDocument } from './document-parser' +import type { ParsedDocument } from './document-parser' +import { parseDocumentOffMain } from '../document-parse-client' type ResolvedAddress = { address: string @@ -224,7 +225,7 @@ export class UrlImporter { .trim() || url.hostname ).slice(0, 240) : url.pathname.split('/').filter(Boolean).at(-1) ?? url.hostname - const document = await parseDocument( + const document = await parseDocumentOffMain( isHtml ? `${title}.html` : `${title}.txt`, response.body ) diff --git a/src/main/startup-marks.ts b/src/main/startup-marks.ts new file mode 100644 index 00000000..8b724a16 --- /dev/null +++ b/src/main/startup-marks.ts @@ -0,0 +1,32 @@ +import { performance } from 'node:perf_hooks' + +// PERF-01 startup phase marks. Inactive unless GB_PERF_STARTUP_MARKS=1 (set by +// build/run-startup-perf.cjs); when inactive each call is one boolean check. +// Marks hold only fixed phase names and process-relative times, never user +// content. The startup harness reads them from the global below. +const enabled = process.env.GB_PERF_STARTUP_MARKS === '1' +type StartupMark = { name: string; at: number } +const marks: StartupMark[] | undefined = enabled ? [] : undefined +if (marks) { + ;(globalThis as { __goodbuddyStartupMarks?: StartupMark[] }).__goodbuddyStartupMarks = marks +} + +export function startupMark(name: string): void { + if (marks) marks.push({ name, at: performance.now() }) +} + +// Takes a thunk so the span also covers the synchronous part of the operation +// (everything before its first await). +export function startupSpan(name: string, operation: () => Promise): Promise { + if (!marks) return operation() + startupMark(`${name}:start`) + let started: Promise + try { + started = operation() + } catch (error) { + startupMark(`${name}:end`) + throw error + } + startupMark(`${name}:sync-end`) + return started.finally(() => startupMark(`${name}:end`)) +} diff --git a/tests/support/document-parse-worker-fixture.ts b/tests/support/document-parse-worker-fixture.ts new file mode 100644 index 00000000..058895fe --- /dev/null +++ b/tests/support/document-parse-worker-fixture.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, rm } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +import { strToU8, zipSync } from 'fflate' + +/** + * Bundles the document parse worker for tests. The bundle lives under + * node_modules/.cache so the external pdfjs-dist (ESM, loaded lazily by the + * parser) resolves from the repository's node_modules. + */ +export async function buildDocumentParseWorker(): Promise<{ workerPath: string; dispose: () => Promise }> { + const directory = resolve('node_modules/.cache', `goodbuddy-document-parse-worker-${randomUUID()}`) + await mkdir(directory, { recursive: true }) + const workerPath = join(directory, 'document-parse-worker.cjs') + await build({ + entryPoints: [resolve('src/main/document-parse-worker.ts')], + outfile: workerPath, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + logLevel: 'silent', + external: ['pdfjs-dist', 'electron'] + }) + return { + workerPath, + dispose: () => rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + } +} + +function escapePdfText(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)') +} + +/** A text-layer PDF with one content stream per page; each page is a list of lines. */ +export function createTextPdf(pages: readonly (readonly string[])[]): Buffer { + const pageCount = pages.length + const objects: string[] = [ + '<< /Type /Catalog /Pages 2 0 R >>', + `<< /Type /Pages /Kids [${pages.map((_, index) => `${4 + index * 2} 0 R`).join(' ')}] /Count ${pageCount} >>`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>' + ] + for (const lines of pages) { + const pageObject = objects.length + 1 + const stream = [ + 'BT /F1 10 Tf 12 TL 40 760 Td', + ...lines.map((line, index) => `${index === 0 ? '' : 'T* '}(${escapePdfText(line)}) Tj`), + 'ET' + ].join('\n') + objects.push( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${pageObject + 1} 0 R >>`, + `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream` + ) + } + let content = '%PDF-1.4\n' + const offsets: number[] = [] + for (const [index, object] of objects.entries()) { + offsets.push(Buffer.byteLength(content, 'latin1')) + content += `${index + 1} 0 obj\n${object}\nendobj\n` + } + const xrefOffset = Buffer.byteLength(content, 'latin1') + content += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + content += offsets.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('') + content += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n` + return Buffer.from(content, 'latin1') +} + +const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review'.split(' ') + +/** Deterministic pseudo-random sentence generator. */ +export function sentenceGenerator(seed = 42): (count: number) => string { + let state = seed + const random = (): number => ((state = (state * 1664525 + 1013904223) >>> 0) / 4294967296) + return (count) => Array.from({ length: count }, () => words[Math.floor(random() * words.length)]).join(' ') +} + +export function createLargePdf(pageCount: number, linesPerPage: number): Buffer { + const sentence = sentenceGenerator(7) + return createTextPdf(Array.from({ length: pageCount }, (_, page) => + Array.from({ length: linesPerPage }, (_, line) => `Page ${page + 1} line ${line + 1}: ${sentence(10)}.`))) +} + +export function createDocx(paragraphs: readonly string[], tables: readonly (readonly (readonly string[])[])[] = []): Buffer { + const body = [ + ...paragraphs.map((text) => `${text}tab`), + ...tables.map((rows) => `${rows.map((row) => `${row.map((cell) => `${cell}`).join('')}`).join('')}`), + 'tail & <end> 中文' + ].join('') + return Buffer.from(zipSync({ + '[Content_Types].xml': strToU8(''), + 'word/document.xml': strToU8(`${body}`) + })) +} + +export function createXlsx(sheets: readonly (readonly (readonly string[])[])[]): Buffer { + const shared: string[] = [] + const files: Record = {} + sheets.forEach((rows, sheetIndex) => { + const xmlRows = rows.map((row, rowIndex) => `${row.map((cell, cellIndex) => { + if (/^\d+$/u.test(cell)) return `${cell}` + if (cellIndex === 2) return `${cell}` + shared.push(cell) + return `${shared.length - 1}` + }).join('')}`).join('') + files[`xl/worksheets/sheet${sheetIndex + 1}.xml`] = strToU8(`${xmlRows}`) + }) + files['xl/sharedStrings.xml'] = strToU8(`${shared.map((value) => `${value}`).join('')}`) + return Buffer.from(zipSync(files)) +} + +export function createPptx(slides: readonly string[], withImage = false): Buffer { + const files: Record = {} + if (withImage) { + files['ppt/media/image1.png'] = new Uint8Array(Array.from({ length: 4096 }, (_, index) => index % 251)) + files['ppt/media/image2.jpg'] = new Uint8Array([255, 216, 255, 224, 1, 2, 3]) + } + slides.forEach((text, index) => { + const page = index + 1 + files[`ppt/slides/slide${page}.xml`] = strToU8( + `${text}${withImage ? `` : ''}` + ) + if (withImage) { + files[`ppt/slides/_rels/slide${page}.xml.rels`] = strToU8( + '' + ) + } + }) + return Buffer.from(zipSync(files)) +} diff --git a/tests/support/startup-perf-driver.mjs b/tests/support/startup-perf-driver.mjs new file mode 100644 index 00000000..5ccbc67e --- /dev/null +++ b/tests/support/startup-perf-driver.mjs @@ -0,0 +1,309 @@ +// Cold-start measurement driver (PERF-01). Launched by +// build/run-startup-perf.cjs, once per measured launch. Runs inside the +// Electron main process: isolates the profile, blocks every non-loopback +// request, imports the current production Main and records when startup +// milestones happen. All times are epoch milliseconds (timeOrigin + now) so the +// supervisor can relate them to its spawn time. The report holds only phase +// names, times and counts; never conversation text or file paths. +// +// GB_STARTUP_MODE=prepare builds a reusable profile instead of measuring: +// acknowledges the release notes, disables the startup update check, seeds +// GB_STARTUP_SEED synthetic conversations, then quits through the normal +// shutdown path so the databases are checkpointed and closed. +import { app, BrowserWindow, session } from 'electron' +import { randomUUID } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { monitorEventLoopDelay, performance } from 'node:perf_hooks' +import process from 'node:process' +import { clearTimeout, setTimeout } from 'node:timers' +import { setTimeout as sleep } from 'node:timers/promises' +import { pathToFileURL, URL } from 'node:url' + +const now = () => performance.timeOrigin + performance.now() +const driverStart = now() +const directory = process.env.GB_STARTUP_DIR +const resultPath = process.env.GB_STARTUP_RESULT +const root = process.env.GB_PERF_ROOT +if (!directory || !resultPath || !root) throw new Error('Run through build/run-startup-perf.cjs') +const mode = process.env.GB_STARTUP_MODE === 'prepare' ? 'prepare' : 'measure' +const profilePath = process.env.GB_STARTUP_CPU_PROFILE +const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + +// Same isolation as tests/support/app-perf-driver.mjs; it must happen before +// the production Main module loads (single-instance lock, storage paths). +app.setAppPath(root) +app.setName(packageJson.name) +app.setVersion(packageJson.version) +app.setPath('userData', join(directory, 'profile')) +app.setPath('sessionData', join(directory, 'session')) +app.setAppLogsPath(join(directory, 'logs')) +app.commandLine.appendSwitch('force-device-scale-factor', '1') +app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion') + +const report = { mode, status: 'running', times: { driverStart }, blockedRequests: { fetch: [], renderer: [] }, renderer: {}, flags: {} } +const times = report.times +const mark = name => { if (times[name] === undefined) times[name] = now() } +const writeReport = () => writeFileSync(resultPath, JSON.stringify(report, null, 2)) +const mainDelay = monitorEventLoopDelay({ resolution: 5 }) +mainDelay.enable() + +const deadline = setTimeout(() => { + report.status = 'failed' + report.error = 'Driver timeout' + writeReport() + app.exit(1) +}, Number(process.env.GB_STARTUP_DRIVER_TIMEOUT_MS || 120_000)) + +// No external requests: the fake model and everything else must be loopback. +const isLoopback = url => { + try { + const { hostname, protocol } = new URL(url) + if (!['http:', 'https:', 'ws:', 'wss:'].includes(protocol)) return true + return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]' + } catch { return true } +} +const hostOf = url => { try { return new URL(url).hostname } catch { return 'invalid' } } +const originalFetch = globalThis.fetch +globalThis.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input?.url + if (url && !isLoopback(url)) { + report.blockedRequests.fetch.push(hostOf(url)) + return Promise.reject(new TypeError('blocked by startup benchmark')) + } + return originalFetch(input, init) +} + +let profiler +if (profilePath) { + const inspector = await import('node:inspector') + profiler = new inspector.Session() + profiler.connect() + const post = (method, params) => new Promise((resolve, reject) => profiler.post(method, params, (error, result) => error ? reject(error) : resolve(result))) + profiler.postAsync = post + await post('Profiler.enable') + await post('Profiler.setSamplingInterval', { interval: 250 }) + await post('Profiler.start') +} + +app.once('ready', () => mark('appReady')) +// Registered before Main imports, so it runs before Main's whenReady handler. +void app.whenReady().then(() => { + session.defaultSession.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, (details, callback) => { + if (isLoopback(details.url)) { callback({}); return } + report.blockedRequests.renderer.push(hostOf(details.url)) + callback({ cancel: true }) + }) +}) + +let win +app.on('browser-window-created', (_event, created) => { + if (win) return + win = created + mark('windowCreated') + created.once('ready-to-show', () => mark('readyToShow')) + created.once('show', () => mark('windowShown')) + created.webContents.once('did-start-loading', () => mark('didStartLoading')) + created.webContents.once('dom-ready', () => { mark('domReady'); void installRendererProbe() }) + created.webContents.once('did-finish-load', () => mark('didFinishLoad')) + created.webContents.on('render-process-gone', (_e, details) => { report.error = `render-process-gone ${details.reason}` }) +}) + +mark('mainImportStart') +await import(pathToFileURL(join(root, 'out', 'main', 'index.js')).href) +mark('mainImportEnd') + +const js = code => win.webContents.executeJavaScript(code, true) + +// Composer is interactive when it exists, is enabled, laid out, and not inside +// an inert or hidden subtree (a modal dialog makes the app shell inert). +const composerSelector = '.composer__input textarea' +const expectedConversations = mode === 'measure' ? Math.max(0, Number(process.env.GB_STARTUP_EXPECT_CONVERSATIONS || 0)) : 0 +const rendererProbe = `new Promise(resolve => { + const origin = performance.timeOrigin; + const at = () => origin + performance.now(); + const r = { installedAt: at(), visibleAtInstall: document.visibilityState === 'visible' }; + // Chromium does not report first-contentful-paint when the first paint + // happens while the page is hidden, and the main window is created hidden + // until ready-to-show. "First visible frame" is the second animation frame + // after the page becomes visible, i.e. a frame actually presented on screen. + let frameDone = false; + const firstVisibleFrame = () => { + r.visibleAt = at(); + requestAnimationFrame(() => requestAnimationFrame(() => { r.firstVisibleFrame = at(); frameDone = true; if (check()) finish(); })); + }; + if (document.visibilityState === 'visible') firstVisibleFrame(); + else document.addEventListener('visibilitychange', function onVisible() { if (document.visibilityState !== 'visible') return; document.removeEventListener('visibilitychange', onVisible); firstVisibleFrame(); }); + const check = () => { + const c = document.querySelector(${JSON.stringify(composerSelector)}); + if (c && r.composerPresent === undefined) r.composerPresent = at(); + if (!r.firstConversationRow && document.querySelector('button.conversation-item')) r.firstConversationRow = at(); + if (!r.startupDialog && document.querySelector('section.release-notes-dialog')) r.startupDialog = at(); + if (c && r.composerInteractive === undefined && !c.disabled && c.getClientRects().length > 0 && !c.closest('[inert], [hidden], [aria-hidden="true"]')) r.composerInteractive = at(); + // Seeded profiles: the sidebar is populated once the virtualized list + // reports the seeded total (or mounts enough rows to fill the viewport). + if (${expectedConversations} > 0 && r.sidebarPopulated === undefined) { + const rows = document.querySelectorAll('button.conversation-item').length; + const entry = document.querySelector('.conversation-list [aria-setsize]'); + const total = entry ? Number(entry.getAttribute('aria-setsize')) : rows; + if (total >= ${expectedConversations} && rows >= ${Math.min(10, expectedConversations)}) r.sidebarPopulated = at(); + } + const sidebarDone = ${expectedConversations} === 0 || r.sidebarPopulated !== undefined; + return frameDone && sidebarDone && (r.composerInteractive !== undefined || (r.composerPresent !== undefined && r.startupDialog !== undefined)); + }; + let observer; + let settled = false; + const finish = () => { if (settled) return; settled = true; observer?.disconnect(); resolve(r); }; + if (check()) { finish(); return; } + observer = new MutationObserver(() => { if (check()) finish(); }); + observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['inert', 'hidden', 'disabled', 'aria-hidden', 'class'] }); + setTimeout(() => { r.timedOut = true; finish(); }, 60000); +})` +let probeResult +async function installRendererProbe() { + try { probeResult = await js(rendererProbe) } catch (error) { report.error = `renderer probe: ${error instanceof Error ? error.message : error}` } +} + +async function collectRenderer() { + const data = await js(`(() => { + const origin = performance.timeOrigin; + const nav = performance.getEntriesByType('navigation')[0]; + const paint = Object.fromEntries(performance.getEntriesByType('paint').map(e => [e.name, origin + e.startTime])); + const scripts = performance.getEntriesByType('resource').filter(e => e.initiatorType === 'script' || /\\.js$/.test(e.name)); + return { + timeOrigin: origin, + navigationStart: nav ? origin + nav.startTime : null, + responseEnd: nav ? origin + nav.responseEnd : null, + domInteractive: nav ? origin + nav.domInteractive : null, + domContentLoaded: nav ? origin + nav.domContentLoadedEventEnd : null, + loadEventEnd: nav ? origin + nav.loadEventEnd : null, + firstPaint: paint['first-paint'] ?? null, + firstContentfulPaint: paint['first-contentful-paint'] ?? null, + scriptResources: scripts.length, + scriptBytes: scripts.reduce((a, e) => a + (e.decodedBodySize || 0), 0), + jsHeapMB: performance.memory ? performance.memory.usedJSHeapSize / 1048576 : null, + domNodes: document.getElementsByTagName('*').length, + conversationRows: document.querySelectorAll('button.conversation-item').length + }; + })()`) + return data +} + +// Long animation frames are buffered by Chromium, so the ones during startup +// are visible even though the observer is installed after dom-ready. +const loafProbe = `new Promise(resolve => { + const items = []; + try { + new PerformanceObserver(list => { for (const e of list.getEntries()) items.push(e); }).observe({ type: 'long-animation-frame', buffered: true }); + } catch { resolve(null); return; } + setTimeout(() => { + const origin = performance.timeOrigin; + resolve({ count: items.length, totalMs: items.reduce((a, e) => a + e.duration, 0), blockingMs: items.reduce((a, e) => a + e.blockingDuration, 0), + maxMs: items.reduce((a, e) => Math.max(a, e.duration), 0), + scriptMs: items.reduce((a, e) => a + e.scripts.reduce((s, x) => s + x.duration, 0), 0), + frames: items.slice(0, 20).map(e => ({ at: origin + e.startTime, duration: e.duration, scriptMs: e.scripts.reduce((s, x) => s + x.duration, 0) })) }); + }, 50); +})` + +function profileHotspots(profile) { + const byId = new Map(profile.nodes.map(node => [node.id, node])) + const parent = new Map() + for (const node of profile.nodes) for (const child of node.children ?? []) parent.set(child, node.id) + const self = new Map() + for (let index = 0; index < profile.samples.length; index += 1) { + const id = profile.samples[index] + self.set(id, (self.get(id) ?? 0) + (profile.timeDeltas[index] ?? 0)) + } + const keyOf = frame => { + const file = frame.url ? frame.url.replace(/^file:\/\/\/?/, '').split(/[\\/]/).slice(-3).join('/') : `(${frame.functionName || 'native'})` + return `${frame.functionName || '(anonymous)'} ${file}:${frame.lineNumber + 1}` + } + const selfByKey = new Map() + const inclusiveByKey = new Map() + let total = 0 + for (const [id, micros] of self) { + total += micros + const key = keyOf(byId.get(id).callFrame) + selfByKey.set(key, (selfByKey.get(key) ?? 0) + micros) + const seen = new Set() + for (let cursor = id; cursor !== undefined; cursor = parent.get(cursor)) { + const k = keyOf(byId.get(cursor).callFrame) + if (seen.has(k)) continue + seen.add(k) + inclusiveByKey.set(k, (inclusiveByKey.get(k) ?? 0) + micros) + } + } + const top = (map, count, filter = () => true) => [...map].filter(([key]) => filter(key)).sort((a, b) => b[1] - a[1]).slice(0, count) + .map(([key, micros]) => ({ key, ms: Math.round(micros / 100) / 10 })) + const own = key => /out\/main\//.test(key) + return { + sampledMs: Math.round(total / 1000), + selfTop: top(selfByKey, 30), + inclusiveOutMain: top(inclusiveByKey, 40, own) + } +} + +async function prepare() { + await js(`window.goodbuddy.updates.updateSettings({ checkUpdatesOnStartup: false }).then(() => true)`) + await js(`window.goodbuddy.releaseNotes.getPending().then(s => s.releases.some(r => r.version === s.currentVersion) ? window.goodbuddy.releaseNotes.acknowledge(s.currentVersion) : undefined).then(() => true)`) + const count = Number(process.env.GB_STARTUP_SEED || 0) + const perConversation = Number(process.env.GB_STARTUP_SEED_MESSAGES || 20) + if (count > 0) { + const projectId = await js(`window.goodbuddy.projects.list(false).then(list => list.find(p => p.kind === 'user' && p.executionSpace.kind === 'local')?.id)`) + if (!projectId) throw new Error('No local project to seed') + const base = Date.now() + const items = [] + for (let index = 0; index < count; index += 1) { + const updatedAt = base - (index + 1) * 60_000 + const messages = [] + for (let m = 0; m < perConversation; m += 1) { + const role = m % 2 === 0 ? 'user' : 'assistant' + const content = role === 'user' + ? `Question ${m}: how should the benchmark handle item ${m}?` + : `## Answer ${m}\n\nThe benchmark answer for item ${m} contains **formatting**, \`inline code\`, and a list:\n\n- point one\n- point two\n\n\`\`\`ts\nconst value${m} = ${m}\n\`\`\`` + messages.push({ id: randomUUID(), role, content, createdAt: updatedAt - (perConversation - m) * 1000, state: 'complete' }) + } + items.push({ header: { id: randomUUID(), projectId, title: `Perf conversation ${String(index + 1).padStart(4, '0')}`, updatedAt, knowledgeLibraryIds: [], knowledgeRetrievalMode: 'auto' }, messages }) + } + for (let offset = 0; offset < items.length; offset += 100) { + await js(`window.goodbuddy.conversations.saveLocal(${JSON.stringify(items.slice(offset, offset + 100))}).then(() => true)`) + } + report.seeded = { conversations: count, messagesPerConversation: perConversation } + } +} + +async function run() { + try { + for (let i = 0; i < 600 && !win; i += 1) await sleep(50) + if (!win) throw new Error('Main window was not created') + for (let i = 0; i < 1200 && probeResult === undefined; i += 1) await sleep(25) + if (!probeResult) throw new Error(report.error ?? 'Renderer probe did not finish') + mark('probeResolvedInMain') + if (profiler) { + const { profile } = await profiler.postAsync('Profiler.stop') + writeFileSync(profilePath, JSON.stringify(profile)) + report.cpuProfile = profileHotspots(profile) + } + Object.assign(report.renderer, probeResult) + Object.assign(report.renderer, await collectRenderer()) + report.renderer.longAnimationFrames = await js(loafProbe).catch(() => null) + report.startupMarks = (globalThis.__goodbuddyStartupMarks ?? []).map(({ name, at }) => ({ name, at: performance.timeOrigin + at })) + report.mainEventLoopDelay = { maxMs: mainDelay.max / 1e6, p99Ms: mainDelay.percentile(99) / 1e6, meanMs: mainDelay.mean / 1e6 } + report.flags.windowCount = BrowserWindow.getAllWindows().length + if (mode === 'prepare') await prepare() + report.status = 'passed' + writeReport() + clearTimeout(deadline) + if (mode === 'prepare') app.quit() + else app.exit(0) + } catch (error) { + report.status = 'failed' + report.error = error instanceof Error ? error.message : String(error) + writeReport() + clearTimeout(deadline) + app.exit(1) + } +} + +// Electron emits `ready` only after this ESM entry finishes evaluating. +void app.whenReady().then(run)