Skip to content

Commit fec9318

Browse files
committed
fix: make account clear reset atomic
1 parent 0056c42 commit fec9318

2 files changed

Lines changed: 44 additions & 23 deletions

File tree

lib/storage/account-clear.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,43 @@ export async function clearAccountStorageArtifacts(params: {
77
backupPaths: string[];
88
logError: (message: string, details: Record<string, unknown>) => void;
99
}): Promise<void> {
10-
await fs.writeFile(
11-
params.resetMarkerPath,
12-
JSON.stringify({ version: 1, createdAt: Date.now() }),
13-
{ encoding: "utf-8", mode: 0o600 },
14-
);
15-
const clearPath = async (targetPath: string): Promise<void> => {
10+
const clearPath = async (
11+
targetPath: string,
12+
required: boolean,
13+
): Promise<void> => {
1614
try {
1715
await fs.unlink(targetPath);
1816
} catch (error) {
1917
const code = (error as NodeJS.ErrnoException).code;
20-
if (code !== "ENOENT") {
18+
if (code === "ENOENT") {
19+
return;
20+
}
21+
if (required) {
2122
params.logError("Failed to clear account storage artifact", {
2223
path: targetPath,
2324
error: String(error),
2425
});
26+
throw error;
2527
}
28+
params.logError("Failed to clear account storage artifact", {
29+
path: targetPath,
30+
error: String(error),
31+
});
2632
}
2733
};
2834

29-
try {
30-
await Promise.all([
31-
clearPath(params.path),
32-
clearPath(params.walPath),
33-
...params.backupPaths.map(clearPath),
34-
]);
35-
} catch {
36-
// Individual path cleanup is already best-effort with per-artifact logging.
35+
await clearPath(params.path, true);
36+
await clearPath(params.walPath, true);
37+
await fs.writeFile(
38+
params.resetMarkerPath,
39+
JSON.stringify({ version: 1, createdAt: Date.now() }),
40+
{ encoding: "utf-8", mode: 0o600 },
41+
);
42+
for (const backupPath of params.backupPaths) {
43+
try {
44+
await clearPath(backupPath, false);
45+
} catch {
46+
// Non-critical artifacts are already logged best-effort.
47+
}
3748
}
3849
}

test/storage.test.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2961,14 +2961,24 @@ describe("storage", () => {
29612961
await fs.rm(testWorkDir, { recursive: true, force: true });
29622962
});
29632963

2964-
it("logs but does not throw on non-ENOENT errors", async () => {
2965-
const readOnlyDir = join(testWorkDir, "readonly");
2966-
await fs.mkdir(readOnlyDir, { recursive: true });
2967-
const readOnlyFile = join(readOnlyDir, "accounts.json");
2968-
await fs.writeFile(readOnlyFile, "{}");
2969-
setStoragePathDirect(readOnlyFile);
2964+
it("throws and does not write reset marker when the primary storage file cannot be removed", async () => {
2965+
await fs.writeFile(testStoragePath, "{}", "utf-8");
2966+
const resetMarkerPath = getIntentionalResetMarkerPath(testStoragePath);
2967+
const unlinkSpy = vi
2968+
.spyOn(fs, "unlink")
2969+
.mockImplementation(async (targetPath) => {
2970+
if (String(targetPath) === testStoragePath) {
2971+
const error = Object.assign(new Error("locked"), { code: "EPERM" });
2972+
throw error;
2973+
}
2974+
return Promise.resolve();
2975+
});
29702976

2971-
await expect(clearAccounts()).resolves.not.toThrow();
2977+
await expect(clearAccounts()).rejects.toMatchObject({ code: "EPERM" });
2978+
expect(existsSync(testStoragePath)).toBe(true);
2979+
expect(existsSync(resetMarkerPath)).toBe(false);
2980+
2981+
unlinkSpy.mockRestore();
29722982
});
29732983
});
29742984

@@ -4122,7 +4132,7 @@ describe("storage", () => {
41224132
Object.assign(new Error("EACCES error"), { code: "EACCES" }),
41234133
);
41244134

4125-
await clearAccounts();
4135+
await expect(clearAccounts()).rejects.toMatchObject({ code: "EACCES" });
41264136

41274137
expect(unlinkSpy).toHaveBeenCalled();
41284138
unlinkSpy.mockRestore();

0 commit comments

Comments
 (0)