Skip to content

Commit 76e809f

Browse files
committed
fix: recreate live account sync on config changes
Live account sync reused the same controller once created, even when its effective debounce/poll configuration changed. That leaves stale runtime state in place and keeps watching with outdated timing parameters. Track a live-sync config key and recreate the controller when the configuration changes, matching the refresh-guardian pattern.
1 parent 81c45e5 commit 76e809f

7 files changed

Lines changed: 151 additions & 10 deletions

File tree

index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
309309
let startupPrewarmTriggered = false;
310310
let lastCodexCliActiveSyncIndex: number | null = null;
311311
let perProjectStorageWarningShown = false;
312-
let liveAccountSync: LiveAccountSync | null = null;
313-
let liveAccountSyncPath: string | null = null;
312+
let liveAccountSync: LiveAccountSync | null = null;
313+
let liveAccountSyncPath: string | null = null;
314+
let liveAccountSyncConfigKey: string | null = null;
314315
let refreshGuardian: RefreshGuardian | null = null;
315316
let refreshGuardianConfigKey: string | null = null;
316317
let refreshGuardianCleanupRegistered = false;
@@ -482,7 +483,10 @@ let sessionAffinityWriteVersion = 0;
482483
authFallback,
483484
currentSync: liveAccountSync,
484485
currentPath: liveAccountSyncPath,
486+
currentConfigKey: liveAccountSyncConfigKey,
485487
getLiveAccountSync,
488+
getLiveAccountSyncDebounceMs,
489+
getLiveAccountSyncPollMs,
486490
getStoragePath,
487491
createSync: (oauthFallback) =>
488492
new LiveAccountSync(
@@ -503,6 +507,7 @@ let sessionAffinityWriteVersion = 0;
503507
});
504508
liveAccountSync = next.liveAccountSync;
505509
liveAccountSyncPath = next.liveAccountSyncPath;
510+
liveAccountSyncConfigKey = next.liveAccountSyncConfigKey;
506511
};
507512
const ensureRefreshGuardian = (
508513
pluginConfig: ReturnType<typeof loadPluginConfig>,

lib/runtime/live-sync-entry.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ export async function ensureLiveAccountSyncEntry<
1212
authFallback?: OAuthAuthDetails;
1313
currentSync: TSync | null;
1414
currentPath: string | null;
15+
currentConfigKey?: string | null;
1516
getLiveAccountSync: (
1617
config: ReturnType<typeof import("../config.js").loadPluginConfig>,
1718
) => boolean;
1819
getStoragePath: () => string;
20+
getLiveAccountSyncDebounceMs: (
21+
config: ReturnType<typeof import("../config.js").loadPluginConfig>,
22+
) => number;
23+
getLiveAccountSyncPollMs: (
24+
config: ReturnType<typeof import("../config.js").loadPluginConfig>,
25+
) => number;
1926
createSync: (authFallback?: OAuthAuthDetails) => TSync;
2027
registerCleanup: (cleanup: () => void) => void;
2128
logWarn: (message: string) => void;
@@ -25,6 +32,8 @@ export async function ensureLiveAccountSyncEntry<
2532
targetPath: string;
2633
currentSync: TSync | null;
2734
currentPath: string | null;
35+
currentConfigKey?: string | null;
36+
configKey?: string | null;
2837
authFallback?: OAuthAuthDetails;
2938
createSync: (authFallback?: OAuthAuthDetails) => TSync;
3039
registerCleanup: (cleanup: () => void) => void;
@@ -33,16 +42,22 @@ export async function ensureLiveAccountSyncEntry<
3342
}) => Promise<{
3443
liveAccountSync: TSync | null;
3544
liveAccountSyncPath: string | null;
45+
liveAccountSyncConfigKey: string | null;
3646
}>;
3747
}): Promise<{
3848
liveAccountSync: TSync | null;
3949
liveAccountSyncPath: string | null;
50+
liveAccountSyncConfigKey: string | null;
4051
}> {
52+
const debounceMs = params.getLiveAccountSyncDebounceMs(params.pluginConfig);
53+
const pollIntervalMs = params.getLiveAccountSyncPollMs(params.pluginConfig);
4154
return params.ensureLiveAccountSyncState({
4255
enabled: params.getLiveAccountSync(params.pluginConfig),
4356
targetPath: params.getStoragePath(),
4457
currentSync: params.currentSync,
4558
currentPath: params.currentPath,
59+
currentConfigKey: params.currentConfigKey,
60+
configKey: `${debounceMs}:${pollIntervalMs}`,
4661
authFallback: params.authFallback,
4762
createSync: params.createSync,
4863
registerCleanup: params.registerCleanup,

lib/runtime/live-sync.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export async function ensureRuntimeLiveAccountSync<
1515
getStoragePath: () => string;
1616
currentSync: TSync | null;
1717
currentPath: string | null;
18+
currentConfigKey?: string | null;
1819
currentCleanupRegistered: boolean;
1920
getCurrentSync: () => TSync | null;
2021
createSync: (
@@ -29,6 +30,7 @@ export async function ensureRuntimeLiveAccountSync<
2930
commitState: (state: {
3031
sync: TSync | null;
3132
path: string | null;
33+
configKey: string | null;
3234
cleanupRegistered: boolean;
3335
}) => void;
3436
registerCleanup: (cleanup: () => void) => void;
@@ -37,18 +39,24 @@ export async function ensureRuntimeLiveAccountSync<
3739
}): Promise<{
3840
sync: TSync | null;
3941
path: string | null;
42+
configKey: string | null;
4043
cleanupRegistered: boolean;
4144
}> {
45+
const debounceMs = deps.getLiveAccountSyncDebounceMs(deps.pluginConfig);
46+
const pollIntervalMs = deps.getLiveAccountSyncPollMs(deps.pluginConfig);
47+
const nextConfigKey = `${debounceMs}:${pollIntervalMs}`;
4248
if (!deps.getLiveAccountSync(deps.pluginConfig)) {
4349
deps.currentSync?.stop();
4450
deps.commitState({
4551
sync: null,
4652
path: null,
53+
configKey: null,
4754
cleanupRegistered: deps.currentCleanupRegistered,
4855
});
4956
return {
5057
sync: null,
5158
path: null,
59+
configKey: null,
5260
cleanupRegistered: deps.currentCleanupRegistered,
5361
};
5462
}
@@ -57,10 +65,18 @@ export async function ensureRuntimeLiveAccountSync<
5765
let sync = deps.currentSync;
5866
let cleanupRegistered = deps.currentCleanupRegistered;
5967
let nextPath = deps.currentPath;
68+
let configKey = deps.currentConfigKey ?? null;
69+
if (sync && configKey !== null && configKey !== nextConfigKey) {
70+
sync.stop();
71+
sync = null;
72+
nextPath = null;
73+
configKey = null;
74+
}
6075
const commitState = (): void => {
6176
deps.commitState({
6277
sync,
6378
path: nextPath,
79+
configKey,
6480
cleanupRegistered,
6581
});
6682
};
@@ -70,10 +86,11 @@ export async function ensureRuntimeLiveAccountSync<
7086
await deps.reloadAccountManagerFromDisk(deps.authFallback);
7187
},
7288
{
73-
debounceMs: deps.getLiveAccountSyncDebounceMs(deps.pluginConfig),
74-
pollIntervalMs: deps.getLiveAccountSyncPollMs(deps.pluginConfig),
89+
debounceMs,
90+
pollIntervalMs,
7591
},
7692
);
93+
configKey = nextConfigKey;
7794
commitState();
7895
if (!cleanupRegistered) {
7996
deps.registerCleanup(() => {
@@ -106,5 +123,5 @@ export async function ensureRuntimeLiveAccountSync<
106123
}
107124
}
108125

109-
return { sync, path: nextPath, cleanupRegistered };
126+
return { sync, path: nextPath, configKey, cleanupRegistered };
110127
}

lib/runtime/runtime-services.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export async function ensureLiveAccountSyncState<
2020
targetPath: string;
2121
currentSync: TSync | null;
2222
currentPath: string | null;
23+
currentConfigKey?: string | null;
24+
configKey?: string | null;
2325
authFallback?: OAuthAuthDetails;
2426
createSync: (authFallback?: OAuthAuthDetails) => TSync;
2527
registerCleanup: (cleanup: () => void) => void;
@@ -28,21 +30,42 @@ export async function ensureLiveAccountSyncState<
2830
}): Promise<{
2931
liveAccountSync: TSync | null;
3032
liveAccountSyncPath: string | null;
33+
liveAccountSyncConfigKey: string | null;
3134
}> {
3235
let liveAccountSync = params.currentSync;
3336
let liveAccountSyncPath = params.currentPath;
37+
let liveAccountSyncConfigKey = params.currentConfigKey ?? null;
3438

3539
if (!params.enabled) {
3640
if (liveAccountSync) {
3741
liveAccountSync.stop();
3842
liveAccountSync = null;
3943
liveAccountSyncPath = null;
44+
liveAccountSyncConfigKey = null;
4045
}
41-
return { liveAccountSync, liveAccountSyncPath };
46+
return {
47+
liveAccountSync,
48+
liveAccountSyncPath,
49+
liveAccountSyncConfigKey,
50+
};
51+
}
52+
53+
const nextConfigKey = params.configKey ?? null;
54+
if (
55+
liveAccountSync &&
56+
nextConfigKey !== null &&
57+
liveAccountSyncConfigKey !== null &&
58+
liveAccountSyncConfigKey !== nextConfigKey
59+
) {
60+
liveAccountSync.stop();
61+
liveAccountSync = null;
62+
liveAccountSyncPath = null;
63+
liveAccountSyncConfigKey = null;
4264
}
4365

4466
if (!liveAccountSync) {
4567
liveAccountSync = params.createSync(params.authFallback);
68+
liveAccountSyncConfigKey = nextConfigKey;
4669
params.registerCleanup(() => {
4770
liveAccountSync?.stop();
4871
});
@@ -71,7 +94,7 @@ export async function ensureLiveAccountSyncState<
7194
}
7295
}
7396

74-
return { liveAccountSync, liveAccountSyncPath };
97+
return { liveAccountSync, liveAccountSyncPath, liveAccountSyncConfigKey };
7598
}
7699

77100
export function ensureRefreshGuardianState<

test/live-sync-entry.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ describe("live sync entry", () => {
1717
} as never,
1818
currentSync: null,
1919
currentPath: null,
20+
currentConfigKey: null,
2021
getLiveAccountSync: () => true,
22+
getLiveAccountSyncDebounceMs: () => 25,
23+
getLiveAccountSyncPollMs: () => 250,
2124
getStoragePath: () => "/tmp/accounts.json",
2225
createSync: vi.fn(() => ({ stop: vi.fn(), syncToPath: vi.fn() })),
2326
registerCleanup: vi.fn(),
@@ -30,6 +33,7 @@ describe("live sync entry", () => {
3033
expect.objectContaining({
3134
enabled: true,
3235
targetPath: "/tmp/accounts.json",
36+
configKey: "25:250",
3337
pluginName: "plugin",
3438
}),
3539
);

test/runtime-live-sync.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe("runtime live sync", () => {
2121
let committedState = {
2222
sync: overrides.currentSync ?? null,
2323
path: overrides.currentPath ?? null,
24+
configKey: null as string | null,
2425
cleanupRegistered: overrides.currentCleanupRegistered ?? false,
2526
};
2627
let cleanupCallback: (() => void) | null = null;
@@ -92,12 +93,14 @@ describe("runtime live sync", () => {
9293
await expect(ensureRuntimeLiveAccountSync(deps)).resolves.toEqual({
9394
sync: null,
9495
path: null,
96+
configKey: null,
9597
cleanupRegistered: true,
9698
});
9799
expect(currentSync.stop).toHaveBeenCalledTimes(1);
98100
expect(deps.commitState).toHaveBeenCalledWith({
99101
sync: null,
100102
path: null,
103+
configKey: null,
101104
cleanupRegistered: true,
102105
});
103106
});
@@ -111,6 +114,7 @@ describe("runtime live sync", () => {
111114
expect(createSync).toHaveBeenCalledTimes(1);
112115
expect(registerCleanup).toHaveBeenCalledTimes(1);
113116
expect(first.path).toBe("C:\\repo\\accounts.json");
117+
expect(first.configKey).toBe("25:250");
114118
expect(first.cleanupRegistered).toBe(true);
115119
expect(first.sync?.syncToPath).toHaveBeenCalledWith(
116120
"C:\\repo\\accounts.json",
@@ -120,6 +124,7 @@ describe("runtime live sync", () => {
120124
...deps,
121125
currentSync: first.sync,
122126
currentPath: first.path,
127+
currentConfigKey: first.configKey,
123128
currentCleanupRegistered: first.cleanupRegistered,
124129
});
125130

@@ -151,6 +156,7 @@ describe("runtime live sync", () => {
151156
await expect(pending).resolves.toMatchObject({
152157
sync: currentSync,
153158
path: "C:\\repo\\new.json",
159+
configKey: null,
154160
cleanupRegistered: true,
155161
});
156162
expect(currentSync.syncToPath).toHaveBeenCalledTimes(3);
@@ -176,6 +182,7 @@ describe("runtime live sync", () => {
176182
await expect(pending).resolves.toMatchObject({
177183
sync: currentSync,
178184
path: "C:\\repo\\old.json",
185+
configKey: null,
179186
cleanupRegistered: true,
180187
});
181188
expect(currentSync.syncToPath).toHaveBeenCalledTimes(3);
@@ -213,6 +220,7 @@ describe("runtime live sync", () => {
213220
const committed = getCommittedState();
214221
expect(committed.sync).toBe(createdSync);
215222
expect(committed.path).toBeNull();
223+
expect(committed.configKey).toBe("25:250");
216224
expect(committed.cleanupRegistered).toBe(true);
217225

218226
const cleanup = getCleanupCallback();
@@ -238,12 +246,14 @@ describe("runtime live sync", () => {
238246

239247
const committed = getCommittedState();
240248
expect(committed.sync).toBe(createdSync);
249+
expect(committed.configKey).toBe("25:250");
241250
expect(committed.cleanupRegistered).toBe(true);
242251

243252
const second = ensureRuntimeLiveAccountSync({
244253
...deps,
245254
currentSync: committed.sync,
246255
currentPath: committed.path,
256+
currentConfigKey: committed.configKey,
247257
currentCleanupRegistered: committed.cleanupRegistered,
248258
});
249259
await vi.runAllTicks();
@@ -253,11 +263,13 @@ describe("runtime live sync", () => {
253263
await expect(pending).resolves.toMatchObject({
254264
sync: createdSync,
255265
path: "C:\\repo\\accounts.json",
266+
configKey: "25:250",
256267
cleanupRegistered: true,
257268
});
258269
await expect(second).resolves.toMatchObject({
259270
sync: createdSync,
260271
path: "C:\\repo\\accounts.json",
272+
configKey: "25:250",
261273
cleanupRegistered: true,
262274
});
263275
});
@@ -273,6 +285,7 @@ describe("runtime live sync", () => {
273285
...deps,
274286
currentSync: first.sync,
275287
currentPath: first.path,
288+
currentConfigKey: first.configKey,
276289
currentCleanupRegistered: first.cleanupRegistered,
277290
getLiveAccountSync: vi.fn().mockReturnValue(false),
278291
});
@@ -282,6 +295,7 @@ describe("runtime live sync", () => {
282295
...deps,
283296
currentSync: disabled.sync,
284297
currentPath: disabled.path,
298+
currentConfigKey: disabled.configKey,
285299
currentCleanupRegistered: disabled.cleanupRegistered,
286300
});
287301
setLiveSync(reenabled.sync);
@@ -293,4 +307,36 @@ describe("runtime live sync", () => {
293307
expect((reenabled.sync as { stop: ReturnType<typeof vi.fn> }).stop).toHaveBeenCalledTimes(1);
294308
expect((first.sync as { stop: ReturnType<typeof vi.fn> }).stop).toHaveBeenCalledTimes(1);
295309
});
310+
311+
it("recreates live sync when debounce/poll settings change", async () => {
312+
const firstSync = {
313+
stop: vi.fn(),
314+
syncToPath: vi.fn().mockResolvedValue(undefined),
315+
};
316+
const secondSync = {
317+
stop: vi.fn(),
318+
syncToPath: vi.fn().mockResolvedValue(undefined),
319+
};
320+
const { deps, createSync } = createDeps({
321+
currentSync: firstSync,
322+
currentPath: "C:\\repo\\accounts.json",
323+
currentCleanupRegistered: true,
324+
});
325+
createSync.mockReturnValue(secondSync);
326+
deps.getLiveAccountSyncDebounceMs = vi.fn().mockReturnValue(50);
327+
deps.getLiveAccountSyncPollMs = vi.fn().mockReturnValue(500);
328+
329+
const result = await ensureRuntimeLiveAccountSync({
330+
...deps,
331+
currentSync: firstSync,
332+
currentPath: "C:\\repo\\accounts.json",
333+
currentConfigKey: "25:250",
334+
});
335+
336+
expect(firstSync.stop).toHaveBeenCalledTimes(1);
337+
expect(createSync).toHaveBeenCalledTimes(1);
338+
expect(result.sync).toBe(secondSync);
339+
expect(result.configKey).toBe("50:500");
340+
expect(secondSync.syncToPath).toHaveBeenCalledWith("C:\\repo\\accounts.json");
341+
});
296342
});

0 commit comments

Comments
 (0)