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
28 changes: 27 additions & 1 deletion mac/Sources/CodeBurnMenubar/Data/DataClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ enum DataClientError: Error {
case outputTooLarge
}

/// Wraps a `MenubarPayload` decode failure with a bounded snippet of what the CLI
/// actually wrote to stdout (plus stderr), so a malformed-output failure — for
/// example a stray Node banner landing on stdout ahead of the JSON (see #515) —
/// is self-diagnosing in logs and the UI instead of an opaque "not valid JSON".
struct CLIDecodeFailure: Error, CustomStringConvertible {
let underlying: Error
let stdoutByteCount: Int
let stdoutSnippet: String
let stderr: String

var description: String {
var parts = [
"decode failed: \(underlying)",
"stdout (\(stdoutByteCount) bytes): \(stdoutSnippet.isEmpty ? "<empty>" : stdoutSnippet)",
]
if !stderr.isEmpty { parts.append("stderr: \(stderr)") }
return parts.joined(separator: " | ")
}
}

/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
/// commands through `/bin/zsh -c` anymore.
struct DataClient {
Expand Down Expand Up @@ -46,7 +66,13 @@ struct DataClient {
do {
return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
} catch {
throw DataClientError.decode(error)
let snippet = String(decoding: result.stdout.prefix(2048), as: UTF8.self)
throw DataClientError.decode(CLIDecodeFailure(
underlying: error,
stdoutByteCount: result.stdout.count,
stdoutSnippet: snippet,
stderr: result.stderr
))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ private func menubarPayload(cost: Double) -> MenubarPayload {
inputTokens: 1,
outputTokens: 1,
cacheHitPercent: 0,
codexCredits: nil,
topActivities: [],
topModels: [],
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
Expand Down
25 changes: 25 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ final class DataClientProcessTests: XCTestCase {
"runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool")
}

/// A decode failure surfaces the CLI's actual stdout/stderr so a stray banner
/// on stdout (see #515) is self-diagnosing instead of an opaque "not valid JSON".
func testDecodeFailureSurfacesOutput() {
struct Boom: Error {}
let failure = CLIDecodeFailure(
underlying: Boom(),
stdoutByteCount: 13,
stdoutSnippet: "(node) banner",
stderr: "warn: x"
)
let text = String(describing: failure)
XCTAssertTrue(text.contains("(node) banner"), "should include the stdout snippet")
XCTAssertTrue(text.contains("13 bytes"), "should include the stdout byte count")
XCTAssertTrue(text.contains("warn: x"), "should include stderr")
}

/// Empty stdout is reported distinctly (the JSONDecoder-on-empty-Data case).
func testDecodeFailureWithEmptyStdout() {
struct Boom: Error {}
let failure = CLIDecodeFailure(underlying: Boom(), stdoutByteCount: 0, stdoutSnippet: "", stderr: "")
let text = String(describing: failure)
XCTAssertTrue(text.contains("0 bytes"))
XCTAssertTrue(text.contains("<empty>"))
}

/// A normally-exiting process returns its real output and exit code through
/// the off-pool wait path.
func testProcessReturnsOutputAndExitCode() async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct MenubarStatusCacheTests {
current: CurrentBlock(
label: "Today", cost: cost, calls: 1, sessions: 1, oneShotRate: nil,
inputTokens: 1, outputTokens: 1, cacheHitPercent: 0,
codexCredits: nil,
topActivities: [], topModels: [], localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []), providers: ["claude": cost],
topProjects: [], modelEfficiency: [], topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
Expand Down