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
2 changed files with 153 additions and 143 deletions

View file

@ -20,6 +20,11 @@ export function createFileStorageAdapter(
): ReturnType<typeof createJSONStorage> {
return createJSONStorage(() => ({
getItem: async () => readStoreFile(options.filePath),
// Do not introduce an `await` before writeStoreFile() here: it registers
// the write in pendingWrites synchronously, which is load-bearing for
// flushStoreFileWrites() (a microtask hop before registration would make a
// flush called right after setItem find an empty map and return early,
// silently regressing tests to non-deterministic).
setItem: async (_name: string, value: string) =>
writeStoreFile(options.filePath, value),
removeItem: async () => deleteStoreFile(options.filePath),

View file

@ -57,20 +57,66 @@ export async function readStoreFile(filePath: string): Promise<string | null> {
}
}
/**
* In-flight writeStoreFile() promises, keyed by resolved path. Lets callers
* await persistence completion via flushStoreFileWrites() instead of polling
* the file — Zustand's persist middleware invokes writeStoreFile() fire-and-
* forget, so the in-memory store updates synchronously while the file write
* lags. Entries are removed once their write settles.
*
* Load-bearing: pendingWrites.set() below runs synchronously before the first
* await in writeStoreFile(), so a flushStoreFileWrites() called right after a
* persist sees the in-flight entry. Callers (e.g. the storage adapter's
* setItem) must not introduce an await before writeStoreFile() — doing so would
* let a flush run before registration and return early.
*/
const pendingWrites = new Map<string, Promise<void>>();
/**
* Write store file atomically (temp file + rename). Ensures parent directory exists.
* Uses mode 0o600 for the file.
*
* The returned promise is also tracked per path so flushStoreFileWrites() can
* await it. Writes to the same path are chained so a flush awaits every queued
* write (and a later write's completion implies all earlier ones settled).
*/
export async function writeStoreFile(
filePath: string,
data: string,
): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await writeFile(filePath, data, {
encoding: "utf-8",
mode: 0o600,
});
const key = path.resolve(filePath);
const run = async (): Promise<void> => {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await writeFile(filePath, data, {
encoding: "utf-8",
mode: 0o600,
});
};
const prior = pendingWrites.get(key);
const tracked = (prior ?? Promise.resolve()).catch(() => {}).then(run);
pendingWrites.set(key, tracked);
try {
await tracked;
} finally {
if (pendingWrites.get(key) === tracked) {
pendingWrites.delete(key);
}
}
}
/**
* Await pending writeStoreFile() writes — those for `filePath` if given, else
* all of them. Use in tests after triggering persistence (Zustand persist
* writes fire-and-forget) instead of polling the file, and for graceful
* shutdown. Resolves immediately when nothing is in flight.
*/
export async function flushStoreFileWrites(filePath?: string): Promise<void> {
if (filePath !== undefined) {
await pendingWrites.get(path.resolve(filePath));
return;
}
await Promise.all(pendingWrites.values());
}
/**