Skip to content

Commit 8a602f9

Browse files
Haiderclaude
andcommitted
fix(tracing): sanitize sessionId in markCrashed to match export() lookup key (cubic P1)
cubic-dev-ai flagged a P1 (confidence 8) in the consensus-review fix: the crash guard could be silently bypassed for any sessionId containing the file-name-unsafe chars `/`, `\`, `.`, or `:`. Six independent LLM reviewers missed this — see memory note on AI-review blind spots for data-flow bugs. The mismatch: - flushSync calls `markCrashed(this.sessionId)` with the RAW value - FileExporter.export checks `crashedSessions.has(trace.sessionId)` where `trace.sessionId` comes from `buildTraceFile` and is sanitized via `replace(/[/\\.:]/g, "_")` For any sessionId containing one of those chars, the Set entry and the lookup key differ, so the guard returns false and export proceeds — the exact scenario the M3 fix was meant to prevent. Fix: sanitize in markCrashed using the same regex, so the Set always stores the canonical (sanitized) key. Lookup-and-store now agree. Regression test added: a sessionId with `:`, `/`, and `.` is marked crashed, then export() is called with the corresponding sanitized trace. Pre-fix: export proceeds and overwrites flushSync's crashed file. Post-fix: export returns undefined; flushSync's file stands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0957fd8 commit 8a602f9

2 files changed

Lines changed: 58 additions & 2 deletions

File tree

packages/opencode/src/altimate/observability/tracing.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,15 @@ export class FileExporter implements TraceExporter {
187187
private crashedSessions = new Set<string>()
188188
/** Mark a session's exports as superseded by a synchronous crash write.
189189
* Subsequent or in-flight export() calls for that session bail at the
190-
* next checkpoint (entry, pre-writeFile, or pre-rename). Idempotent. */
190+
* next checkpoint (entry, pre-writeFile, or pre-rename). Idempotent.
191+
* The sessionId is normalized through the same sanitization that
192+
* buildTraceFile / export use for the on-disk file name, so callers
193+
* can pass the raw `this.sessionId` without worrying about whether
194+
* it contains path-unsafe characters. */
191195
markCrashed(sessionId: string) {
192-
if (sessionId) this.crashedSessions.add(sessionId)
196+
if (!sessionId) return
197+
const safeId = sessionId.replace(/[/\\.:]/g, "_") || "unknown"
198+
this.crashedSessions.add(safeId)
193199
}
194200
// altimate_change end
195201

packages/opencode/test/altimate/tracing-rename-race.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,56 @@ describe("trace corruption — flushSync vs in-flight rename race", () => {
437437
expect(traceFileB.summary.status).toBe("completed")
438438
})
439439

440+
// Regression for cubic-dev-ai's P1 review finding on PR #867:
441+
// markCrashed() was being called with the raw sessionId, but the export()
442+
// suppression check uses trace.sessionId (which comes from buildTraceFile
443+
// and is sanitized through the `[/\\.:]` → `_` regex). A sessionId
444+
// containing any of those characters would store under one key in the
445+
// crashedSessions Set and be looked up under a different key in export(),
446+
// silently bypassing the crash guard. Fix: sanitize at the store side too.
447+
test("crash guard handles unsafe sessionId chars (sanitization parity)", async () => {
448+
const exporter = new FileExporter(tmpDir)
449+
const tracer = Trace.withExporters([exporter])
450+
// Pick a sessionId that needs sanitization through the file-name regex
451+
const unsafeId = "weird:session/with.unsafe:chars"
452+
tracer.startTrace(unsafeId, { prompt: "test" })
453+
await new Promise((r) => setTimeout(r, 50))
454+
tracer.flushSync("crash with unsafe id")
455+
456+
// Manually invoke export() with the canonical (sanitized) trace data —
457+
// this is what endTrace would do internally. If sanitization parity is
458+
// broken, the markCrashed Set entry won't match and the export will
459+
// overwrite flushSync's content.
460+
const trace: TraceFile = {
461+
version: 2,
462+
traceId: "t",
463+
sessionId: unsafeId.replace(/[/\\.:]/g, "_"),
464+
startedAt: new Date().toISOString(),
465+
metadata: {},
466+
spans: [],
467+
summary: {
468+
totalTokens: 0,
469+
totalCost: 0,
470+
totalToolCalls: 0,
471+
totalGenerations: 0,
472+
duration: 0,
473+
status: "completed",
474+
tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
475+
},
476+
}
477+
const result = await exporter.export(trace)
478+
// export() must bail because the session was marked crashed; the
479+
// sanitized lookup must find the (sanitized) entry from markCrashed.
480+
expect(result).toBeUndefined()
481+
482+
// And the on-disk file must still be flushSync's crashed content
483+
const safeId = unsafeId.replace(/[/\\.:]/g, "_")
484+
const traceFile: TraceFile = JSON.parse(
485+
await fs.readFile(path.join(tmpDir, `${safeId}.json`), "utf-8"),
486+
)
487+
expect(traceFile.summary.status).toBe("crashed")
488+
})
489+
440490
test("baseline — flushSync alone writes crashed content correctly (no race)", async () => {
441491
// Sanity check: without the rename delay, flushSync's content lands and survives.
442492
// If this fails we have a different bug than the race we're investigating.

0 commit comments

Comments
 (0)