Skip to content

Commit 643c3cf

Browse files
committed
fix(telemetry): loop shutdown's pending-call drain instead of a single snapshot
1 parent 58729f2 commit 643c3cf

2 files changed

Lines changed: 77 additions & 1 deletion

File tree

packages/telemetry/src/TelemetryService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,11 @@ export class TelemetryService {
372372

373373
// Drain any in-flight capture/captureException calls first, so a client's shutdown()
374374
// (which flushes its queue) can't run ahead of a capture that hasn't been enqueued yet.
375-
await Promise.all(this.pendingClientCalls)
375+
// Loop rather than a single snapshot: a call queued while draining (e.g. from a
376+
// teardown-time error handler) would otherwise be missed by one Promise.all pass.
377+
while (this.pendingClientCalls.size > 0) {
378+
await Promise.all(this.pendingClientCalls)
379+
}
376380

377381
await Promise.all(this.clients.map((client) => client.shutdown()))
378382
}

packages/telemetry/src/__tests__/TelemetryService.shutdown.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,78 @@ describe("TelemetryService.shutdown draining", () => {
4444
expect(captureOrder).toEqual(["captured", "shutdown"])
4545
})
4646

47+
it("drains a second capture that gets queued after shutdown() has already started draining", async () => {
48+
// Regression test: shutdown() must not take a single Promise.all snapshot of
49+
// pendingClientCalls. A promise added to pendingClientCalls *after* Promise.all(set)
50+
// has already been called is never awaited by that call, even if it never resolves
51+
// -- Promise.all takes its list of promises to await synchronously, at call time.
52+
// So a second capture queued while the first Promise.all pass is still pending (e.g.
53+
// a teardown-time error handler reacting to something unrelated) would be silently
54+
// dropped by a single-pass drain, letting client.shutdown() run without it.
55+
let resolveFirstCapture!: () => void
56+
const firstCapturePromise = new Promise<void>((resolve) => {
57+
resolveFirstCapture = resolve
58+
})
59+
60+
let resolveSecondCapture!: () => void
61+
const secondCapturePromise = new Promise<void>((resolve) => {
62+
resolveSecondCapture = resolve
63+
})
64+
65+
const captureOrder: string[] = []
66+
let firstCaptureStarted = false
67+
68+
const mockClient: TelemetryClient = {
69+
setProvider: vi.fn(),
70+
capture: vi.fn().mockImplementation(async () => {
71+
if (!firstCaptureStarted) {
72+
firstCaptureStarted = true
73+
await firstCapturePromise
74+
captureOrder.push("first-captured")
75+
return
76+
}
77+
78+
await secondCapturePromise
79+
captureOrder.push("second-captured")
80+
}),
81+
captureException: vi.fn(),
82+
updateTelemetryState: vi.fn(),
83+
isTelemetryEnabled: vi.fn().mockReturnValue(true),
84+
shutdown: vi.fn().mockImplementation(async () => {
85+
captureOrder.push("shutdown")
86+
}),
87+
}
88+
89+
const service = new TelemetryService([mockClient])
90+
91+
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: "first" })
92+
93+
const shutdownPromise = service.shutdown()
94+
95+
// Queue the second capture synchronously, immediately after shutdown() has started
96+
// (and thus after its first Promise.all(pendingClientCalls) pass has already taken
97+
// its snapshot). This is the scenario the loop fix protects against.
98+
service.captureEvent(TelemetryEventName.TASK_CREATED, { taskId: "second" })
99+
100+
resolveFirstCapture()
101+
// Flush several microtask ticks so a buggy single-pass drain has every opportunity
102+
// to run client.shutdown() before we check -- a couple of ticks isn't enough since
103+
// the mocked capture/shutdown chain itself spans a few microtask hops.
104+
for (let i = 0; i < 4; i++) {
105+
await Promise.resolve()
106+
}
107+
108+
// The second capture is still pending (its own resolver hasn't fired) -- shutdown
109+
// must not have completed yet, otherwise it dropped the second capture.
110+
expect(captureOrder).not.toContain("shutdown")
111+
112+
resolveSecondCapture()
113+
await shutdownPromise
114+
115+
expect(mockClient.capture).toHaveBeenCalledTimes(2)
116+
expect(captureOrder).toEqual(["first-captured", "second-captured", "shutdown"])
117+
})
118+
47119
it("does not let a rejected capture prevent shutdown from completing", async () => {
48120
const mockClient: TelemetryClient = {
49121
setProvider: vi.fn(),

0 commit comments

Comments
 (0)