Skip to content

Commit f75c574

Browse files
authored
fix(settings): guard custom-instructions sync against out-of-order reads
Two reads in flight at once (rapid toggle off/on/off/on) could resolve out of order, letting the stale read land last and win while sync is on. A late read resolving after toggle-off could likewise deposit a latent snapshot. Capture a generation before the await and discard any read a newer reconcile has superseded. The read is a host round-trip, so on slower transports than local IPC the window is real. Covers the contribution with tests: happy path, clear-on-disable, and both race orderings (verified the race tests fail without the guard). Generated-By: PostHog Code Task-Id: b6359217-b9b8-40bb-a748-308fa450a732
1 parent 2e65480 commit f75c574

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import type { HostTrpcClient } from "@posthog/host-router/client";
2+
import { registerRendererStateStorage } from "@posthog/ui/shell/rendererStorage";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { CustomInstructionsSyncContribution } from "./customInstructionsSync.contribution";
5+
import { useSettingsStore } from "./settingsStore";
6+
7+
registerRendererStateStorage({
8+
getItem: vi.fn().mockResolvedValue(null),
9+
setItem: vi.fn().mockResolvedValue(undefined),
10+
removeItem: vi.fn().mockResolvedValue(undefined),
11+
});
12+
13+
const query = vi.fn();
14+
const client = {
15+
os: { getUserAgentInstructions: { query } },
16+
} as unknown as HostTrpcClient;
17+
18+
const staleFile = {
19+
path: "/home/u/.claude/CLAUDE.md",
20+
displayPath: "~/.claude/CLAUDE.md",
21+
content: "stale",
22+
truncated: false,
23+
};
24+
const freshFile = { ...staleFile, content: "fresh" };
25+
26+
/** Runs queued microtasks so a resolved read's continuation lands. */
27+
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
28+
29+
const setSyncEnabled = (enabled: boolean) =>
30+
useSettingsStore.getState().setSyncCustomInstructionsFromFile(enabled);
31+
32+
describe("CustomInstructionsSyncContribution", () => {
33+
// The contribution subscribes to the module-level store and never
34+
// unsubscribes, so one shared instance serves every test.
35+
let started = false;
36+
37+
beforeEach(() => {
38+
useSettingsStore.setState({
39+
_hasHydrated: true,
40+
syncCustomInstructionsFromFile: false,
41+
syncedCustomInstructions: null,
42+
});
43+
query.mockReset();
44+
if (!started) {
45+
new CustomInstructionsSyncContribution(client).start();
46+
started = true;
47+
}
48+
});
49+
50+
it("mirrors the file into the store when sync turns on", async () => {
51+
query.mockResolvedValueOnce(freshFile);
52+
53+
setSyncEnabled(true);
54+
await flush();
55+
56+
expect(useSettingsStore.getState().syncedCustomInstructions).toEqual(
57+
freshFile,
58+
);
59+
});
60+
61+
it("clears the snapshot when sync turns off", async () => {
62+
query.mockResolvedValueOnce(freshFile);
63+
setSyncEnabled(true);
64+
await flush();
65+
66+
setSyncEnabled(false);
67+
68+
expect(useSettingsStore.getState().syncedCustomInstructions).toBeNull();
69+
});
70+
71+
it("discards a read that resolves after sync was toggled off", async () => {
72+
let resolveRead: (file: typeof staleFile) => void = () => {};
73+
query.mockReturnValueOnce(
74+
new Promise((resolve) => {
75+
resolveRead = resolve;
76+
}),
77+
);
78+
79+
setSyncEnabled(true);
80+
setSyncEnabled(false);
81+
resolveRead(staleFile);
82+
await flush();
83+
84+
expect(useSettingsStore.getState().syncedCustomInstructions).toBeNull();
85+
});
86+
87+
it("keeps the newest read when re-enable reads resolve out of order", async () => {
88+
let resolveFirst: (file: typeof staleFile) => void = () => {};
89+
let resolveSecond: (file: typeof freshFile) => void = () => {};
90+
query
91+
.mockReturnValueOnce(
92+
new Promise((resolve) => {
93+
resolveFirst = resolve;
94+
}),
95+
)
96+
.mockReturnValueOnce(
97+
new Promise((resolve) => {
98+
resolveSecond = resolve;
99+
}),
100+
);
101+
102+
setSyncEnabled(true);
103+
setSyncEnabled(false);
104+
setSyncEnabled(true);
105+
resolveSecond(freshFile);
106+
await flush();
107+
resolveFirst(staleFile);
108+
await flush();
109+
110+
expect(useSettingsStore.getState().syncedCustomInstructions).toEqual(
111+
freshFile,
112+
);
113+
});
114+
});

packages/ui/src/features/settings/customInstructionsSync.contribution.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ const log = logger.scope("custom-instructions-sync");
1717
*/
1818
@injectable()
1919
export class CustomInstructionsSyncContribution implements Contribution {
20+
/**
21+
* Bumped by every reconcile so an in-flight read that a newer toggle flip
22+
* has superseded cannot land its (now stale) snapshot in the store. The
23+
* read is a host round-trip, so on slower transports rapid toggling can
24+
* genuinely have two reads in flight resolving out of order.
25+
*/
26+
private generation = 0;
27+
2028
constructor(
2129
@inject(HOST_TRPC_CLIENT)
2230
private readonly hostClient: HostTrpcClient,
@@ -39,6 +47,7 @@ export class CustomInstructionsSyncContribution implements Contribution {
3947
}
4048

4149
private async reconcile(enabled: boolean): Promise<void> {
50+
const generation = ++this.generation;
4251
if (!enabled) {
4352
useSettingsStore.getState().setSyncedCustomInstructions(null);
4453
return;
@@ -50,6 +59,9 @@ export class CustomInstructionsSyncContribution implements Contribution {
5059
useSettingsStore.getState().setSyncedCustomInstructions(null);
5160
try {
5261
const file = await this.hostClient.os.getUserAgentInstructions.query();
62+
// A newer toggle flip started its own reconcile while this read was in
63+
// flight; its outcome owns the store now.
64+
if (generation !== this.generation) return;
5365
useSettingsStore.getState().setSyncedCustomInstructions(file);
5466
} catch (err) {
5567
// The snapshot was already cleared above, so a transient read failure

0 commit comments

Comments
 (0)