fix(presence-api): tolerate corrupt cookie JSON instead of crashing the process

An interrupted atomic write can leave a player file truncated (0 bytes).
JSON.parse of such a file threw SyntaxError, which was unhandled and
crashed the whole presence process (cursors/Yjs/cookies all share it),
putting the canary into a crash loop.

Add tryReadJsonFile() that treats ENOENT and invalid JSON the same way
(as absent) and route readPlayerRecord, getLeaderboard and
sumAllBalances through it. A corrupt file is rebuilt on next access.

Add a regression test covering a truncated player file.
This commit is contained in:
2026-08-06 02:23:34 +02:00
parent 16eb0647ce
commit d07dc35c03
2 changed files with 96 additions and 70 deletions
+18 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import test from "node:test";
@@ -130,4 +130,21 @@ test("sums personal balances for global total", async () => {
assert.equal(await store.getGlobalBalance(), 17);
});
test("tolerates a truncated player file instead of crashing", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-cookies-"));
const store = new BotsuCookieStore({ root, now: () => 1_725_000_000_000 });
// Simulate an interrupted atomic write: an empty player file.
await mkdir(join(root, "players"), { recursive: true });
await writeFile(join(root, "players", "_chris_botsu.net.json"), "");
// Balance, leaderboard and global sum must not throw and treat it as absent.
const balance = await store.getPersonalBalance("@chris:botsu.net", "Chris");
assert.equal(balance, 0);
assert.equal(await store.getGlobalBalance(), 0);
// The truncated file is treated as absent and rebuilt with a zero balance,
// so the leaderboard contains Chris rather than crashing.
assert.deepEqual(await store.getLeaderboard(), [
{ userId: "@chris:botsu.net", displayName: "Chris", balance: 0 },
]);
});
export {};
+78 -69
View File
@@ -64,6 +64,23 @@ const writeJsonAtomic = async (path: string, value: unknown): Promise<void> => {
await rename(temporaryPath, path);
};
const tryReadJsonFile = async (path: string): Promise<unknown> => {
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
try {
return JSON.parse(raw);
} catch {
// A truncated/corrupt JSON file (e.g. an interrupted atomic write) must not
// crash the whole presence process: treat it as absent so it is rebuilt.
return undefined;
}
};
const sanitizeUserIdForPath = (userId: string): string =>
userId.replace(/[^a-zA-Z0-9._=/-]/g, "_");
@@ -104,72 +121,66 @@ export class BotsuCookieStore {
}
private async readPlayerRecord(userId: string): Promise<CookiePlayerRecord | undefined> {
try {
const raw = await readFile(this.playerPath(userId), "utf8");
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
const record = parsed as Record<string, unknown>;
if (
record.version !== COOKIES_PLAYER_VERSION ||
typeof record.userId !== "string" ||
record.userId !== userId ||
typeof record.displayName !== "string" ||
typeof record.balance !== "number" ||
!Number.isInteger(record.balance) ||
record.balance < 0 ||
typeof record.createdAt !== "number" ||
!Number.isInteger(record.createdAt)
) {
return undefined;
}
const player: CookiePlayerRecord = {
version: COOKIES_PLAYER_VERSION,
userId: record.userId,
displayName: record.displayName,
balance: record.balance,
lifetimeEarned:
typeof record.lifetimeEarned === "number" &&
Number.isInteger(record.lifetimeEarned) &&
record.lifetimeEarned >= record.balance
? record.lifetimeEarned
: record.balance,
lifetimeSpent:
typeof record.lifetimeSpent === "number" &&
Number.isInteger(record.lifetimeSpent) &&
record.lifetimeSpent >= 0
? record.lifetimeSpent
: 0,
cookiesPerClick:
typeof record.cookiesPerClick === "number" &&
Number.isInteger(record.cookiesPerClick) &&
record.cookiesPerClick >= 1
? record.cookiesPerClick
: 1,
cookiesPerSecond:
typeof record.cookiesPerSecond === "number" &&
Number.isInteger(record.cookiesPerSecond) &&
record.cookiesPerSecond >= 0
? record.cookiesPerSecond
: 0,
createdAt: record.createdAt,
};
if (
typeof record.lastClickedAt === "number" &&
Number.isInteger(record.lastClickedAt)
) {
player.lastClickedAt = record.lastClickedAt;
}
if (
typeof record.lastPassiveAt === "number" &&
Number.isInteger(record.lastPassiveAt)
) {
player.lastPassiveAt = record.lastPassiveAt;
}
return player;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
const parsed = await tryReadJsonFile(this.playerPath(userId));
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
const record = parsed as Record<string, unknown>;
if (
record.version !== COOKIES_PLAYER_VERSION ||
typeof record.userId !== "string" ||
record.userId !== userId ||
typeof record.displayName !== "string" ||
typeof record.balance !== "number" ||
!Number.isInteger(record.balance) ||
record.balance < 0 ||
typeof record.createdAt !== "number" ||
!Number.isInteger(record.createdAt)
) {
return undefined;
}
const player: CookiePlayerRecord = {
version: COOKIES_PLAYER_VERSION,
userId: record.userId,
displayName: record.displayName,
balance: record.balance,
lifetimeEarned:
typeof record.lifetimeEarned === "number" &&
Number.isInteger(record.lifetimeEarned) &&
record.lifetimeEarned >= record.balance
? record.lifetimeEarned
: record.balance,
lifetimeSpent:
typeof record.lifetimeSpent === "number" &&
Number.isInteger(record.lifetimeSpent) &&
record.lifetimeSpent >= 0
? record.lifetimeSpent
: 0,
cookiesPerClick:
typeof record.cookiesPerClick === "number" &&
Number.isInteger(record.cookiesPerClick) &&
record.cookiesPerClick >= 1
? record.cookiesPerClick
: 1,
cookiesPerSecond:
typeof record.cookiesPerSecond === "number" &&
Number.isInteger(record.cookiesPerSecond) &&
record.cookiesPerSecond >= 0
? record.cookiesPerSecond
: 0,
createdAt: record.createdAt,
};
if (
typeof record.lastClickedAt === "number" &&
Number.isInteger(record.lastClickedAt)
) {
player.lastClickedAt = record.lastClickedAt;
}
if (
typeof record.lastPassiveAt === "number" &&
Number.isInteger(record.lastPassiveAt)
) {
player.lastPassiveAt = record.lastPassiveAt;
}
return player;
}
private async writePlayerRecord(record: CookiePlayerRecord): Promise<void> {
@@ -268,8 +279,7 @@ export class BotsuCookieStore {
}
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const raw = await readFile(join(playersDir, entry), "utf8");
const parsed = JSON.parse(raw) as unknown;
const parsed = await tryReadJsonFile(join(playersDir, entry));
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
const record = parsed as Record<string, unknown>;
if (
@@ -390,8 +400,7 @@ export class BotsuCookieStore {
}
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const raw = await readFile(join(playersDir, entry), "utf8");
const parsed = JSON.parse(raw) as unknown;
const parsed = await tryReadJsonFile(join(playersDir, entry));
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
const record = parsed as Record<string, unknown>;
if (typeof record.balance === "number" && Number.isInteger(record.balance) && record.balance >= 0) {