diff --git a/scripts/proof/session-cost-usage-readline-errors.mts b/scripts/proof/session-cost-usage-readline-errors.mts new file mode 100644 index 00000000000..a7a514b6692 --- /dev/null +++ b/scripts/proof/session-cost-usage-readline-errors.mts @@ -0,0 +1,47 @@ +// Real behavior proof: session log readline errors are swallowed at the +// diagnostic boundary so callers get a truncated but stable result. +// +// The proof creates a real transcript session directory where the session file +// is a directory instead of a file. `fs.createReadStream` on a directory emits +// an EISDIR error on the stream. With the fix, `loadSessionLogs` returns an +// empty array after the stream closes. Before the fix the unhandled stream error +// rejected `loadSessionLogs`. + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))); +const { loadSessionLogs } = await import(path.join(repoRoot, "src/infra/session-cost-usage.js")); + +const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-proof-session-cost-")); +const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); +await fs.mkdir(sessionsDir, { recursive: true }); + +// Make the session file a directory. createReadStream on a directory emits +// EISDIR, which exercises the best-effort error handler in loadSessionLogs. +const sessionFile = path.join(sessionsDir, "proof-session.jsonl"); +await fs.mkdir(sessionFile); + +console.log("=== Proof: session-cost usage readline stream error catch ===\n"); +console.log(`Created directory-as-file at: ${sessionFile}`); +console.log("Calling loadSessionLogs...\n"); + +try { + const logs = await loadSessionLogs({ sessionFile }); + if (Array.isArray(logs) && logs.length === 0) { + console.log("loadSessionLogs returned: []"); + console.log("\nPASS: EISDIR stream error was caught and loadSessionLogs returned empty logs."); + } else { + console.log(`\nFAIL: unexpected result: ${JSON.stringify(logs)}`); + process.exitCode = 1; + } +} catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log(`\nFAIL: loadSessionLogs threw: ${message}`); + console.log("The stream error should have been swallowed by loadSessionLogs."); + process.exitCode = 1; +} finally { + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/src/infra/session-cost-usage.stream-errors.test.ts b/src/infra/session-cost-usage.stream-errors.test.ts new file mode 100644 index 00000000000..46dab804cfa --- /dev/null +++ b/src/infra/session-cost-usage.stream-errors.test.ts @@ -0,0 +1,99 @@ +// Regression test: session-cost readline stream errors are swallowed instead of +// crashing the caller's async iteration. +import nodeFs from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { + loadCostUsageSummaryFromCache, + loadSessionLogs, + refreshCostUsageCache, +} from "./session-cost-usage.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("session cost usage stream errors", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("does not crash when the transcript stream emits an error mid-read", async () => { + const tempDir = tempDirs.make("openclaw-session-cost-stream-"); + const sessionsDir = path.join(tempDir, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = path.join(sessionsDir, "sess-stream-error.jsonl"); + await fs.writeFile( + sessionFile, + [ + JSON.stringify({ type: "session", version: 1, id: "sess-stream-error" }), + JSON.stringify({ + type: "message", + timestamp: new Date().toISOString(), + message: { role: "user", content: "hello" }, + }), + "", + ].join("\n"), + "utf-8", + ); + + const originalCreateReadStream = nodeFs.createReadStream; + vi.spyOn(nodeFs, "createReadStream").mockImplementationOnce((...args: unknown[]) => { + const stream = originalCreateReadStream.apply(nodeFs, args as never); + process.nextTick(() => { + stream.emit("error", new Error("stream read failed")); + }); + return stream; + }); + + const logs = await loadSessionLogs({ sessionFile }); + + expect(logs).toEqual([]); + }); + + it("does not persist a partial durable cache entry after a stream error", async () => { + const tempDir = tempDirs.make("openclaw-session-cost-cache-stream-"); + const sessionsDir = path.join(tempDir, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = path.join(sessionsDir, "sess-cache-stream-error.jsonl"); + const usageEntry = (timestamp: string, input: number) => + JSON.stringify({ + type: "message", + timestamp, + message: { + role: "assistant", + usage: { input, output: 0, totalTokens: input, cost: { total: input / 1000 } }, + }, + }); + await fs.writeFile(sessionFile, `${usageEntry("2026-07-06T12:00:00.000Z", 10)}\n`, "utf-8"); + + await withEnvAsync({ OPENCLAW_STATE_DIR: tempDir }, async () => { + await refreshCostUsageCache(); + const cachePath = path.join(sessionsDir, ".usage-cost-cache.json"); + const cacheBefore = await fs.readFile(cachePath, "utf-8"); + + await fs.appendFile(sessionFile, `${usageEntry("2026-07-06T12:01:00.000Z", 20)}\n`, "utf-8"); + const originalCreateReadStream = nodeFs.createReadStream; + vi.spyOn(nodeFs, "createReadStream").mockImplementationOnce((...args: unknown[]) => { + const stream = originalCreateReadStream.apply(nodeFs, args as never); + process.nextTick(() => { + stream.emit("error", new Error("stream read failed")); + }); + return stream; + }); + + await expect(refreshCostUsageCache()).rejects.toThrow("stream read failed"); + expect(await fs.readFile(cachePath, "utf-8")).toBe(cacheBefore); + + const summary = await loadCostUsageSummaryFromCache({ + startMs: Date.UTC(2026, 6, 6), + endMs: Date.UTC(2026, 6, 7), + requestRefresh: false, + }); + expect(summary.totals.totalTokens).toBe(10); + expect(summary.cacheStatus?.status).toBe("partial"); + expect(summary.cacheStatus?.pendingFiles).toBe(1); + }); + }); +}); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 517c6a32ce3..32e7cd2c323 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -1233,6 +1233,17 @@ async function* readJsonlRecords( } } +async function* readJsonlRecordsBestEffort( + filePath: string, +): AsyncGenerator> { + try { + yield* readJsonlRecords(filePath); + } catch { + // Diagnostic readers return the records available before a stream failure. + // Durable cache scans use the strict reader so partial data is never marked fresh. + } +} + async function scanTranscriptFile(params: { filePath: string; config?: OpenClawConfig; @@ -2651,7 +2662,7 @@ export async function loadSessionLogs(params: { const retentionLimit = limit * 2; const resolveCost = createUsageCostResolver(params.config); - for await (const parsed of readJsonlRecords(sessionFile)) { + for await (const parsed of readJsonlRecordsBestEffort(sessionFile)) { try { const message = parsed.message as Record | undefined; if (!message) {