Skip to content
Closed
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
32 changes: 30 additions & 2 deletions mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import Foundation
/// Pipe file descriptors pinned forever.
private let maxPayloadBytes = 20 * 1024 * 1024
private let maxStderrBytes = 256 * 1024
private let maxDecodeStdoutPreviewBytes = 4 * 1024
private let spawnTimeoutSeconds: UInt64 = 45
private let maxConcurrentSpawns = 6

enum DataClientError: Error {
case spawn(String)
case nonZeroExit(code: Int32, stderr: String)
case decode(Error)
case decode(Error, stdoutPreview: String, stderr: String, stdoutBytes: Int)
case timeout
case outputTooLarge
}
Expand Down Expand Up @@ -46,7 +47,12 @@ struct DataClient {
do {
return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
} catch {
throw DataClientError.decode(error)
throw DataClientError.decode(
error,
stdoutPreview: decodeFailureStdoutPreview(from: result.stdout),
stderr: result.stderr,
stdoutBytes: result.stdout.count
)
}
}

Expand Down Expand Up @@ -176,6 +182,28 @@ struct DataClient {
}
return buffer
}

static func decodeFailureStdoutPreview(from stdout: Data) -> String {
let preview = stdout.prefix(maxDecodeStdoutPreviewBytes)
return String(decoding: preview, as: UTF8.self)
}
}

extension DataClientError: CustomStringConvertible {
var description: String {
switch self {
case .spawn(let message):
return "spawn(\(String(reflecting: message)))"
case .nonZeroExit(let code, let stderr):
return "nonZeroExit(code: \(code), stderr: \(String(reflecting: stderr)))"
case .decode(let error, let stdoutPreview, let stderr, let stdoutBytes):
return "decode(\(error); stdoutPreview(first \(stdoutPreview.utf8.count) of \(stdoutBytes) bytes): \(String(reflecting: stdoutPreview)); stderr: \(String(reflecting: stderr)))"
case .timeout:
return "timeout"
case .outputTooLarge:
return "outputTooLarge"
}
}
}

/// One-shot async signal that bridges `Process.terminationHandler` (invoked on a
Expand Down
86 changes: 86 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/DataClientDecodeErrorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import XCTest
@testable import CodeBurnMenubar

final class DataClientDecodeErrorTests: XCTestCase {
func testDecodeFailureMessageIncludesStdoutPreviewAndStderr() async throws {
let stdoutBanner = "NODE_V25_STDOUT_BANNER leaked before JSON"
let stderrText = "stderr marker: node emitted diagnostics"

try await withFakeCodeburn(stdout: "\(stdoutBanner)\n\(validMenubarJSON())", stderr: stderrText) {
do {
_ = try await DataClient.fetch(period: .today, provider: .all, includeOptimize: false)
XCTFail("Expected menubar payload decode to fail")
} catch {
let message = String(describing: error)
XCTAssertTrue(message.contains(stdoutBanner), "message did not include stdout preview: \(message)")
XCTAssertTrue(message.contains(stderrText), "message did not include stderr: \(message)")
}
}
}

func testDecodeFailureStdoutPreviewIsCappedAtFourKilobytes() async throws {
let stdoutHead = "CAP_START_STDOUT_515"
let stdoutTail = "CAP_TAIL_SHOULD_NOT_APPEAR"
let largeStdout = stdoutHead
+ String(repeating: "A", count: 5_000)
+ stdoutTail
+ "\n"
+ validMenubarJSON()
let stderrText = "stderr marker: cap diagnostics"

try await withFakeCodeburn(stdout: largeStdout, stderr: stderrText) {
do {
_ = try await DataClient.fetch(period: .today, provider: .all, includeOptimize: false)
XCTFail("Expected menubar payload decode to fail")
} catch {
let message = String(describing: error)
XCTAssertTrue(message.contains(stdoutHead), "message did not include start of stdout preview: \(message)")
XCTAssertTrue(message.contains(stderrText), "message did not include stderr: \(message)")
XCTAssertFalse(message.contains(stdoutTail), "message embedded stdout beyond the 4 KB preview cap")
}
}
}

private func withFakeCodeburn(stdout: String, stderr: String, body: () async throws -> Void) async throws {
let fakeURL = FileManager.default.temporaryDirectory
.appendingPathComponent("codeburn-fake-\(UUID().uuidString)")
let script = """
#!/bin/sh
cat <<'CODEBURN_STDOUT_EOF'
\(stdout)
CODEBURN_STDOUT_EOF
cat >&2 <<'CODEBURN_STDERR_EOF'
\(stderr)
CODEBURN_STDERR_EOF
exit 0
"""
try script.write(to: fakeURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakeURL.path)

let previousAllow = getenv("CODEBURN_ALLOW_DEV_BIN").map { String(cString: $0) }
let previousBin = getenv("CODEBURN_BIN").map { String(cString: $0) }
setenv("CODEBURN_ALLOW_DEV_BIN", "1", 1)
setenv("CODEBURN_BIN", fakeURL.path, 1)
defer {
restoreEnv("CODEBURN_ALLOW_DEV_BIN", previousAllow)
restoreEnv("CODEBURN_BIN", previousBin)
try? FileManager.default.removeItem(at: fakeURL)
}

try await body()
}

private func restoreEnv(_ name: String, _ value: String?) {
if let value {
setenv(name, value, 1)
} else {
unsetenv(name)
}
}

private func validMenubarJSON() -> String {
"""
{"generated":"2026-06-22T00:00:00Z","current":{"label":"Today","cost":0,"calls":0,"sessions":0,"oneShotRate":null,"inputTokens":0,"outputTokens":0,"cacheHitPercent":0,"codexCredits":0,"topActivities":[],"topModels":[],"localModelSavings":{"totalUSD":0,"calls":0,"byModel":[],"byProvider":[]},"providers":{},"topProjects":[],"modelEfficiency":[],"topSessions":[],"retryTax":{"totalUSD":0,"retries":0,"editTurns":0,"byModel":[]},"routingWaste":{"totalSavingsUSD":0,"baselineModel":"","baselineCostPerEdit":0,"byModel":[]},"tools":[],"skills":[],"subagents":[],"mcpServers":[]},"optimize":{"findingCount":0,"savingsUSD":0,"topFindings":[]},"history":{"daily":[]}}
"""
}
}
Loading