Skip to content

Commit 323871e

Browse files
committed
Drain pending saves without overlapping writes
1 parent 51886eb commit 323871e

3 files changed

Lines changed: 216 additions & 10 deletions

File tree

index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
16811681
accountManagerPromise = null;
16821682
};
16831683

1684+
// Plugin init can run more than once per process; runCleanup drains this module-level list.
16841685
registerCleanup(async () => {
16851686
await cachedAccountManager?.flushPendingSave();
16861687
});

lib/accounts.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,16 +1009,38 @@ export class AccountManager {
10091009
}
10101010

10111011
async flushPendingSave(): Promise<void> {
1012-
const hadDebouncedSave = !!this.saveDebounceTimer;
1013-
if (this.saveDebounceTimer) {
1014-
clearTimeout(this.saveDebounceTimer);
1015-
this.saveDebounceTimer = null;
1016-
}
1017-
if (this.pendingSave) {
1018-
await this.pendingSave;
1019-
}
1020-
if (hadDebouncedSave) {
1021-
await this.saveToDisk();
1012+
while (true) {
1013+
const hadDebouncedSave = !!this.saveDebounceTimer;
1014+
if (this.saveDebounceTimer) {
1015+
clearTimeout(this.saveDebounceTimer);
1016+
this.saveDebounceTimer = null;
1017+
}
1018+
if (this.pendingSave) {
1019+
await this.pendingSave;
1020+
// Let debounced callbacks waiting on the completed save re-arm pendingSave.
1021+
await Promise.resolve();
1022+
if (this.saveDebounceTimer !== null || this.pendingSave !== null) {
1023+
continue;
1024+
}
1025+
}
1026+
if (!hadDebouncedSave) {
1027+
return;
1028+
}
1029+
if (this.saveDebounceTimer !== null || this.pendingSave !== null) {
1030+
continue;
1031+
}
1032+
const flushSave = this.saveToDisk().finally(() => {
1033+
if (this.pendingSave === flushSave) {
1034+
this.pendingSave = null;
1035+
}
1036+
});
1037+
this.pendingSave = flushSave;
1038+
await flushSave;
1039+
// Drain saves that were queued while the flush save was in flight.
1040+
await Promise.resolve();
1041+
if (this.saveDebounceTimer === null && this.pendingSave === null) {
1042+
return;
1043+
}
10221044
}
10231045
}
10241046
}

test/accounts.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,12 @@ describe("AccountManager", () => {
18541854
});
18551855

