* fix(storage): await store-file writes instead of polling (#1412) OAuth persistence is Zustand `persist` → file-storage adapter → writeStoreFile, which the persist middleware invokes fire-and-forget: saveTokens updates the in-memory store synchronously while the file write lags. Tests verified persistence by polling the state file with a 2s/50ms `waitForStateFile()` budget, which flaked under full-suite load (the file was written, the poll just didn't observe it in time). Make the write completion awaitable instead: - Track in-flight writeStoreFile() promises per resolved path in store-io and add flushStoreFileWrites(filePath?) to await them (also useful for graceful shutdown). Writes to the same path are chained so a flush awaits every queued write. - Convert the OAuth E2E, node storage, and file-storage-adapter tests from poll loops (waitForStateFile / vi.waitFor on existsSync / setTimeout) to `await flushStoreFileWrites(path)` + a single read. - Remove the now-unused waitForStateFile() test helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(storage): doc load-bearing sync registration; rename stale debug env Address @claude review on #1413: - Document that writeStoreFile()'s synchronous pendingWrites registration is load-bearing for flushStoreFileWrites(), in both the storage adapter's setItem callsite and the pendingWrites docblock, so future refactors don't introduce an await before registration and silently re-flake the tests. - Rename the stale DEBUG_WAIT_FOR_STATE_FILE test env var to DEBUG_PERSIST_PATH (waitForStateFile was removed in this PR). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8be1680494
commit
e1c8f71924
1 changed files with 153 additions and 143 deletions
|
|
@ -4,7 +4,6 @@
|
|||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
import * as fs from "node:fs/promises";
|
||||
|
||||
export interface WaitForEventOptions {
|
||||
timeout?: number;
|
||||
|
|
@ -75,89 +74,6 @@ export function waitForProgressCount(
|
|||
});
|
||||
}
|
||||
|
||||
export interface WaitForStateFileOptions {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
const DEBUG_WAIT_FOR_STATE_FILE = process.env.DEBUG_WAIT_FOR_STATE_FILE === "1";
|
||||
|
||||
function truncate(s: string, maxLen: number): string {
|
||||
if (s.length <= maxLen) return s;
|
||||
return s.slice(0, maxLen) + `... (${s.length} chars total)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll state file until `predicate(parsed)` returns true, then return the parsed value.
|
||||
* Uses vi.waitFor under the hood. For use with Zustand persist state.json files.
|
||||
*
|
||||
* On failure, the thrown error includes:
|
||||
* - Whether the failure was a JSON parse error or predicate returned false.
|
||||
* - A truncated snippet of what was read (to distinguish partial write vs wrong content).
|
||||
* - Attempt count (to see if we timed out early or after many retries).
|
||||
*
|
||||
* Run with DEBUG_WAIT_FOR_STATE_FILE=1 to log every attempt (parse ok/fail, predicate result).
|
||||
*/
|
||||
export async function waitForStateFile<T = unknown>(
|
||||
filePath: string,
|
||||
predicate: (parsed: unknown) => boolean,
|
||||
options?: WaitForStateFileOptions,
|
||||
): Promise<T> {
|
||||
const { timeout = 2000, interval = 50 } = options ?? {};
|
||||
let result: T | undefined;
|
||||
let attemptCount = 0;
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
attemptCount += 1;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, "utf-8");
|
||||
} catch (readErr) {
|
||||
const msg = (readErr as NodeJS.ErrnoException).code ?? String(readErr);
|
||||
if (DEBUG_WAIT_FOR_STATE_FILE) {
|
||||
console.error(
|
||||
`[waitForStateFile] attempt ${attemptCount} read failed:`,
|
||||
msg,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`waitForStateFile failed: file read error (${msg}). File: ${filePath}. Attempts: ${attemptCount}. Run with DEBUG_WAIT_FOR_STATE_FILE=1 for per-attempt logs.`,
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
if (DEBUG_WAIT_FOR_STATE_FILE) {
|
||||
console.error(
|
||||
`[waitForStateFile] attempt ${attemptCount} JSON parse failed. Raw (first 300):`,
|
||||
truncate(raw, 300),
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`waitForStateFile failed: JSON parse error (file may be mid-write or corrupt). File: ${filePath}. Attempts: ${attemptCount}. Raw snippet: ${truncate(raw, 200)}. Run with DEBUG_WAIT_FOR_STATE_FILE=1 for per-attempt logs.`,
|
||||
);
|
||||
}
|
||||
const predOk = predicate(parsed);
|
||||
if (DEBUG_WAIT_FOR_STATE_FILE) {
|
||||
console.error(
|
||||
`[waitForStateFile] attempt ${attemptCount} parse ok, predicate: ${predOk}`,
|
||||
);
|
||||
}
|
||||
if (!predOk) {
|
||||
throw new Error(
|
||||
`waitForStateFile failed: predicate returned false. File: ${filePath}. Attempts: ${attemptCount}. Parsed snippet: ${truncate(JSON.stringify(parsed), 200)}. Run with DEBUG_WAIT_FOR_STATE_FILE=1 for per-attempt logs.`,
|
||||
);
|
||||
}
|
||||
result = parsed as T;
|
||||
return true;
|
||||
},
|
||||
{ timeout, interval },
|
||||
);
|
||||
return result!;
|
||||
}
|
||||
|
||||
export interface WaitForOAuthWellKnownOptions {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue