fix(storage): await store-file writes instead of polling (#1412) (#1413)

* 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:
Cliff Hall 2026-06-02 11:55:24 -04:00 • committed by GitHub
commit e1c8f71924
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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;