18561856
describe("flushPendingSave", () => {
1857+
const drainMicrotasks = async (turns = 10): Promise<void> => {
1858+
for (let turn = 0; turn < turns; turn++) {
1859+
await Promise.resolve();
1860+
}
1861+
};
1862+
18571863
it("flushes pending debounced save", async () => {
18581864
const { saveAccounts } = await import("../lib/storage.js");
18591865
const mockSaveAccounts = vi.mocked(saveAccounts);
@@ -1998,6 +2004,183 @@ describe("AccountManager", () => {
19982004
vi.useRealTimers();
19992005
}
20002006
});
2007+
2008+
it("drains saves queued during flush without overlapping writes", async () => {
2009+
vi.useFakeTimers();
2010+
try {
2011+
const { saveAccounts } = await import("../lib/storage.js");
2012+
const mockSaveAccounts = vi.mocked(saveAccounts);
2013+
mockSaveAccounts.mockClear();
2014+
2015+
let activeWrites = 0;
2016+
let maxConcurrentWrites = 0;
2017+
const settleWrites: Array<() => void> = [];
2018+
const writePromises: Promise<void>[] = [];
2019+
2020+
mockSaveAccounts.mockImplementation(() => {
2021+
activeWrites++;
2022+
maxConcurrentWrites = Math.max(maxConcurrentWrites, activeWrites);
2023+
const writePromise = new Promise<void>((resolve) => {
2024+
settleWrites.push(() => {
2025+
activeWrites--;
2026+
resolve();
2027+
});
2028+
});
2029+
writePromises.push(writePromise);
2030+
return writePromise;
2031+
});
2032+
2033+
const now = Date.now();
2034+
const stored = {
2035+
version: 3 as const,
2036+
activeIndex: 0,
2037+
accounts: [
2038+
{ refreshToken: "token-1", addedAt: now, lastUsed: now },
2039+
],
2040+
};
2041+
2042+
const manager = new AccountManager(undefined, stored);
2043+
2044+
manager.saveToDiskDebounced(50);
2045+
await vi.advanceTimersByTimeAsync(60);
2046+
expect(mockSaveAccounts).toHaveBeenCalledTimes(1);
2047+
2048+
const armGapSave = writePromises[0]!.then(() => {
2049+
manager.saveToDiskDebounced(0);
2050+
});
2051+
2052+
manager.saveToDiskDebounced(50);
2053+
const flushPromise = manager.flushPendingSave();
2054+
2055+
settleWrites[0]!();
2056+
await armGapSave;
2057+
await drainMicrotasks();
2058+
2059+
expect(mockSaveAccounts).toHaveBeenCalledTimes(2);
2060+
2061+
manager.saveToDiskDebounced(0);
2062+
await vi.advanceTimersByTimeAsync(0);
2063+
expect(mockSaveAccounts).toHaveBeenCalledTimes(2);
2064+
2065+
settleWrites[1]!();
2066+
await drainMicrotasks();
2067+
2068+
expect(mockSaveAccounts).toHaveBeenCalledTimes(3);
2069+
2070+
settleWrites[2]!();
2071+
await flushPromise;
2072+
2073+
expect(maxConcurrentWrites).toBe(1);
2074+
} finally {
2075+
vi.useRealTimers();
2076+
}
2077+
});
2078+
2079+
it("persists disabled auth-failure state after flushing over an in-flight save", async () => {
2080+
vi.useFakeTimers();
2081+
try {
2082+
const { saveAccounts } = await import("../lib/storage.js");
2083+
const mockSaveAccounts = vi.mocked(saveAccounts);
2084+
mockSaveAccounts.mockClear();
2085+
2086+
let activeWrites = 0;
2087+
let maxConcurrentWrites = 0;
2088+
const settleWrites: Array<() => void> = [];
2089+
const savedSnapshots: Array<
2090+
Array<{
2091+
refreshToken?: string;
2092+
enabled?: false;
2093+
disabledReason?: string;
2094+
coolingDownUntil?: number;
2095+
cooldownReason?: string;
2096+
}>
2097+
> = [];
2098+
2099+
mockSaveAccounts.mockImplementation(async (storage) => {
2100+
activeWrites++;
2101+
maxConcurrentWrites = Math.max(maxConcurrentWrites, activeWrites);
2102+
savedSnapshots.push(
2103+
storage.accounts.map((account) => ({
2104+
refreshToken: account.refreshToken,
2105+
enabled: account.enabled,
2106+
disabledReason: account.disabledReason,
2107+
coolingDownUntil: account.coolingDownUntil,
2108+
cooldownReason: account.cooldownReason,
2109+
})),
2110+
);
2111+
await new Promise<void>((resolve) => {
2112+
settleWrites.push(() => {
2113+
activeWrites--;
2114+
resolve();
2115+
});
2116+
});
2117+
});
2118+
2119+
const now = Date.now();
2120+
const stored = {
2121+
version: 3 as const,
2122+
activeIndex: 0,
2123+
accounts: [
2124+
{
2125+
refreshToken: "shared-refresh",
2126+
addedAt: now,
2127+
lastUsed: now,
2128+
coolingDownUntil: now + 10_000,
2129+
cooldownReason: "auth-failure" as const,
2130+
},
2131+
{
2132+
refreshToken: "shared-refresh",
2133+
addedAt: now + 1,
2134+
lastUsed: now + 1,
2135+
coolingDownUntil: now + 20_000,
2136+
cooldownReason: "auth-failure" as const,
2137+
},
2138+
],
2139+
};
2140+
2141+
const manager = new AccountManager(undefined, stored);
2142+
2143+
manager.saveToDiskDebounced(50);
2144+
await vi.advanceTimersByTimeAsync(60);
2145+
expect(mockSaveAccounts).toHaveBeenCalledTimes(1);
2146+
2147+
const account = manager.getAccountsSnapshot()[0]!;
2148+
manager.disableAccountsWithSameRefreshToken(account);
2149+
manager.saveToDiskDebounced(50);
2150+
2151+
const flushPromise = manager.flushPendingSave();
2152+
2153+
settleWrites[0]!();
2154+
await drainMicrotasks();
2155+
2156+
expect(mockSaveAccounts).toHaveBeenCalledTimes(2);
2157+
2158+
const flushedSharedAccounts = savedSnapshots[1]!.filter(
2159+
(savedAccount) => savedAccount.refreshToken === "shared-refresh",
2160+
);
2161+
expect(flushedSharedAccounts).toEqual([
2162+
expect.objectContaining({
2163+
enabled: false,
2164+
disabledReason: "auth-failure",
2165+
coolingDownUntil: undefined,
2166+
cooldownReason: undefined,
2167+
}),
2168+
expect.objectContaining({
2169+
enabled: false,
2170+
disabledReason: "auth-failure",
2171+
coolingDownUntil: undefined,
2172+
cooldownReason: undefined,
2173+
}),
2174+
]);
2175+
2176+
settleWrites[1]!();
2177+
await flushPromise;
2178+
2179+
expect(maxConcurrentWrites).toBe(1);
2180+
} finally {
2181+
vi.useRealTimers();
2182+
}
2183+
});
20012184
});
20022185

20032186
describe("health and token tracking methods", () => {

0 commit comments

Comments
 (0)