Skip to content

Commit 8d7ade9

Browse files
anandgupta42claude
andauthored
fix: [AI-190] prevent tracing exporter timeout from leaking timers (#191)
- Add `clearTimeout` in `.finally()` to `withTimeout` so the event loop exits immediately after `endTrace()` instead of hanging for 5 seconds - Log a `console.warn` when an exporter times out (uses the previously unused `name` parameter for diagnostics) - Align `HttpExporter` internal `AbortSignal.timeout` from 10s to 5s to match the per-exporter wrapper timeout - Clean up safety-net timer in adversarial test to prevent open handles Closes #190 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9d47f1e commit 8d7ade9

File tree

3 files changed

+38
-16
lines changed

3 files changed

+38
-16
lines changed

.github/meta/commit.txt

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
docs: update site-wide docs for training and new agent modes
1+
fix: [AI-190] prevent tracing exporter timeout from leaking timers
22

3-
- Homepage: update from "Four agents" to "Seven agents" — add Researcher,
4-
Trainer, Executive cards with descriptions
5-
- Getting Started: update training link to match new pitch
6-
"Corrections That Stick"
7-
- Tools index: add Training row (3 tools + 3 skills) with link
8-
- All references now consistent with simplified training system
3+
- Add `clearTimeout` in `.finally()` to `withTimeout` so the event loop
4+
exits immediately after `endTrace()` instead of hanging for 5 seconds
5+
- Log a `console.warn` when an exporter times out (uses the previously
6+
unused `name` parameter for diagnostics)
7+
- Align `HttpExporter` internal `AbortSignal.timeout` from 10s to 5s to
8+
match the per-exporter wrapper timeout
9+
- Clean up safety-net timer in adversarial test to prevent open handles
10+
11+
Closes #190
912

1013
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ export class HttpExporter implements TraceExporter {
219219
method: "POST",
220220
headers: { "Content-Type": "application/json", ...this.headers },
221221
body: JSON.stringify(trace),
222-
signal: AbortSignal.timeout(10_000),
222+
signal: AbortSignal.timeout(5_000),
223223
})
224224

225225
if (!res.ok) return undefined
@@ -735,11 +735,24 @@ export class Tracer {
735735

736736
const trace = this.buildTraceFile(error)
737737

738-
// Wrap each exporter call to catch synchronous throws as well as rejections
738+
// Wrap each exporter call with a timeout to prevent hanging exporters
739+
// from blocking the entire endTrace call
740+
const EXPORTER_TIMEOUT_MS = 5_000
741+
const withTimeout = (p: Promise<string | undefined>, name: string) => {
742+
let timer: ReturnType<typeof setTimeout>
743+
const timeout = new Promise<undefined>((resolve) => {
744+
timer = setTimeout(() => {
745+
console.warn(`[tracing] Exporter "${name}" timed out after ${EXPORTER_TIMEOUT_MS}ms`)
746+
resolve(undefined)
747+
}, EXPORTER_TIMEOUT_MS)
748+
})
749+
return Promise.race([p, timeout]).finally(() => clearTimeout(timer))
750+
}
751+
739752
const results = await Promise.allSettled(
740753
this.exporters.map((e) => {
741754
try {
742-
return e.export(trace)
755+
return withTimeout(Promise.resolve(e.export(trace)), e.name)
743756
} catch {
744757
return Promise.resolve(undefined)
745758
}

packages/opencode/test/altimate/tracing-adversarial.test.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -548,18 +548,24 @@ describe("Adversarial — exporter failures", () => {
548548
}
549549
const fileExporter = makeExporter()
550550

551+
// Put FileExporter first so its result is returned
551552
const tracer = Tracer.withExporters([fileExporter, hangingExporter])
552553
tracer.startTrace("s-hang", { prompt: "test" })
553554

554-
// Use Promise.race to prevent test from hanging
555+
// endTrace has a per-exporter timeout (5s), so the hanging exporter
556+
// will be timed out and the FileExporter result returned.
557+
// Use a generous outer timeout just as a safety net.
558+
let safetyTimer: ReturnType<typeof setTimeout>
555559
const result = await Promise.race([
556560
tracer.endTrace(),
557-
new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 5000)),
558-
])
561+
new Promise<string>((resolve) => {
562+
safetyTimer = setTimeout(() => resolve("timeout"), 8000)
563+
}),
564+
]).finally(() => clearTimeout(safetyTimer!))
559565

560-
// This will either return the file path or "timeout" — either way no crash
561-
expect(typeof result).toBe("string")
562-
})
566+
// Should get the file path from FileExporter, not "timeout"
567+
expect(result).toContain(".json")
568+
}, 10000)
563569

564570
test("exporter that returns null/undefined", async () => {
565571
const nullExporter: TraceExporter = {

0 commit comments

Comments
 (0)