Skip to content

Commit 8eedfac

Browse files
committed
fix(webview): serialize webviewDidLaunch telemetry init, polish locale copy
1 parent ded1b6d commit 8eedfac

11 files changed

Lines changed: 130 additions & 22 deletions

File tree

PRIVACY.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,6 @@ go—and, importantly, where they don't.
4040
We retain telemetry only as long as needed for product analytics and
4141
debugging. This PostHog-based telemetry does **not** collect your code or AI
4242
prompts, and you can opt out at any time through the settings.
43-
- **Zoo Code Cloud Task Sync**: If you sign in to Zoo Code Cloud, your task
44-
history — including your prompts and the assistant's responses — is uploaded
45-
to and stored on Zoo Code's servers so you can view and resume tasks across
46-
devices. This is separate from, and more than, the PostHog usage telemetry
47-
described above. Task sync is on by default once you're signed in (or follows
48-
your organization's policy if your account belongs to one); you can turn it
49-
off in Zoo Code Cloud account settings, and signing out of Zoo Code Cloud
50-
stops it entirely.
5143
- **Marketplace Requests**: When you browse or search the Marketplace for Model
5244
Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API
5345
call to Zoo Code's backend servers to retrieve listing information. These

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ vi.mock("../../../api/providers/fetchers/lmstudio", () => ({
1111
getLMStudioModels: vi.fn(),
1212
}))
1313

14+
vi.mock("../../../integrations/theme/getTheme", () => ({
15+
getTheme: vi.fn().mockResolvedValue({}),
16+
}))
17+
1418
vi.mock("../../../integrations/openai-codex/oauth", () => ({
1519
openAiCodexOAuthManager: {
1620
getAccessToken: vi.fn(),
@@ -1533,4 +1537,106 @@ describe("webviewMessageHandler - telemetrySetting", () => {
15331537
const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls
15341538
expect(calls.at(-1)).toEqual([true])
15351539
})
1540+
1541+
// CodeRabbit follow-up on the finding #12 fix: webviewDidLaunch's telemetry init read state
1542+
// via an async provider.getStateToPostToWebview().then(...) continuation, outside
1543+
// telemetrySettingQueue -- so it could resolve after a concurrent "telemetrySetting" message
1544+
// and clobber that message's queued (correct) update with a stale value. webviewDidLaunch now
1545+
// reads getGlobalState synchronously and is routed through the same queue.
1546+
it("does not let webviewDidLaunch's telemetry init race and clobber a concurrent telemetrySetting message", async () => {
1547+
const { TelemetryService } = await import("@roo-code/telemetry")
1548+
vi.mocked(TelemetryService.hasInstance).mockReturnValue(true)
1549+
vi.mocked(vscode.env).isTelemetryEnabled = true
1550+
1551+
// webviewDidLaunch starts out "unset" (disclosed opt-out default -- opted in). Scoped to
1552+
// the "telemetrySetting" key specifically -- webviewDidLaunch also calls
1553+
// updateGlobalState("customModes", ...) through the same contextProxy mock, which must
1554+
// not clobber storedSetting.
1555+
let storedSetting: string | undefined = "unset"
1556+
vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) =>
1557+
key === "telemetrySetting" ? storedSetting : undefined,
1558+
)
1559+
vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value) => {
1560+
if (key === "telemetrySetting") {
1561+
storedSetting = value as string
1562+
}
1563+
})
1564+
1565+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([])
1566+
;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(undefined)
1567+
;(mockClineProvider as any).providerSettingsManager = {
1568+
listConfig: vi.fn().mockResolvedValue(undefined),
1569+
}
1570+
1571+
// Deferred-promise handshake instead of setTimeout delays, so ordering is enforced
1572+
// explicitly rather than by racing real clock delays. Signals when webviewDidLaunch has
1573+
// taken its (pre-fix) state snapshot -- only fires under the *old* code path
1574+
// (provider.getStateToPostToWebview().then(...)); the fix never calls it at all.
1575+
let snapshotTaken!: () => void
1576+
const snapshotTakenPromise = new Promise<void>((resolve) => {
1577+
snapshotTaken = resolve
1578+
})
1579+
let releaseSnapshot!: () => void
1580+
const snapshotReleased = new Promise<void>((resolve) => {
1581+
releaseSnapshot = resolve
1582+
})
1583+
1584+
// Snapshots storedSetting at call time (mirroring the real ClineProvider building its
1585+
// state object synchronously before any internal awaits), signals it was taken, then
1586+
// waits until the test explicitly releases it -- by which point the concurrent
1587+
// telemetrySetting write below has already landed, making the snapshot genuinely stale
1588+
// once its .then() callback finally runs.
1589+
;(mockClineProvider as any).getStateToPostToWebview = vi.fn().mockImplementation(async () => {
1590+
const snapshot = storedSetting
1591+
snapshotTaken()
1592+
await snapshotReleased
1593+
return { telemetrySetting: snapshot }
1594+
})
1595+
1596+
// webviewDidLaunch fires first (e.g. webview reload) -- its telemetry init is now queued
1597+
// behind telemetrySettingQueue rather than resolving independently.
1598+
const launch = webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" } as any)
1599+
1600+
// Wait for webviewDidLaunch to either take its (pre-fix) snapshot, or flush a fixed
1601+
// number of microtask turns as a same-tick fallback for the fixed code path (which never
1602+
// triggers that signal) -- enough for its synchronous prefix (await getCustomModes(),
1603+
// await updateGlobalState()) to run, without relying on a wall-clock timer.
1604+
await Promise.race([
1605+
snapshotTakenPromise,
1606+
(async () => {
1607+
for (let i = 0; i < 10; i++) {
1608+
await Promise.resolve()
1609+
}
1610+
})(),
1611+
])
1612+
1613+
// A concurrent "telemetrySetting" message turns telemetry off, and is awaited to
1614+
// completion -- including its own updateTelemetryState(false) call -- *before* the
1615+
// deferred (pre-fix-only) snapshot below is released. Against the pre-fix code, this
1616+
// proves the snapshot it captured earlier ("unset") is genuinely stale by the time its
1617+
// .then() callback finally runs: the user's real, later choice already landed.
1618+
const disable = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" })
1619+
await disable
1620+
1621+
// Now release the deferred snapshot so a getStateToPostToWebview() call, if the old code
1622+
// path is exercised, resolves (with its already-captured, now-stale value) only after
1623+
// the disable write above has fully landed.
1624+
const snapshotResolved = vi.mocked((mockClineProvider as any).getStateToPostToWebview).mock.results[0]
1625+
?.value as Promise<unknown> | undefined
1626+
releaseSnapshot()
1627+
1628+
await Promise.all([launch, snapshotResolved])
1629+
1630+
// webviewDidLaunch's telemetry init is fire-and-forget from the handler's own point of
1631+
// view (the "webviewDidLaunch" case doesn't await it), so even awaiting
1632+
// getStateToPostToWebview() directly isn't enough to observe its .then() callback --
1633+
// flush one more microtask turn for that callback to run.
1634+
await Promise.resolve()
1635+
1636+
// The user's explicit "disabled" choice must be the final state -- webviewDidLaunch's
1637+
// queued re-application of the (by-then-stale) "unset"/opted-in state must not run after
1638+
// and override it.
1639+
const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls
1640+
expect(calls.at(-1)).toEqual([false])
1641+
})
15361642
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -643,12 +643,22 @@ export const webviewMessageHandler = async (
643643
// vscode.env.isTelemetryEnabled is ANDed in (matching extension.ts's
644644
// onDidChangeTelemetryEnabled listener) so a webview reload can't re-enable
645645
// telemetry while VS Code's global toggle is off.
646-
provider.getStateToPostToWebview().then((state) => {
647-
const { telemetrySetting } = state
648-
TelemetryService.instance.updateTelemetryState(
649-
isTelemetryOptedIn(telemetrySetting) && vscode.env.isTelemetryEnabled,
650-
)
651-
})
646+
//
647+
// Read the setting synchronously via getGlobalState (same as the "telemetrySetting"
648+
// handler below) rather than awaiting provider.getStateToPostToWebview() -- that
649+
// async gap let this continuation resolve after a concurrent "telemetrySetting"
650+
// message's queued update and clobber it with a stale value, the same interleaving
651+
// class of bug telemetrySettingQueue exists to prevent. Routing through the queue
652+
// here too means webviewDidLaunch can't race a concurrent telemetrySetting message
653+
// either.
654+
telemetrySettingQueue = telemetrySettingQueue
655+
.catch(() => undefined)
656+
.then(async () => {
657+
const telemetrySetting = getGlobalState("telemetrySetting") || "unset"
658+
TelemetryService.instance.updateTelemetryState(
659+
isTelemetryOptedIn(telemetrySetting) && vscode.env.isTelemetryEnabled,
660+
)
661+
})
652662

653663
provider.isViewLaunched = true
654664
break

webview-ui/src/i18n/locales/fr/settings.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/it/settings.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/ja/settings.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/ko/settings.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/tr/settings.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/vi/settings.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/zh-CN/settings.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)