Skip to content

Commit 0058999

Browse files
committed
fix(telemetry): bound client.shutdown() phase, strengthen breaker test
1 parent 32bd50b commit 0058999

3 files changed

Lines changed: 64 additions & 9 deletions

File tree

packages/telemetry/src/TelemetryService.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@ const CIRCUIT_BREAKER_WINDOW_MS = 10 * 60 * 1000
2626
const CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000
2727

2828
/**
29-
* Upper bound on how long shutdown() will wait for in-flight capture calls to drain.
30-
* deactivate() awaits shutdown() before terminal cleanup, so an unbounded wait here
31-
* (e.g. a capture stuck on network I/O that never resolves/rejects) would block the
32-
* extension host from ever finishing deactivation. Losing an in-flight capture on
33-
* timeout is an acceptable tradeoff against blocking shutdown indefinitely.
29+
* Upper bound applied separately to each phase of shutdown(): draining in-flight capture
30+
* calls, then awaiting client.shutdown(). deactivate() awaits shutdown() before terminal
31+
* cleanup, so an unbounded wait in either phase (e.g. a capture stuck on network I/O, or
32+
* a client's own shutdown() -- posthog-node defaults to a 30s internal timeout -- never
33+
* settling) would block the extension host from ever finishing deactivation. Losing an
34+
* in-flight capture, or a client's graceful flush, on timeout is an acceptable tradeoff
35+
* against blocking shutdown indefinitely. Worst case, shutdown() takes up to roughly
36+
* 2 * SHUTDOWN_PHASE_TIMEOUT_MS.
3437
*/
35-
const SHUTDOWN_DRAIN_TIMEOUT_MS = 3000
38+
const SHUTDOWN_PHASE_TIMEOUT_MS = 3000
3639

3740
/**
3841
* TelemetryService wrapper class that defers initialization.
@@ -368,15 +371,23 @@ export class TelemetryService {
368371
// stuck on network I/O that never resolves/rejects can't block deactivate() forever --
369372
// losing that one capture is an acceptable tradeoff against hanging terminal cleanup.
370373
const drainStart = Date.now()
371-
while (this.pendingClientCalls.size > 0 && Date.now() - drainStart < SHUTDOWN_DRAIN_TIMEOUT_MS) {
374+
while (this.pendingClientCalls.size > 0 && Date.now() - drainStart < SHUTDOWN_PHASE_TIMEOUT_MS) {
372375
await Promise.race([
373376
Promise.all(this.pendingClientCalls),
374-
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_DRAIN_TIMEOUT_MS - (Date.now() - drainStart))),
377+
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_PHASE_TIMEOUT_MS - (Date.now() - drainStart))),
375378
])
376379
}
377380

381+
// Bound client shutdown the same way as the drain above: posthog-node's own shutdown()
382+
// defaults to a 30s internal timeout when called with no argument (as PostHogTelemetryClient
383+
// does), and TelemetryClient#shutdown() takes no timeout parameter to pass one through. Racing
384+
// against our own timer here, instead of just awaiting client.shutdown() directly, keeps
385+
// deactivate() from blocking for up to 30s on a client that never settles.
378386
// allSettled, not all: one client rejecting must not stop us from awaiting the others.
379-
await Promise.allSettled(this.clients.map((client) => client.shutdown()))
387+
await Promise.race([
388+
Promise.allSettled(this.clients.map((client) => client.shutdown())),
389+
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_PHASE_TIMEOUT_MS)),
390+
])
380391
}
381392

382393
private static _instance: TelemetryService | null = null

packages/telemetry/src/__tests__/TelemetryService.circuit-breaker.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,5 +137,12 @@ describe("TelemetryService circuit breaker", () => {
137137
}
138138

139139
expect(mockClient.capture).not.toHaveBeenCalled()
140+
141+
// Prove the 50 zero-client calls didn't count towards the breaker: once a client is
142+
// registered, the very next capture must still go through instead of being dropped.
143+
service.register(mockClient)
144+
service.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { i: 50 })
145+
146+
expect(mockClient.capture).toHaveBeenCalledTimes(1)
140147
})
141148
})

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,43 @@ describe("TelemetryService.shutdown draining", () => {
215215
}
216216
})
217217

218+
it("does not hang forever when client.shutdown() itself never settles", async () => {
219+
// A capture-drain timeout alone isn't enough: posthog-node's shutdown() defaults to a
220+
// 30s internal timeout when called with no argument, and TelemetryClient#shutdown() has
221+
// no way to pass a shorter one through. TelemetryService.shutdown() must apply its own
222+
// bound to this phase too, or deactivate() could block for up to 30s on a client whose
223+
// own shutdown() never settles.
224+
vi.useFakeTimers()
225+
try {
226+
const mockClient: TelemetryClient = {
227+
setProvider: vi.fn(),
228+
capture: vi.fn().mockResolvedValue(undefined),
229+
captureException: vi.fn(),
230+
updateTelemetryState: vi.fn(),
231+
isTelemetryEnabled: vi.fn().mockReturnValue(true),
232+
// Never resolves -- simulates posthog-node's shutdown() stuck past its own timeout.
233+
shutdown: vi.fn().mockImplementation(() => new Promise(() => {})),
234+
}
235+
236+
const service = new TelemetryService([mockClient])
237+
238+
const shutdownPromise = service.shutdown()
239+
let settled = false
240+
void shutdownPromise.then(() => {
241+
settled = true
242+
})
243+
244+
// No pending captures, so the drain phase resolves immediately; only the
245+
// client-shutdown phase's own timeout should be needed here.
246+
await vi.advanceTimersByTimeAsync(3000)
247+
248+
expect(settled).toBe(true)
249+
expect(mockClient.shutdown).toHaveBeenCalledTimes(1)
250+
} finally {
251+
vi.useRealTimers()
252+
}
253+
})
254+
218255
it("calling shutdown() twice shuts down clients twice (no re-entrancy guard)", async () => {
219256
// Documents current behavior: shutdown() has no guard against being called more than
220257
// once. deactivate() only calls it once, so this isn't a bug to fix here, but a second

0 commit comments

Comments
 (0)