* 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
|
|
@ -13,6 +13,10 @@ import { createFileStorageAdapter } from "@inspector/core/storage/adapters/file-
|
|||
import { createRemoteStorageAdapter } from "@inspector/core/storage/adapters/remote-storage.js";
|
||||
import { createOAuthStore } from "@inspector/core/auth/store.js";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
import {
|
||||
writeStoreFile,
|
||||
flushStoreFileWrites,
|
||||
} from "@inspector/core/storage/store-io.js";
|
||||
|
||||
interface StartRemoteServerOptions {
|
||||
storageDir?: string;
|
||||
|
|
@ -147,13 +151,8 @@ describe("Storage adapters", () => {
|
|||
tokens: { access_token: "test-token", token_type: "Bearer" },
|
||||
});
|
||||
|
||||
// Wait for persistence (Zustand persist is async; poll for file so we don't race with cleanup)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
},
|
||||
{ timeout: 2000, interval: 20 },
|
||||
);
|
||||
// Persistence is fire-and-forget; await the write rather than polling.
|
||||
await flushStoreFileWrites(filePath);
|
||||
const fileContent = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(fileContent);
|
||||
expect(parsed.state.servers["https://example.com"].tokens).toEqual({
|
||||
|
|
@ -172,12 +171,7 @@ describe("Storage adapters", () => {
|
|||
store1.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "initial-token", token_type: "Bearer" },
|
||||
});
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
},
|
||||
{ timeout: 2000, interval: 20 },
|
||||
);
|
||||
await flushStoreFileWrites(filePath);
|
||||
|
||||
// Create new store instance (should load persisted state)
|
||||
const storage2 = createFileStorageAdapter({ filePath });
|
||||
|
|
@ -201,7 +195,7 @@ describe("Storage adapters", () => {
|
|||
store.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "test-token", token_type: "Bearer" },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await flushStoreFileWrites(filePath);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
|
||||
// Clear all servers (this will persist empty state)
|
||||
|
|
@ -210,7 +204,7 @@ describe("Storage adapters", () => {
|
|||
for (const url of urls) {
|
||||
state.clearServerState(url);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await flushStoreFileWrites(filePath);
|
||||
|
||||
// Verify file still exists but with empty servers
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
|
|
@ -229,15 +223,75 @@ describe("Storage adapters", () => {
|
|||
store.getState().setServerState("https://example.com", {
|
||||
tokens: { access_token: "t", token_type: "Bearer" },
|
||||
});
|
||||
await vi.waitFor(() => expect(existsSync(filePath)).toBe(true), {
|
||||
timeout: 2000,
|
||||
interval: 20,
|
||||
});
|
||||
await flushStoreFileWrites(filePath);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
await storage!.removeItem("inspector-oauth-store");
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flushStoreFileWrites", () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves immediately when nothing is in flight", async () => {
|
||||
await expect(flushStoreFileWrites()).resolves.toBeUndefined();
|
||||
await expect(
|
||||
flushStoreFileWrites("/no/such/path.json"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("awaits the pending write for a specific path", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-flush-test-"));
|
||||
const filePath = join(tempDir, "store.json");
|
||||
|
||||
// Kick off the write without awaiting it, then flush.
|
||||
const write = writeStoreFile(filePath, '{"hello":"world"}');
|
||||
await flushStoreFileWrites(filePath);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(filePath, "utf-8"))).toEqual({
|
||||
hello: "world",
|
||||
});
|
||||
await write;
|
||||
});
|
||||
|
||||
it("awaits all pending writes when no path is given", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-flush-test-"));
|
||||
const a = join(tempDir, "a.json");
|
||||
const b = join(tempDir, "b.json");
|
||||
|
||||
const writes = Promise.all([
|
||||
writeStoreFile(a, '{"n":1}'),
|
||||
writeStoreFile(b, '{"n":2}'),
|
||||
]);
|
||||
await flushStoreFileWrites();
|
||||
expect(existsSync(a)).toBe(true);
|
||||
expect(existsSync(b)).toBe(true);
|
||||
await writes;
|
||||
});
|
||||
|
||||
it("serializes overlapping writes to the same path (last write wins)", async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-flush-test-"));
|
||||
const filePath = join(tempDir, "store.json");
|
||||
|
||||
const first = writeStoreFile(filePath, '{"v":1}');
|
||||
const second = writeStoreFile(filePath, '{"v":2}');
|
||||
await Promise.all([first, second]);
|
||||
await flushStoreFileWrites(filePath);
|
||||
expect(JSON.parse(readFileSync(filePath, "utf-8"))).toEqual({ v: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("RemoteStorageAdapter", () => {
|
||||
let remoteServer: ServerType | null = null;
|
||||
let tempDir: string | null = null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue