mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 22:44:31 +00:00
Merge pull request #959 from getagentseal/perf/web-prefetch
perf: web dashboard period prefetch + menubar serve client
This commit is contained in:
commit
77e809a3ec
4 changed files with 210 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
171
mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift
Normal file
171
mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue