Skip to content

Commit 5cb755d

Browse files
committed
fix(webview): serialize webviewDidLaunch telemetry init, polish locale copy
1 parent 7ff0890 commit 5cb755d

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(),
@@ -1817,4 +1821,106 @@ describe("webviewMessageHandler - telemetrySetting", () => {
18171821
const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls
18181822
expect(calls.at(-1)).toEqual([true])
18191823
})
1824+
1825+
// CodeRabbit follow-up on the finding #12 fix: webviewDidLaunch's telemetry init read state
1826+
// via an async provider.getStateToPostToWebview().then(...) continuation, outside
1827+
// telemetrySettingQueue -- so it could resolve after a concurrent "telemetrySetting" message
1828+
// and clobber that message's queued (correct) update with a stale value. webviewDidLaunch now
1829+
// reads getGlobalState synchronously and is routed through the same queue.
1830+
it("does not let webviewDidLaunch's telemetry init race and clobber a concurrent telemetrySetting message", async () => {
1831+
const { TelemetryService } = await import("@roo-code/telemetry")
1832+
vi.mocked(TelemetryService.hasInstance).mockReturnValue(true)
1833+
vi.mocked(vscode.env).isTelemetryEnabled = true
1834+
1835+
// webviewDidLaunch starts out "unset" (disclosed opt-out default -- opted in). Scoped to
1836+
// the "telemetrySetting" key specifically -- webviewDidLaunch also calls
1837+
// updateGlobalState("customModes", ...) through the same contextProxy mock, which must
1838+
// not clobber storedSetting.
1839+
let storedSetting: string | undefined = "unset"
1840+
vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) =>
1841+
key === "telemetrySetting" ? storedSetting : undefined,
1842+
)
1843+
vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value) => {
1844+
if (key === "telemetrySetting") {
1845+
storedSetting = value as string
1846+
}
1847+
})
1848+
1849+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([])
1850+
;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(undefined)
1851+
;(mockClineProvider as any).providerSettingsManager = {
1852+
listConfig: vi.fn().mockResolvedValue(undefined),
1853+
}
1854+
1855+
// Deferred-promise handshake instead of setTimeout delays, so ordering is enforced
1856+
// explicitly rather than by racing real clock delays. Signals when webviewDidLaunch has
1857+
// taken its (pre-fix) state snapshot -- only fires under the *old* code path
1858+
// (provider.getStateToPostToWebview().then(...)); the fix never calls it at all.
1859+
let snapshotTaken!: () => void
1860+
const snapshotTakenPromise = new Promise<void>((resolve) => {
1861+
snapshotTaken = resolve
1862+
})
1863+
let releaseSnapshot!: () => void
1864+
const snapshotReleased = new Promise<void>((resolve) => {
1865+
releaseSnapshot = resolve
1866+
})
1867+
1868+
// Snapshots storedSetting at call time (mirroring the real ClineProvider building its
1869+
// state object synchronously before any internal awaits), signals it was taken, then
1870+
// waits until the test explicitly releases it -- by which point the concurrent
1871+
// telemetrySetting write below has already landed, making the snapshot genuinely stale
1872+
// once its .then() callback finally runs.
1873+
;(mockClineProvider as any).getStateToPostToWebview = vi.fn().mockImplementation(async () => {
1874+
const snapshot = storedSetting
1875+
snapshotTaken()
1876+
await snapshotReleased
1877+
return { telemetrySetting: snapshot }
1878+
})
1879+
1880+
// webviewDidLaunch fires first (e.g. webview reload) -- its telemetry init is now queued
1881+
// behind telemetrySettingQueue rather than resolving independently.
1882+
const launch = webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" } as any)
1883+
1884+
// Wait for webviewDidLaunch to either take its (pre-fix) snapshot, or flush a fixed
1885+
// number of microtask turns as a same-tick fallback for the fixed code path (which never
1886+
// triggers that signal) -- enough for its synchronous prefix (await getCustomModes(),
1887+
// await updateGlobalState()) to run, without relying on a wall-clock timer.
1888+
await Promise.race([
1889+
snapshotTakenPromise,
1890+
(async () => {
1891+
for (let i = 0; i < 10; i++) {
1892+
await Promise.resolve()
1893+
}
1894+
})(),
1895+
])
1896+
1897+
// A concurrent "telemetrySetting" message turns telemetry off, and is awaited to
1898+
// completion -- including its own updateTelemetryState(false) call -- *before* the
1899+
// deferred (pre-fix-only) snapshot below is released. Against the pre-fix code, this
1900+
// proves the snapshot it captured earlier ("unset") is genuinely stale by the time its
1901+
// .then() callback finally runs: the user's real, later choice already landed.
1902+
const disable = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" })
1903+
await disable
1904+
1905+
// Now release the deferred snapshot so a getStateToPostToWebview() call, if the old code
1906+
// path is exercised, resolves (with its already-captured, now-stale value) only after
1907+
// the disable write above has fully landed.
1908+
const snapshotResolved = vi.mocked((mockClineProvider as any).getStateToPostToWebview).mock.results[0]
1909+
?.value as Promise<unknown> | undefined
1910+
releaseSnapshot()
1911+
1912+
await Promise.all([launch, snapshotResolved])
1913+
1914+
// webviewDidLaunch's telemetry init is fire-and-forget from the handler's own point of
1915+
// view (the "webviewDidLaunch" case doesn't await it), so even awaiting
1916+
// getStateToPostToWebview() directly isn't enough to observe its .then() callback --
1917+
// flush one more microtask turn for that callback to run.
1918+
await Promise.resolve()
1919+
1920+
// The user's explicit "disabled" choice must be the final state -- webviewDidLaunch's
1921+
// queued re-application of the (by-then-stale) "unset"/opted-in state must not run after
1922+
// and override it.
1923+
const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls
1924+
expect(calls.at(-1)).toEqual([false])
1925+
})
18201926
})

src/core/webview/webviewMessageHandler.ts

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

658668
provider.isViewLaunched = true
659669
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)