Keep rapid settings edits from reverting each other#4494
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
ApprovabilityVerdict: Needs human review 2 blocking correctness issues found. This PR introduces a new optimistic overlay system for settings with significant runtime behavior changes. Multiple unresolved review comments identify high-severity bugs in the echo counting and failure handling logic that could recreate the very issue this PR aims to fix. You can customize Macroscope's approvability policy. Learn more. |
|
|
||
| if (Object.keys(serverPatch).length > 0) { | ||
| if (environmentId) { | ||
| const pendingId = retainPendingServerPatch( |
There was a problem hiding this comment.
🟡 Medium hooks/useSettings.ts:294
A failed server settings update can be silently committed by a later update that overlaps it in flight. For example, toggle provider A then immediately toggle provider B: B's providerInstances map is computed from the optimistically overlaid settings (which include A's pending edit) and dispatched to the server. If A's RPC fails, dropPendingServerPatch removes A's local overlay, but B's already-sent patch still carries A's edit and persists it on the server. The overlaid base used to compute each outgoing patch is never reconciled when an earlier patch is dropped, so the server keeps a value the user intended to discard.
The root cause is that retainPendingServerPatch snapshots serverSettingsRef.current as the base, and a subsequent patch is computed from settings that include the to-be-dropped overlay. When a patch fails and is dropped, any later patch already computed from that overlaid base carries the failed edit forward. Consider recomputing the base by stripping failed-but-not-yet-dropped overlays when building each outgoing patch, or blocking/rebasing subsequent writes until prior ones settle.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/hooks/useSettings.ts around line 294:
A failed server settings update can be silently committed by a later update that overlaps it in flight. For example, toggle provider A then immediately toggle provider B: B's `providerInstances` map is computed from the optimistically overlaid settings (which include A's pending edit) and dispatched to the server. If A's RPC fails, `dropPendingServerPatch` removes A's local overlay, but B's already-sent patch still carries A's edit and persists it on the server. The overlaid base used to compute each outgoing patch is never reconciled when an earlier patch is dropped, so the server keeps a value the user intended to discard.
The root cause is that `retainPendingServerPatch` snapshots `serverSettingsRef.current` as the base, and a subsequent patch is computed from settings that include the to-be-dropped overlay. When a patch fails and is dropped, any later patch already computed from that overlaid base carries the failed edit forward. Consider recomputing the base by stripping failed-but-not-yet-dropped overlays when building each outgoing patch, or blocking/rebasing subsequent writes until prior ones settle.
0e81a81 to
6574213
Compare
|
|
||
| // Every authoritative value that arrives is one `settingsUpdated` broadcast, | ||
| // which is what retires the writes the overlay is still covering. | ||
| useEffect(() => { |
There was a problem hiding this comment.
🟡 Medium hooks/useSettings.ts:237
useMergedSettings treats every new serverSettings reference as a write echo. When an update is dispatched while the environment has no config (so the hook is holding DEFAULT_SERVER_SETTINGS), the initial config load is counted as that update's echo. Once the RPC settles, the overlay is removed before the real settingsUpdated broadcast arrives, briefly reverting the UI and allowing another edit to be computed from stale settings. Consider distinguishing a genuine write echo from the first config load so the overlay is not retired early.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/hooks/useSettings.ts around line 237:
`useMergedSettings` treats every new `serverSettings` reference as a write echo. When an update is dispatched while the environment has no config (so the hook is holding `DEFAULT_SERVER_SETTINGS`), the initial config load is counted as that update's echo. Once the RPC settles, the overlay is removed before the real `settingsUpdated` broadcast arrives, briefly reverting the UI and allowing another edit to be computed from stale settings. Consider distinguishing a genuine write echo from the first config load so the overlay is not retired early.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6574213. Configure here.
6574213 to
4df7b36
Compare
Server settings only change locally once the server echoes `settingsUpdated` back over the websocket, and `useUpdateSettingsTarget` applied nothing locally in the meantime. Any edit issued inside that round trip was therefore computed from pre-edit settings, and because keys such as `providerInstances` are whole-value replacements rather than deep merges, the second write silently reverted the first: toggle one provider off, toggle a second off a moment later, and the first provider's switch snaps back on and never reaches settings.json. Retain in-flight patches per environment and replay them over the server value with the same `applyServerSettingsPatch` the server runs, so reads and follow-up patches build on the edit the user just made. Patches are dropped once no write is outstanding — including after a failed write, so the server value stays authoritative. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4df7b36 to
b27a313
Compare

What Changed
useUpdateSettingsTargetnow retains each in-flight server settings patch and replays it over the server value (via the sameapplyServerSettingsPatchthe server runs) until no write is outstanding for that environment.apps/web/src/hooks/pendingServerSettings.tsholds the per-environment overlay: retain on dispatch, release when the write settles, drop the overlay only when the last outstanding write settles.useMergedSettingsapplies the overlay before merging client settings, sousePrimarySettings/useEnvironmentSettingsreflect the edit immediately.pendingServerSettings.test.ts.Why
Server settings only change locally once the server echoes
settingsUpdatedback over the websocket. Until then, readers still see pre-edit settings, so a second edit issued inside that window is computed from stale state — and because keys such asproviderInstancesare whole-map replacements rather than deep merges (applyServerSettingsPatch), the second write silently reverts the first.Reproduction before this change, in Settings → Providers: toggle Codex off, then toggle Claude off a moment later. Codex's switch snaps back on and its
enabled: falsenever reachessettings.json— only the Claude entry is persisted. The same race applies to every provider-instance edit (binary path, custom models, accent color, add/delete), plusfavoritesandproviderModelPreferences, since those patches are also built from the settings snapshot.The doc comment on
useUpdateSettingsTargetalready claimed server keys were patched optimistically; this makes that true.UI Changes
No visual changes. Behavioral only: a provider toggle now flips immediately and stays flipped instead of reverting when another edit follows quickly.
Verified in a local build (server + web from this branch, driven through the browser): toggling two providers in immediate succession leaves both switches off and persists both entries to
settings.json; an off/on/other three-click sequence composes correctly. Onmainthe same first sequence loses the first toggle.Checklist
Validation:
vp test run src/hooks/pendingServerSettings.test.ts(6 passed),vp test run src/hooks src/components/settings src/providerInstances.test.ts(86 passed),vp run typecheckinapps/web,vp lint apps/web/src/hooks/,vp fmt --check.Note
Medium Risk
Touches settings read/write paths and persistence timing; behavior is scoped to the web client overlay but incorrect release timing could briefly show stale or server-authoritative values after failures.
Overview
Fixes a race where rapid server settings edits could undo each other because the UI still read pre-echo websocket state while patches like
providerInstancesreplace the whole map.Adds
pendingServerSettings: each dispatched server patch is retained, replayed withapplyServerSettingsPatchfor reads, and released when the RPC settles; the overlay clears only when no writes are in flight for that environment.useMergedSettingsapplies this overlay (viauseSyncExternalStore) before merging client settings, anduseUpdateSettingsTargetretains on dispatch and releases infinallyinstead of relying on atom-only optimism.Unit tests cover stacked provider toggles, refcounted release, per-environment scoping, and subscriber notifications.
Reviewed by Cursor Bugbot for commit b27a313. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Prevent rapid server settings edits from reverting each other with an optimistic patch overlay
useMergedSettingsso the UI reflects the change before the server responds.usePendingServerPatchesbridges the patch state into React viauseSyncExternalStoreso components re-render when pending patches change.Macroscope summarized b27a313.