Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
171 changes: 171 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
29 changes: 25 additions & 4 deletions src/web-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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.
Expand Down
Loading