Merge pull request #959 from getagentseal/perf/web-prefetch

perf: web dashboard period prefetch + menubar serve client
This commit is contained in:
Resham Joshi 2026-08-10 11:21:01 -07:00 committed by GitHub
commit 77e809a3ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 210 additions and 4 deletions

View file

@ -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

View file

@ -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)

View file

@ -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<Data, Error>] = [:]
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<Data, Error>) 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<Data, Error>) {
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..<newline)
buffer.removeSubrange(buffer.startIndex...newline)
guard !lineData.isEmpty,
let object = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue }
if object["ready"] as? Bool == true {
ready = true
continue
}
guard let id = object["id"] as? Int, let continuation = pending.removeValue(forKey: id) else { continue }
if object["ok"] as? Bool == true, let output = object["output"] as? String {
continuation.resume(returning: Data(output.utf8))
} else {
let message = object["error"] as? String ?? "serve request failed"
continuation.resume(throwing: ServeRequestFailed(message: message))
}
}
}
private func childDied() {
process = nil
stdinHandle = nil
ready = false
warm = false
buffer.removeAll()
deaths += 1
failAllPending()
}
private func failAllPending() {
for (_, continuation) in pending {
continuation.resume(throwing: ServeRequestFailed(message: "serve exited"))
}
pending.removeAll()
}
}

View file

@ -116,10 +116,7 @@ export async function runWebDashboard(opts: {
// process cannot). Store the promise before any await so concurrent identical
// requests collapse into one parse instead of racing.
const localPayloadCache = new Map<string, { at: number; payload: Promise<MenubarPayload> }>()
const getLocalPayload = (period: string, provider: string, from?: string, to?: string): Promise<MenubarPayload> => {
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<MenubarPayload> => {
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<MenubarPayload> => {
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<void> => {
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.