Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/recover-unreadable-session-lock.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 20 additions & 2 deletions src/payment/session-lock.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -28,10 +28,15 @@ export async function withSessionLock<T>(url: string, fn: () => Promise<T>): 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;
Expand All @@ -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, "_");
Expand Down
97 changes: 97 additions & 0 deletions test/session-lock.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});