Skip to content
42 changes: 41 additions & 1 deletion packages/kilo-telemetry/src/__tests__/telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { describe, test, expect, beforeEach, mock } from "bun:test"
import { Identity } from "../identity.js"
import { TelemetryEvent } from "../events.js"
import { Telemetry } from "../telemetry.js"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Static import prevents the PostHog mock from replacing the client under test

This file imports Telemetry before mock.module("posthog-node", ...) runs, which also loads ../client.js and binds it to the real PostHog import. The later dynamic imports return the cached modules, so Client.init() will not use this fake class and the test can either hit the real client or fail to assert the intended shutdown call. Move the telemetry/client imports into the test after the mock is registered (and remove this top-level import) so the mocked posthog-node is the dependency used by Client.init().

Expand Down Expand Up @@ -89,3 +89,43 @@ describe("Telemetry", () => {
expect(typeof Telemetry.trackIndexingError).toBe("function")
})
})

describe("Telemetry.shutdown timeout (#9788)", () => {
test("passes timeoutMs through to PostHog.shutdown and skips unbounded explicit flush()", async () => {
// Reproduces the CLI exit hang reported in #9788: when the PostHog endpoint
// is unreachable (offline, firewall, DNS adblock resolving the host to
// 0.0.0.0), an explicit flush() call before shutdown retries 3x with 3s
// gaps plus 10s per attempt before throwing, blocking process.exit on
// short-lived commands like `kilo --help`. The fix drops the explicit
// flush() (PostHog.shutdown drains the queue itself) and threads a caller-
// supplied timeoutMs through to PostHog.shutdown.
let flushCalls = 0
const shutdownCalls: Array<number | undefined> = []
mock.module("posthog-node", () => ({
PostHog: class {
async flush() {
flushCalls += 1
await new Promise((resolve) => setTimeout(resolve, 60_000))
}
async shutdown(timeoutMs?: number) {
shutdownCalls.push(timeoutMs)
}
optIn() {}
optOut() {}
capture() {}
alias() {}
},
}))

const { Telemetry } = await import("../telemetry.js")
const { Client } = await import("../client.js")
Client.init()
const start = Date.now()
await Telemetry.shutdown(50)
const elapsed = Date.now() - start

expect(flushCalls).toBe(0)
expect(shutdownCalls).toEqual([50])
expect(elapsed).toBeLessThan(1000)
})
})
16 changes: 11 additions & 5 deletions packages/kilo-telemetry/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,18 @@ export namespace Client {
})
}

export async function shutdown(): Promise<void> {
export async function shutdown(timeoutMs?: number): Promise<void> {
if (client) {
// Flush any pending events before shutdown
await client.flush()
await client.shutdown()
client = null
try {
// PostHog's shutdown drains the queue internally and is bounded by
// shutdownTimeoutMs. Calling flush() first is redundant and unbounded:
// when the endpoint is unreachable (offline, firewall, DNS adblock),
// flush retries up to 3x with 3s delays plus 10s per attempt before
// throwing, blocking process exit before shutdown's outer cap kicks in.
await client.shutdown(timeoutMs)
} finally {
client = null
}
}
}
}
4 changes: 2 additions & 2 deletions packages/kilo-telemetry/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export namespace Telemetry {
track(TelemetryEvent.ERROR, { error, context })
}

export async function shutdown(): Promise<void> {
await Client.shutdown()
export async function shutdown(timeoutMs?: number): Promise<void> {
await Client.shutdown(timeoutMs)
}
}
5 changes: 4 additions & 1 deletion packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,10 @@ try {
// kilocode_change start - Track CLI exit and shutdown telemetry
const exitCode = typeof process.exitCode === "number" ? process.exitCode : undefined
Telemetry.trackCliExit(exitCode)
await Telemetry.shutdown()
// Bound telemetry shutdown so an unreachable endpoint (offline, firewall,
// DNS adblock resolving the host to 0.0.0.0) cannot block process exit on
// short-lived commands like `kilo --help` / `kilo --version` (#9788).
await Telemetry.shutdown(2000)
// kilocode_change end

await Instance.disposeAll() // kilocode_change - safety net disposal (no-op if already disposed)
Expand Down