Files
openclaw/extensions/memory-wiki/src/vault-page-write.test.ts
Wynne668 f3d92936b5 fix(memory-wiki): retry transient source-page rewrite race (#94443)
A concurrent atomic rewrite (write-temp + rename) of a memory-wiki source
page by the bridge re-export made fs-safe's opened-fd identity check fail
with `path-mismatch`, which the page write rethrew as a fatal "Refusing to
write" error and aborted the whole wiki_status / source-sync call. The race
is transient and benign: the file is replaced under the open handle and the
concurrent writer lands equivalent content.

Retry briefly on `path-mismatch` (the rename window closes sub-ms) and
rethrow unchanged on exhaustion, so persistent failures (directory
collision, not-file) and symlink/path-alias swaps still hard-fail exactly
as before. The identity guard is untouched; only the benign rename race is
retried, matching the sibling read path that already treats path-mismatch
as transient.

Extracts the guarded-write logic duplicated by source-page-shared.ts and
okf.ts into one writeGuardedVaultPage helper so both write paths get the
fix and the copy is removed.

Closes #92134

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:22:15 +00:00

95 lines
3.0 KiB
TypeScript

// Memory Wiki tests cover the shared guarded vault page write helper.
import { FsSafeError } from "openclaw/plugin-sdk/security-runtime";
import { describe, expect, it, vi } from "vitest";
import { writeGuardedVaultPage } from "./vault-page-write.js";
type FakeVault = Parameters<typeof writeGuardedVaultPage>[0]["vault"];
function fakeVault(write: () => Promise<void>): {
vault: FakeVault;
remove: ReturnType<typeof vi.fn>;
} {
const remove = vi.fn(async () => {});
return { vault: { write: vi.fn(write), remove } as unknown as FakeVault, remove };
}
describe("writeGuardedVaultPage", () => {
it("recovers from a transient concurrent-rewrite path-mismatch by retrying", async () => {
let attempts = 0;
const { vault } = fakeVault(async () => {
attempts += 1;
if (attempts < 3) {
// A concurrent atomic rewrite replaces the page mid-operation.
throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
}
});
await expect(
writeGuardedVaultPage({
vault,
pagePath: "sources/page.md",
content: "body",
pageStat: null,
pageLabel: "imported source page",
}),
).resolves.toBeUndefined();
expect(attempts).toBe(3);
});
it("rethrows a labeled error when the path-mismatch persists across attempts", async () => {
const { vault } = fakeVault(async () => {
throw new FsSafeError("path-mismatch", "unable to resolve opened file path");
});
await expect(
writeGuardedVaultPage({
vault,
pagePath: "sources/page.md",
content: "body",
pageStat: null,
pageLabel: "imported source page",
}),
).rejects.toThrow(
/Refusing to write imported source page \(path-mismatch\): sources\/page\.md/u,
);
});
it("does not retry persistent non-race guard failures and keeps fatal wording", async () => {
const { vault } = fakeVault(async () => {
throw new FsSafeError("not-file", "target is not a regular file");
});
await expect(
writeGuardedVaultPage({
vault,
pagePath: "concepts/page.md",
content: "body",
pageStat: null,
pageLabel: "OKF concept page",
}),
).rejects.toThrow(/Refusing to write OKF concept page \(not-file\): concepts\/page\.md/u);
expect((vault as unknown as { write: ReturnType<typeof vi.fn> }).write).toHaveBeenCalledTimes(
1,
);
});
it("maps symlink and path-alias swaps to the symlink wording without retrying", async () => {
const { vault } = fakeVault(async () => {
throw new FsSafeError("symlink", "page resolved through a symlink");
});
await expect(
writeGuardedVaultPage({
vault,
pagePath: "sources/page.md",
content: "body",
pageStat: null,
pageLabel: "imported source page",
}),
).rejects.toThrow(/Refusing to write imported source page through symlink: sources\/page\.md/u);
expect((vault as unknown as { write: ReturnType<typeof vi.fn> }).write).toHaveBeenCalledTimes(
1,
);
});
});