From 6cb5616c68c95c1b0501ce68990f992a9cf1346a Mon Sep 17 00:00:00 2001 From: rayasa07 <264205419+rayasa07@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:18:01 +0100 Subject: [PATCH] fix(wallet): recover a session lock left without a pid withSessionLock creates the lock with open(path, "wx") and records the pid as a separate step, so a holder that died in between left an empty file. Stale-lock recovery skipped it because there was no pid to check, and every later run on that origin waited out the 30s deadline and failed with no way back. Release a lock carrying no usable pid once it is older than a short grace period, so one that is only momentarily empty still belongs to its live holder. --- .changelog/recover-unreadable-session-lock.md | 5 + src/payment/session-lock.ts | 22 ++++- test/session-lock.test.ts | 97 +++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 .changelog/recover-unreadable-session-lock.md create mode 100644 test/session-lock.test.ts diff --git a/.changelog/recover-unreadable-session-lock.md b/.changelog/recover-unreadable-session-lock.md new file mode 100644 index 0000000..a485b4b --- /dev/null +++ b/.changelog/recover-unreadable-session-lock.md @@ -0,0 +1,5 @@ +--- +wallet-cli: patch +--- + +Fixed `withSessionLock` to release a lock file that carries no usable pid. The lock is created by `open(path, "wx")` and its pid written as a separate step, so a holder that died in between left an empty file that stale-lock recovery skipped, and every later run on that origin waited out the 30 second deadline and failed. Such a lock is now released once it is older than a short grace period, which still leaves a lock that is only momentarily empty to its live holder. diff --git a/src/payment/session-lock.ts b/src/payment/session-lock.ts index 18853dd..f69eb1c 100644 --- a/src/payment/session-lock.ts +++ b/src/payment/session-lock.ts @@ -1,4 +1,4 @@ -import { open, readFile, unlink, mkdir } from "node:fs/promises"; +import { open, readFile, stat, unlink, mkdir } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -28,10 +28,15 @@ export async function withSessionLock(url: string, fn: () => Promise): Pro } } +// The lock file is created by open(path, "wx") and only written afterwards, so +// a freshly created one is legitimately empty for a moment. Give the writer +// this long to record its pid before treating the lock as abandoned. +const unreadablePidGraceMs = 5_000; + async function removeStaleLock(path: string) { const text = await readFile(path, "utf8").catch(() => ""); const pid = Number(text.split("\n")[0]); - if (!Number.isInteger(pid) || pid <= 0) return false; + if (!Number.isInteger(pid) || pid <= 0) return await removeAbandonedLock(path); try { process.kill(pid, 0); return false; @@ -43,6 +48,19 @@ async function removeStaleLock(path: string) { } } +// A lock carrying no usable pid cannot be checked against a running process. +// Once it is older than the grace period no writer is still recording one, so +// the holder died between creating the file and writing to it, or the contents +// were truncated. Release it rather than waiting out the deadline on every +// later run. +async function removeAbandonedLock(path: string) { + const stats = await stat(path).catch(() => undefined); + if (!stats) return false; + if (Date.now() - stats.mtimeMs < unreadablePidGraceMs) return false; + await unlink(path).catch(() => undefined); + return true; +} + function lockPath(url: string) { const origin = new URL(url).origin; const key = origin.replace(/[^A-Za-z0-9.-]/g, "_"); diff --git a/test/session-lock.test.ts b/test/session-lock.test.ts new file mode 100644 index 0000000..733981d --- /dev/null +++ b/test/session-lock.test.ts @@ -0,0 +1,97 @@ +import { mkdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { withSessionLock } from "../src/payment/session-lock.js"; +import { useTempHome } from "./helpers.js"; + +const url = "https://lock.example.com/resource"; + +function lockPath() { + const key = new URL(url).origin.replace(/[^A-Za-z0-9.-]/g, "_"); + return join(homedir(), ".tempo", "wallet", "session-locks", `${key}.lock`); +} + +async function seedLock(contents: string, ageMs = 0) { + const path = lockPath(); + await mkdir(join(homedir(), ".tempo", "wallet", "session-locks"), { recursive: true }); + await writeFile(path, contents); + if (ageMs > 0) { + const when = new Date(Date.now() - ageMs); + await utimes(path, when, when); + } + return path; +} + +describe("withSessionLock", () => { + it("releases a lock left empty by a holder that died before writing its pid", async () => { + // open(path, "wx") creates the lock and writeFile records the pid as a + // separate step; a holder that dies in between leaves an empty lock with + // no pid to check, which used to make every later run wait out the + // 30s deadline and fail. + await useTempHome(); + await seedLock("", 60_000); + + await expect(withSessionLock(url, async () => "ran")).resolves.toBe("ran"); + }); + + it("releases a lock whose pid line is not a usable pid", async () => { + await useTempHome(); + await seedLock("not-a-pid\n2026-09-20T00:00:00.000Z\n", 60_000); + + await expect(withSessionLock(url, async () => "ran")).resolves.toBe("ran"); + }); + + it("leaves a just-created empty lock alone while its holder is still writing", async () => { + // The same empty lock is legitimate for a moment, so it must not be + // stolen on sight: that would let two holders run at once. + await useTempHome(); + const path = await seedLock(""); + + let settled = false; + const pending = withSessionLock(url, async () => "ran").finally(() => { + settled = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(settled).toBe(false); + await expect(stat(path)).resolves.toBeDefined(); + + await rm(path, { force: true }); + await expect(pending).resolves.toBe("ran"); + }); + + it("releases a lock held by a process that is gone", async () => { + await useTempHome(); + await seedLock("999999999\n2026-09-20T00:00:00.000Z\n", 60_000); + + await expect(withSessionLock(url, async () => "ran")).resolves.toBe("ran"); + }); + + it("respects a lock held by a live process", async () => { + await useTempHome(); + const path = await seedLock(`${process.pid}\n${new Date().toISOString()}\n`, 60_000); + + let settled = false; + const pending = withSessionLock(url, async () => "ran").finally(() => { + settled = true; + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(settled).toBe(false); + await expect(readFile(path, "utf8")).resolves.toContain(`${process.pid}`); + + await rm(path, { force: true }); + await expect(pending).resolves.toBe("ran"); + }); + + it("removes the lock after the callback finishes", async () => { + await useTempHome(); + const path = lockPath(); + + await expect(withSessionLock(url, async () => "ran")).resolves.toBe("ran"); + await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" }); + }); +});