From ac162030086bbb6806c3aee267bb2faa6594b325 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:09:45 +0300 Subject: [PATCH] fix(menubar): surface CLI stdout/stderr on payload decode failure (#515) --- .../CodeBurnMenubar/Data/DataClient.swift | 32 ++++++- .../DataClientDecodeErrorTests.swift | 86 +++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 mac/Tests/CodeBurnMenubarTests/DataClientDecodeErrorTests.swift diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index df9891b7..77545f89 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -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 } @@ -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 + ) } } @@ -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 diff --git a/mac/Tests/CodeBurnMenubarTests/DataClientDecodeErrorTests.swift b/mac/Tests/CodeBurnMenubarTests/DataClientDecodeErrorTests.swift new file mode 100644 index 00000000..d26af4c5 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/DataClientDecodeErrorTests.swift @@ -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":[]}} + """ + } +}