Skip to content

Commit fde908f

Browse files
committed
fix(auth): recover stale credential file locks via PID liveness
1 parent 83c2aed commit fde908f

2 files changed

Lines changed: 240 additions & 3 deletions

File tree

‎src/auth/store.test.ts‎

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2+
import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { type } from "arktype";
@@ -328,6 +328,142 @@ describe("createAuthStore", () => {
328328
}
329329
});
330330

331+
test("takes over a dead-holder lock instead of timing out", async () => {
332+
const home = await mkdtemp(join(tmpdir(), "oauth-store-takeover-"));
333+
try {
334+
const store = createAuthStore<TestTokens>({
335+
filename: "test-auth.json",
336+
settingsDirName: TEST_SETTINGS_DIR,
337+
isTokens: isTestTokens,
338+
});
339+
const lockPath = `${store.authPath(home)}.lock`;
340+
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
341+
// The maximum pid_t can never be a live holder: kill(pid, 0) answers
342+
// ESRCH (or EINVAL), both of which read as dead.
343+
await writeFile(lockPath, `${2_147_483_647}`, { mode: 0o600 });
344+
345+
const profile = {
346+
name: "work",
347+
tokens: { access: "a", refresh: "r", expiresAt: 1 },
348+
createdAt: 1,
349+
};
350+
await expect(store.saveProfile(profile, home)).resolves.toBeUndefined();
351+
expect(await store.loadProfile("work", home)).toEqual(profile);
352+
await expect(readFile(lockPath, "utf8")).rejects.toThrow("ENOENT");
353+
} finally {
354+
await rm(home, { recursive: true, force: true });
355+
}
356+
});
357+
358+
test("waits on a live-holder lock and times out without touching it", async () => {
359+
const home = await mkdtemp(join(tmpdir(), "oauth-store-live-"));
360+
try {
361+
const store = createAuthStore<TestTokens>({
362+
filename: "test-auth.json",
363+
settingsDirName: TEST_SETTINGS_DIR,
364+
isTokens: isTestTokens,
365+
lockTimeoutMs: 100,
366+
});
367+
const lockPath = `${store.authPath(home)}.lock`;
368+
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
369+
await writeFile(lockPath, `${process.pid}`, { mode: 0o600 });
370+
371+
await expect(
372+
store.saveProfile(
373+
{
374+
name: "work",
375+
tokens: { access: "a", refresh: "r", expiresAt: 1 },
376+
createdAt: 1,
377+
},
378+
home,
379+
),
380+
).rejects.toThrow(
381+
`Timed out waiting for OAuth credential lock ${lockPath}. ` +
382+
"If no Corbits process is running, remove this lock file manually and retry.",
383+
);
384+
expect(await readFile(lockPath, "utf8")).toBe(`${process.pid}`);
385+
} finally {
386+
await rm(home, { recursive: true, force: true });
387+
}
388+
});
389+
390+
test("takes over a stale legacy lock but waits on a fresh one", async () => {
391+
const home = await mkdtemp(join(tmpdir(), "oauth-store-legacy-"));
392+
try {
393+
const staleStore = createAuthStore<TestTokens>({
394+
filename: "stale-auth.json",
395+
settingsDirName: TEST_SETTINGS_DIR,
396+
isTokens: isTestTokens,
397+
});
398+
const staleLockPath = `${staleStore.authPath(home)}.lock`;
399+
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
400+
await writeFile(staleLockPath, "legacy-orphan", { mode: 0o600 });
401+
await utimes(
402+
staleLockPath,
403+
new Date(),
404+
new Date(Date.now() - 60_000),
405+
);
406+
407+
const profile = {
408+
name: "work",
409+
tokens: { access: "a", refresh: "r", expiresAt: 1 },
410+
createdAt: 1,
411+
};
412+
await expect(
413+
staleStore.saveProfile(profile, home),
414+
).resolves.toBeUndefined();
415+
expect(await staleStore.loadProfile("work", home)).toEqual(profile);
416+
417+
const freshStore = createAuthStore<TestTokens>({
418+
filename: "fresh-auth.json",
419+
settingsDirName: TEST_SETTINGS_DIR,
420+
isTokens: isTestTokens,
421+
lockTimeoutMs: 100,
422+
});
423+
const freshLockPath = `${freshStore.authPath(home)}.lock`;
424+
await writeFile(freshLockPath, "legacy-orphan", { mode: 0o600 });
425+
await expect(
426+
freshStore.saveProfile(profile, home),
427+
).rejects.toThrow("Timed out waiting for OAuth credential lock");
428+
expect(await readFile(freshLockPath, "utf8")).toBe("legacy-orphan");
429+
} finally {
430+
await rm(home, { recursive: true, force: true });
431+
}
432+
});
433+
434+
test("saves when a contended lock vanishes mid-wait", async () => {
435+
const home = await mkdtemp(join(tmpdir(), "oauth-store-vanish-"));
436+
try {
437+
const store = createAuthStore<TestTokens>({
438+
filename: "test-auth.json",
439+
settingsDirName: TEST_SETTINGS_DIR,
440+
isTokens: isTestTokens,
441+
});
442+
const lockPath = `${store.authPath(home)}.lock`;
443+
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
444+
await writeFile(lockPath, `${2_147_483_647}`, { mode: 0o600 });
445+
446+
// Yank the stale lock out from under the waiter: whether the waiter
447+
// observes the dead PID, an ENOENT read, or an ENOENT unlink, it must
448+
// retry the exclusive create and land the save — never throw ENOENT.
449+
const pending = store.saveProfile(
450+
{
451+
name: "work",
452+
tokens: { access: "a", refresh: "r", expiresAt: 1 },
453+
createdAt: 1,
454+
},
455+
home,
456+
);
457+
await rm(lockPath, { force: true });
458+
await expect(pending).resolves.toBeUndefined();
459+
expect((await store.loadProfile("work", home))?.tokens.access).toBe(
460+
"a",
461+
);
462+
} finally {
463+
await rm(home, { recursive: true, force: true });
464+
}
465+
});
466+
331467
test("fails closed with manual recovery guidance when an orphan lock exists", async () => {
332468
const home = await mkdtemp(join(tmpdir(), "oauth-store-orphan-"));
333469
try {

‎src/auth/store.ts‎

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
open,
44
readFile,
55
rename,
6+
stat,
67
unlink,
78
writeFile,
89
} from "node:fs/promises";
@@ -60,6 +61,12 @@ interface AuthFile<TTokens extends BaseTokens> {
6061
const LOCK_RETRY_MS = 25;
6162
const LOCK_TIMEOUT_MS = 1_000;
6263

64+
// Age at which a lock file carrying no holder PID (a foreign writer, or a
65+
// lock predating PID tagging) is presumed orphaned by a crashed holder and
66+
// taken over. Stays far above LOCK_TIMEOUT_MS so a waiter never declares a
67+
// live holder stale mid-wait; PID-tagged locks ignore this horizon.
68+
const LOCK_STALE_MS = 5_000;
69+
6370
// Per-call unique temp (pid + counter). Matches mcp/auth-store — pid alone is not
6471
// unique per call if writeAuthFile ever overlaps in-process.
6572
let tmpWriteCounter = 0;
@@ -97,6 +104,57 @@ function isProfile<TTokens extends BaseTokens>(
97104
return isTokens(parsed.tokens);
98105
}
99106

107+
function holderPid(content: string): number | null {
108+
const pid = Number(content.split(":")[0]?.trim());
109+
return Number.isInteger(pid) && pid > 0 ? pid : null;
110+
}
111+
112+
// kill(pid, 0) liveness: success means the process exists (alive); EPERM
113+
// means it exists but belongs to another user (alive); ESRCH/EINVAL mean no
114+
// such process (dead). Any other failure reads as alive — never steal a live
115+
// holder's lock on a confused signal check; the waiter times out with a
116+
// recovery hint instead.
117+
function isPidAlive(pid: number): boolean {
118+
try {
119+
process.kill(pid, 0);
120+
return true;
121+
} catch (err) {
122+
const code = (err as NodeJS.ErrnoException)?.code;
123+
return code !== "ESRCH" && code !== "EINVAL";
124+
}
125+
}
126+
127+
// Null when the lock vanished under the waiter (a release raced the read);
128+
// anything but ENOENT propagates — permission and disk errors must surface,
129+
// not read as an empty lock.
130+
async function readLockContent(lockPath: string): Promise<string | null> {
131+
try {
132+
return await readFile(lockPath, "utf8");
133+
} catch (err) {
134+
if (isErrnoCode(err, "ENOENT")) return null;
135+
throw err;
136+
}
137+
}
138+
139+
// True when the observed lock is safe to take over: a tagged holder whose
140+
// PID is dead (crashed — reachable under any timeout), or a legacy untagged
141+
// lock older than the stale horizon. A vanished lock reads as not stale;
142+
// the acquire loop retries the exclusive create instead.
143+
async function isLockStale(
144+
lockPath: string,
145+
content: string,
146+
): Promise<boolean> {
147+
const pid = holderPid(content);
148+
if (pid !== null) return !isPidAlive(pid);
149+
try {
150+
const info = await stat(lockPath);
151+
return Date.now() - info.mtimeMs > LOCK_STALE_MS;
152+
} catch (err) {
153+
if (isErrnoCode(err, "ENOENT")) return false;
154+
throw err;
155+
}
156+
}
157+
100158
export function createAuthStore<TTokens extends BaseTokens>(
101159
options: AuthStoreOptions<TTokens>,
102160
): AuthStore<TTokens> {
@@ -153,10 +211,44 @@ export function createAuthStore<TTokens extends BaseTokens>(
153211

154212
while (true) {
155213
try {
156-
lock = await open(lockPath, "wx", 0o600);
214+
const handle = await open(lockPath, "wx", 0o600);
215+
try {
216+
await handle.writeFile(`${process.pid}`, "utf8");
217+
} catch (writeError) {
218+
try {
219+
await handle.close();
220+
} catch {
221+
// Ignore close errors on the cleanup path; the write error below
222+
// is the one the caller must see.
223+
}
224+
try {
225+
await unlink(lockPath);
226+
} catch (unlinkError) {
227+
if (!isErrnoCode(unlinkError, "ENOENT")) throw unlinkError;
228+
}
229+
throw writeError;
230+
}
231+
lock = handle;
157232
break;
158233
} catch (error) {
159234
if (!isErrnoCode(error, "EEXIST")) throw error;
235+
// A crashed holder never releases: take over a stale lock rather
236+
// than brick the store. A lock that vanished under the read
237+
// (a release raced us) is not stale — retry the exclusive create.
238+
const content = await readLockContent(lockPath);
239+
if (content !== null && (await isLockStale(lockPath, content))) {
240+
// Re-check before unlinking so a concurrent takeover winner's
241+
// fresh lock is never mistaken for the stale entry just observed.
242+
if ((await readLockContent(lockPath)) === content) {
243+
try {
244+
await unlink(lockPath);
245+
} catch (unlinkError) {
246+
// A concurrent winner unlinked first; retry the create.
247+
if (!isErrnoCode(unlinkError, "ENOENT")) throw unlinkError;
248+
}
249+
}
250+
continue;
251+
}
160252
if (Date.now() >= deadline) {
161253
throw new Error(
162254
`Timed out waiting for OAuth credential lock ${lockPath}. ` +
@@ -173,8 +265,17 @@ export function createAuthStore<TTokens extends BaseTokens>(
173265
} finally {
174266
try {
175267
await lock.close();
268+
} catch {
269+
// The callback's result (or error) owns this return path; a close
270+
// failure must not mask it. The unlink below still runs.
176271
} finally {
177-
await unlink(lockPath);
272+
// Swallow ENOENT only: a stale-takeover steal legitimately removes
273+
// the file first, but permission and disk errors must surface.
274+
try {
275+
await unlink(lockPath);
276+
} catch (error) {
277+
if (!isErrnoCode(error, "ENOENT")) throw error;
278+
}
178279
}
179280
}
180281
}

0 commit comments

Comments
 (0)