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:
@@ -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 {};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user