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
180 changes: 127 additions & 53 deletions apps/macos/Sources/Burn/BurnLedger.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import os

/// Runs the `burn` CLI and returns its stdout. A seam so tests can inject a fake
/// that returns canned JSON without spawning a subprocess.
Expand Down Expand Up @@ -31,93 +32,166 @@ actor SystemBurnRunner: BurnRunner {
}
private var tool: Tool = .unknown

/// When set, resolution is skipped and this URL is executed directly (no
/// login shell), exactly like the bundled case. Lets tests drive the real
/// Process/Pipe/timeout machinery against a fake `burn` script. `nil`
/// preserves the normal bundled→PATH→missing resolution.
private let explicitBinaryURL: URL?
/// The `capture` timeout (see `capture`). Injectable so tests can exercise
/// the timeout/reap path without waiting the production 30s.
private let captureTimeout: TimeInterval

/// Serial queue the blocking subprocess work runs on. `capture` awaits a
/// continuation resumed from here instead of blocking inside the actor, so
/// the cooperative-pool thread is released for the (up to `captureTimeout`)
/// duration of the child's run. Because the queue is serial, only one
/// `burn` subprocess is ever alive at a time — the same invariant the
/// blocking actor used to provide — even though the actor is reentrant at
/// the await.
private let execQueue = DispatchQueue(label: "com.agentworkforce.burn.subprocess", qos: .utility)

/// - Parameters:
/// - explicitBinaryURL: a `burn`-compatible executable to run directly,
/// bypassing resolution (default `nil` → normal resolution).
/// - timeout: per-invocation capture timeout in seconds (default `30`).
init(explicitBinaryURL: URL? = nil, timeout: TimeInterval = 30) {
self.explicitBinaryURL = explicitBinaryURL
self.captureTimeout = timeout
}

func run(_ args: [String]) async -> String? {
switch resolveTool() {
let label = args.first ?? "burn"
switch await resolveTool() {
case .bundled(let url):
// Self-contained Rust binary — exec directly, no shell needed.
return capture { $0.executableURL = url; $0.arguments = args }
return await capture(label: label) { $0.executableURL = url; $0.arguments = args }
case .path:
// Run through a login shell so nvm/Homebrew PATH (and the `node` the
// npm `burn` shim needs) resolve even when launched from Finder.
let command = "burn " + args.map(shellQuote).joined(separator: " ")
return loginShell(command)
return await loginShell(command, label: label)
case .missing, .unknown:
return nil
}
}

func bundledBinaryURL() async -> URL? {
if case .bundled(let url) = resolveTool() { return url }
if case .bundled(let url) = await resolveTool() { return url }
return nil
}

private func resolveTool() -> Tool {
private func resolveTool() async -> Tool {
// Explicit binary short-circuits resolution: run it directly like a
// bundled helper. Preserves normal resolution when unset.
if let explicit = explicitBinaryURL {
return .bundled(explicit)
}
if case .unknown = tool {
if let url = Bundle.main.url(forAuxiliaryExecutable: "burn"),
FileManager.default.isExecutableFile(atPath: url.path) {
tool = .bundled(url)
} else if !(loginShell("command -v burn")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "").isEmpty {
tool = .path
} else {
tool = .missing
// The actor is reentrant across this await, so two concurrent
// first calls may both run the PATH probe. That's benign: both
// converge on the same value and the probe is idempotent.
let probe = (await loginShell("command -v burn"))?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
tool = probe.isEmpty ? .missing : .path
}
}
return tool
}

private func loginShell(_ command: String) -> String? {
capture {
private func loginShell(_ command: String, label: String = "shell") async -> String? {
await capture(label: label) {
$0.executableURL = URL(fileURLWithPath: "/bin/zsh")
$0.arguments = ["-lc", command]
}
}

/// Runs a configured process and returns stdout, or `nil` on failure /
/// nonzero exit / timeout. The timeout stops a hung `burn` from wedging the
/// actor and queuing follow-up spend requests behind it.
private func capture(_ configure: (Process) -> Void, timeout: TimeInterval = 30) -> String? {
let process = Process()
configure(process)
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
do {
try process.run()
} catch {
return nil
}
// Read stdout to EOF (which arrives when the process exits) and reap it
// on a background queue; bound the wait with a timeout. This blocks the
// runner actor until the process truly finishes or is killed — which,
// because the actor serializes calls, guarantees only one `burn`
// subprocess can ever be alive at a time (no pile-up). Avoids the
// `terminationHandler` race that could let capture() return while the
// child kept running.
let group = DispatchGroup()
group.enter()
let output = DataBox()
DispatchQueue.global(qos: .utility).async {
output.data = stdout.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
group.leave()
}
// Drain stderr separately: an undrained pipe fills at ~64KB and blocks
// the child's writes, turning a chatty failure into a timeout.
DispatchQueue.global(qos: .utility).async {
_ = stderr.fileHandleForReading.readDataToEndOfFile()
/// Runs a configured process on the serial exec queue and returns stdout,
/// or `nil` on failure / nonzero exit / timeout. The timeout
/// (`captureTimeout`) stops a hung `burn` from wedging the queue and piling
/// follow-up spend requests behind it. Awaiting the queue keeps the
/// cooperative pool free while the child runs. `label` names the subprocess
/// in the Instruments signpost interval, which spans queue wait + run.
private func capture(label: String, _ configure: @escaping @Sendable (Process) -> Void) async -> String? {
let signpostID = Signposts.subprocess.makeSignpostID()
let interval = Signposts.subprocess.beginInterval(
"capture", id: signpostID, "\(label, privacy: .public)"
)
defer { Signposts.subprocess.endInterval("capture", interval) }

let timeout = captureTimeout
return await withCheckedContinuation { continuation in
execQueue.async {
continuation.resume(returning: Self.blockingCapture(timeout: timeout, configure))
}
}
if group.wait(timeout: .now() + timeout) == .timedOut {
process.terminate() // SIGTERM…
usleep(200_000)
if process.isRunning { // …then SIGKILL if it ignores it
kill(process.processIdentifier, SIGKILL)
}

/// The synchronous Process/Pipe body. Pure — touches no actor state — so it
/// runs on the exec queue, off the cooperative pool.
///
/// Descriptor hygiene matters here: the parent-side `Pipe`/`FileHandle`
/// objects are Obj-C autoreleased, and on GCD threads the pool drains
/// lazily — without the explicit `close()`s and per-call `autoreleasepool`
/// each spawn leaked ~2 fds long after the child exited (caught by
/// `SubprocessSoakTests`; with a spawn every 3s on the live tab this is a
/// real production leak, not test noise).
private static func blockingCapture(timeout: TimeInterval, _ configure: (Process) -> Void) -> String? {
autoreleasepool { () -> String? in
let process = Process()
configure(process)
let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
do {
try process.run()
} catch {
return nil
}
return nil
// Read stdout to EOF (which arrives when the process exits) and reap
// it on a background queue; bound the wait with a timeout. This
// blocks the exec queue until the process truly finishes or is
// killed — which, because that queue is serial, guarantees only one
// `burn` subprocess can ever be alive at a time (no pile-up). Avoids
// the `terminationHandler` race that could let capture() return
// while the child kept running.
let group = DispatchGroup()
group.enter()
let output = DataBox()
DispatchQueue.global(qos: .utility).async {
output.data = stdout.fileHandleForReading.readDataToEndOfFile()
try? stdout.fileHandleForReading.close()
process.waitUntilExit()
group.leave()
}
// Drain stderr separately: an undrained pipe fills at ~64KB and
// blocks the child's writes, turning a chatty failure into a timeout.
DispatchQueue.global(qos: .utility).async {
_ = stderr.fileHandleForReading.readDataToEndOfFile()
try? stderr.fileHandleForReading.close()
}
if group.wait(timeout: .now() + timeout) == .timedOut {
process.terminate() // SIGTERM…
usleep(200_000)
if process.isRunning { // …then SIGKILL if it ignores it
kill(process.processIdentifier, SIGKILL)
}
// The kill closes the child's write ends, so the background
// readers hit EOF, return, and close their handles. Give them a
// bounded beat (avoids closing under an in-flight legacy read),
// then close defensively — double-closes throw and are swallowed.
_ = group.wait(timeout: .now() + 1)
try? stdout.fileHandleForReading.close()
try? stderr.fileHandleForReading.close()
return nil
}
guard process.terminationStatus == 0 else { return nil }
return String(data: output.data, encoding: .utf8)
}
guard process.terminationStatus == 0 else { return nil }
return String(data: output.data, encoding: .utf8)
}

/// Mutable holder the background reader fills in; capturing a `var` for
Expand Down
11 changes: 10 additions & 1 deletion apps/macos/Sources/Burn/LiveBurnViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import SwiftUI
import os

/// One bucket of the burn series for a provider: the per-bucket burn rate plus
/// the running cumulative totals across the selected range.
struct LiveBurnSample: Identifiable {
let id = UUID()
/// Identity is the bucket's date, not a fresh `UUID` per refresh. Series are
/// stored per-provider and each chart `ForEach` iterates a single provider's
/// array, where bucket dates are unique — so this is stable across the ~3s
/// refresh. A per-refresh `UUID` forced Swift Charts to rebuild every mark
/// instead of diffing, which showed up as churn/flicker on the live tab.
var id: Date { date }
let date: Date
/// Cumulative cost (USD) across the range up to and including this bucket.
let cost: Double
Expand Down Expand Up @@ -165,6 +171,9 @@ final class LiveBurnViewModel: ObservableObject {
refreshing = true
defer { refreshing = false }

let interval = Signposts.refresh.beginInterval("liveRefresh")
defer { Signposts.refresh.endInterval("liveRefresh", interval) }

repeat {
refreshAgain = false
let range = self.range
Expand Down
17 changes: 17 additions & 0 deletions apps/macos/Sources/Burn/Signposts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import os

/// Lightweight `os_signpost` instrumentation so the app's hot paths can be
/// profiled in Instruments (Time Profiler / os_signpost lane). Filter by
/// subsystem `com.agentworkforce.burn` and pick a category:
///
/// - `refresh` — the view-model refresh loops and spend load.
/// - `subprocess` — each `burn` process execution (the interval message carries
/// the first CLI argument, e.g. `summary`).
///
/// Signposters are cheap and safe to leave compiled in; they emit nothing unless
/// a tool is recording. `OSSignpostIntervalState` is `Sendable`, so an interval
/// begun before an `await` can be ended after it.
enum Signposts {
static let refresh = OSSignposter(subsystem: "com.agentworkforce.burn", category: "refresh")
static let subprocess = OSSignposter(subsystem: "com.agentworkforce.burn", category: "subprocess")
}
5 changes: 5 additions & 0 deletions apps/macos/Sources/Burn/UsageViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import os

/// This-period and previous-period spend (USD) for one usage window, from the
/// burn ledger.
Expand Down Expand Up @@ -112,6 +113,8 @@ final class UsageViewModel: ObservableObject {
func refresh(force: Bool = false) async {
guard let provider = providers[selectedProvider] else { return }
if !force, let until = backoffUntil, Date() < until { return }
let refreshSignpost = Signposts.refresh.beginInterval("usageRefresh")
defer { Signposts.refresh.endInterval("usageRefresh", refreshSignpost) }
isLoading = true
let result = await provider.fetch()
let now = Date()
Expand Down Expand Up @@ -177,6 +180,8 @@ final class UsageViewModel: ObservableObject {
if let last = lastSpendAt, Date().timeIntervalSince(last) < spendInterval {
return
}
let spendSignpost = Signposts.refresh.beginInterval("loadSpend")
defer { Signposts.refresh.endInterval("loadSpend", spendSignpost) }
lastSpendAt = Date()
let burnProvider = BurnLedger.burnProvider(for: provider)
var result: [String: PeriodSpend] = [:]
Expand Down
Loading
Loading