From df05d7792e13f7d1198149cee8413f57d7176b5b Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 10 Aug 2026 11:17:59 -0700 Subject: [PATCH] perf: web dashboard period prefetch + menubar serve client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more surfaces adopt the resident-serve pattern the desktop app got: - Web dashboard: every period tab is prefetched sequentially right after startup, so the first click on 7d/30d/Month answers from the payload cache instead of paying a full parse; stale-while-revalidate rebuilds behind a served payload past 75% of the TTL so expiry never lands its multi-second parse on a user's click. Lifetime prefetches last. - Menubar: ServeConnection (Swift actor) holds one codeburn serve --stdio child; status payload fetches route through it once warm, with the same contract as the app client — cold start and every failure keep the spawn path, three child deaths disable serve for the run, requests time out by killing the child, app termination shuts it down, and a pre-serve CLI (0.9.19) simply dies into permanent spawn fallback, so mixed-version installs degrade gracefully. swift build clean, swift test 156/156, CLI tsc clean; verified live with both the Electron app's and the menubar's serve children resident and answering. --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 5 + .../CodeBurnMenubar/Data/DataClient.swift | 9 + .../Data/ServeConnection.swift | 171 ++++++++++++++++++ src/web-dashboard.ts | 29 ++- 4 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index f33f0516..e6366baa 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -83,6 +83,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM private var refreshLoopHeartbeatAt: Date = .distantPast func applicationWillTerminate(_ notification: Notification) { + Task { await ServeConnection.shared.shutdown() } if let monitor = rightClickMonitor { NSEvent.removeMonitor(monitor) rightClickMonitor = nil @@ -126,6 +127,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM // interaction (popover open, wake) refreshes immediately. restorePersistedCurrency() + // Resident serve child: payload fetches answer from a warm CLI once + // its warm-up completes; until then (and on any failure) fetches keep + // the spawn path. See ServeConnection. + Task { await ServeConnection.shared.ensureStarted() } // #868 experiment: restore only the activation half of the #147 fix. // Packaged builds ship LSUIElement=true, so the policy is .accessory // before main() runs and never transitions (the transition is what diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index fc90214d..cafe0449 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -123,6 +123,15 @@ struct DataClient { subcommand: [String], qualityOfService: QualityOfService = .userInitiated ) async throws -> ProcessResult { + // Serve fast path: a warm resident `codeburn serve` child answers the + // status payload without a spawn (no node boot, no session-cache + // reload). Any serve failure falls back to the spawn path below, so + // this is strictly an optimization; it also takes no spawn slot. + if ServeConnection.isEligible(subcommand) { + if let stdout = try? await ServeConnection.shared.requestIfWarm(args: subcommand) { + return ProcessResult(stdout: stdout, stderr: "", exitCode: 0) + } + } await spawnLimiter.acquire() defer { Task { await spawnLimiter.release() } } let process = CodeburnCLI.makeProcess(subcommand: subcommand, qualityOfService: qualityOfService) diff --git a/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift b/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift new file mode 100644 index 00000000..0eb13578 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift @@ -0,0 +1,171 @@ +import Foundation + +/// A resident `codeburn serve --stdio` child, held so payload fetches skip the +/// per-spawn cost (node boot + a 100MB+ session-cache parse on large corpora, +/// seconds per fetch at the CLI level). Requests are JSON lines `{id, args}`; +/// replies are `{id, ok, output}`. Mirrors the desktop app's client contract: +/// +/// - Only `status` payload queries route here; anything else spawns as before. +/// - Requests route through serve only once the child is READY and WARM (one +/// completed query), so cold start behaves exactly as today. +/// - Any failure falls back to the spawn path for that call; three child +/// deaths disable serve for this app run. +/// - The child's stdin closing (app quit, even SIGKILL) ends the server loop +/// on the CLI side, so no orphan survives the menubar. +actor ServeConnection { + static let shared = ServeConnection() + + private var process: Process? + private var stdinHandle: FileHandle? + private var nextId = 1 + private var pending: [Int: CheckedContinuation] = [:] + private var ready = false + private var warm = false + private var deaths = 0 + private var buffer = Data() + + private static let maxDeaths = 3 + private static let requestTimeoutSeconds: UInt64 = 60 + + struct ServeUnavailable: Error {} + struct ServeRequestFailed: Error { let message: String } + + static func isEligible(_ subcommand: [String]) -> Bool { + subcommand.first == "status" + } + + /// Kick the child off (idempotent). Called from app startup; fetches keep + /// spawning until the warm-up completes. + func ensureStarted() { + guard process == nil, deaths < Self.maxDeaths else { return } + let child = CodeburnCLI.makeProcess(subcommand: ["serve", "--stdio"], qualityOfService: .utility) + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + child.standardInput = stdinPipe + child.standardOutput = stdoutPipe + child.standardError = FileHandle.nullDevice + stdoutPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + Task { await ServeConnection.shared.consume(data) } + } + child.terminationHandler = { _ in + stdoutPipe.fileHandleForReading.readabilityHandler = nil + Task { await ServeConnection.shared.childDied() } + } + do { + try child.run() + } catch { + deaths = Self.maxDeaths // spawn path can't produce the binary either better than makeProcess did + return + } + process = child + stdinHandle = stdinPipe.fileHandleForWriting + Task { + // Warm-up: one cheap query makes the child parse the session cache + // once; every later payload answers from the warm in-memory copy. + _ = try? await self.send(args: ["status", "--format", "menubar-json", "--period", "today", "--no-optimize"]) + await self.markWarm() + } + } + + /// The fast path `runCLI` consults: throws ServeUnavailable unless the + /// child is warm, so callers can fall back to a spawn without waiting. + func requestIfWarm(args: [String]) async throws -> Data { + guard ready, warm, process != nil else { throw ServeUnavailable() } + return try await send(args: args) + } + + func shutdown() { + deaths = Self.maxDeaths + process?.terminate() + failAllPending() + process = nil + stdinHandle = nil + } + + // MARK: - internals + + private func markWarm() { + if process != nil { warm = true } + } + + private func send(args: [String]) async throws -> Data { + guard let stdinHandle, let child = process else { throw ServeUnavailable() } + let id = nextId + nextId += 1 + let request: [String: Any] = ["id": id, "args": args] + let line = try JSONSerialization.data(withJSONObject: request) + return try await withThrowingTaskGroup(of: Data.self) { group in + group.addTask { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Task { await self.registerPending(id: id, continuation: continuation) } + do { + try stdinHandle.write(contentsOf: line + Data("\n".utf8)) + } catch { + Task { await self.rejectPending(id: id, error: ServeRequestFailed(message: "stdin write failed")) } + } + } + } + group.addTask { + try await Task.sleep(nanoseconds: Self.requestTimeoutSeconds * 1_000_000_000) + // A hung request would block the serialized queue behind it: + // kill the child so everything falls back to spawns. + await self.rejectPending(id: id, error: ServeRequestFailed(message: "serve timeout")) + child.terminate() + throw ServeRequestFailed(message: "serve timeout") + } + let result = try await group.next()! + group.cancelAll() + return result + } + } + + private func registerPending(id: Int, continuation: CheckedContinuation) { + pending[id] = continuation + } + + private func rejectPending(id: Int, error: Error) { + if let continuation = pending.removeValue(forKey: id) { + continuation.resume(throwing: error) + } + } + + private func consume(_ data: Data) { + buffer.append(data) + while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) { + let lineData = buffer.subdata(in: buffer.startIndex.. }>() - const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise => { - const key = `${period}|${provider}|${from ?? ''}|${to ?? ''}` - const hit = localPayloadCache.get(key) - if (hit && Date.now() - hit.at < LOCAL_PAYLOAD_TTL_MS) return hit.payload + const rebuildPayload = (key: string, period: string, provider: string, from?: string, to?: string): Promise => { const periodInfo = periodInfoFromQuery({ period, from, to }, opts.period) const payload = buildMenubarPayloadForRange(periodInfo, { provider, project: opts.project, exclude: opts.exclude, optimize: false }) const now = Date.now() @@ -128,6 +125,30 @@ export async function runWebDashboard(opts: { void payload.catch(() => localPayloadCache.delete(key)) return payload } + const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise => { + const key = `${period}|${provider}|${from ?? ''}|${to ?? ''}` + const hit = localPayloadCache.get(key) + if (hit && Date.now() - hit.at < LOCAL_PAYLOAD_TTL_MS) { + // Stale-while-revalidate: past 75% of the TTL, hand back the cached + // payload instantly and rebuild behind it, so the TTL expiry never + // lands its multi-second parse on a user's click. + if (Date.now() - hit.at > LOCAL_PAYLOAD_TTL_MS * 0.75) void rebuildPayload(key, period, provider, from, to).catch(() => {}) + return hit.payload + } + return rebuildPayload(key, period, provider, from, to) + } + + // Warm every period tab shortly after startup, sequentially, so the first + // click on 7d/30d/Month answers from the payload cache instead of paying a + // full parse. Lifetime is deliberately last-and-optional: it is the rarest + // tab and the costliest parse. Failures are ignored - a prefetch is never + // load-bearing. + const prefetchPeriods = async (): Promise => { + for (const period of ['today', 'week', '30days', 'month', 'all', 'lifetime']) { + try { await getLocalPayload(period, opts.provider, opts.from, opts.to) } catch { /* not load-bearing */ } + } + } + setTimeout(() => { void prefetchPeriods() }, 500) // Context trees re-read a whole transcript (up to 100MB), so cache each by // file version. Keyed on mtime: an active session invalidates itself.