Skip to content

Commit 9d53234

Browse files
Merge pull request #906 from corbitsdev/cl-7318-createauthstore-has-a-temp-path-collision-and-a-lost-update
Queue same-process credential writes so each gets its own lock window
2 parents 9cfe836 + a38bb46 commit 9d53234

2 files changed

Lines changed: 131 additions & 5 deletions

File tree

‎src/auth/store.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,102 @@ describe("createAuthStore", () => {
114114
}
115115
});
116116

117+
test("keeps a same-process burst of profile saves without shared-deadline loss", async () => {
118+
const home = await mkdtemp(join(tmpdir(), "oauth-store-burst-"));
119+
try {
120+
const store = createAuthStore<TestTokens>({
121+
filename: "test-auth.json",
122+
settingsDirName: TEST_SETTINGS_DIR,
123+
isTokens: isTestTokens,
124+
});
125+
126+
// Without the per-path queue, a large same-process burst shares one lock
127+
// deadline from invoke time and some waiters time out. With the queue,
128+
// each save gets its own window and all land.
129+
const names = Array.from(
130+
{ length: 50 },
131+
(_, index) => `profile-${String(index)}`,
132+
);
133+
const results = await Promise.allSettled(
134+
names.map((name) =>
135+
store.saveProfile(
136+
{
137+
name,
138+
tokens: {
139+
access: `access-${name}`,
140+
refresh: `refresh-${name}`,
141+
expiresAt: 1,
142+
},
143+
createdAt: 1,
144+
},
145+
home,
146+
),
147+
),
148+
);
149+
150+
const failures = results.flatMap((result, index) =>
151+
result.status === "rejected"
152+
? [`${names[index]}: ${String(result.reason)}`]
153+
: [],
154+
);
155+
expect(failures).toEqual([]);
156+
expect(
157+
(await store.listProfiles(home)).map((profile) => profile.name),
158+
).toEqual([...names].sort());
159+
} finally {
160+
await rm(home, { recursive: true, force: true });
161+
}
162+
});
163+
164+
test("gives queued same-process writes their own lock window", async () => {
165+
const home = await mkdtemp(join(tmpdir(), "oauth-store-queue-"));
166+
try {
167+
const store = createAuthStore<TestTokens>({
168+
filename: "test-auth.json",
169+
settingsDirName: TEST_SETTINGS_DIR,
170+
isTokens: isTestTokens,
171+
});
172+
await store.saveProfile(
173+
{
174+
name: "work",
175+
tokens: { access: "a", refresh: "r", expiresAt: 1 },
176+
createdAt: 1,
177+
},
178+
home,
179+
);
180+
181+
// Hold the lock until the head of the same-process queue times out; the
182+
// queued write must still get its own lock window after we release.
183+
// `second` may already be polling when `first` rejects — release must land
184+
// inside LOCK_TIMEOUT_MS of that handoff.
185+
const lockPath = `${store.authPath(home)}.lock`;
186+
await writeFile(lockPath, "foreign", { mode: 0o600 });
187+
188+
const first = store.updateTokens(
189+
"work",
190+
{ access: "first", refresh: "r1", expiresAt: 2 },
191+
home,
192+
);
193+
const second = store.updateTokens(
194+
"work",
195+
{ access: "second", refresh: "r2", expiresAt: 3 },
196+
home,
197+
);
198+
199+
await expect(first).rejects.toThrow(
200+
"Timed out waiting for OAuth credential lock",
201+
);
202+
await rm(lockPath, { force: true });
203+
await expect(second).resolves.toBeUndefined();
204+
205+
expect((await store.loadProfile("work", home))?.tokens.access).toBe(
206+
"second",
207+
);
208+
} finally {
209+
await rm(home, { recursive: true, force: true });
210+
}
211+
});
212+
117213
test("round-trips profiles under an injected home and survives corrupt files", async () => {
118214
const home = await mkdtemp(join(tmpdir(), "oauth-store-"));
119215
try {

‎src/auth/store.ts‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export type { AuthProfile, BaseTokens };
1818
// for the same provider, so credentials are keyed by a user-chosen profile name
1919
// within a single file. Tokens are credentials, so the file is owner-only (0o600)
2020
// and the directory 0o700. Writes go through a temp file + rename so a concurrent
21-
// reader never observes a torn file.
21+
// reader never observes a torn file. Same-process writers also queue per auth path
22+
// so each lock wait starts its own deadline.
2223

2324
export interface AuthStore<TTokens extends BaseTokens> {
2425
authPath: (home?: string) => string;
@@ -48,6 +49,15 @@ interface AuthFile<TTokens extends BaseTokens> {
4849
const LOCK_RETRY_MS = 25;
4950
const LOCK_TIMEOUT_MS = 1_000;
5051

52+
// Per-call unique temp (pid + counter). Matches mcp/auth-store — pid alone is not
53+
// unique per call if writeAuthFile ever overlaps in-process.
54+
let tmpWriteCounter = 0;
55+
56+
// Same-process ops on one auth file queue here so a caller's lock deadline
57+
// starts when it actually runs, not when it was invoked — otherwise one lock
58+
// held past LOCK_TIMEOUT_MS fails the whole burst, not just the first waiter.
59+
const updateChains = new Map<string, Promise<unknown>>();
60+
5161
const AuthFileShape = type({
5262
profiles: "Record<string, unknown>",
5363
});
@@ -115,7 +125,7 @@ export function createAuthStore<TTokens extends BaseTokens>(
115125
): Promise<void> {
116126
const path = authPath(home);
117127
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
118-
const tmp = `${path}.${String(process.pid)}.tmp`;
128+
const tmp = `${path}.${process.pid}.${(tmpWriteCounter += 1)}.tmp`;
119129
await writeFile(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
120130
await rename(tmp, path);
121131
}
@@ -158,6 +168,26 @@ export function createAuthStore<TTokens extends BaseTokens>(
158168
}
159169
}
160170

171+
function enqueueAuthFileOp<TResult>(
172+
home: string,
173+
op: () => Promise<TResult>,
174+
): Promise<TResult> {
175+
const path = authPath(home);
176+
const previous = updateChains.get(path) ?? Promise.resolve();
177+
const run = previous.then(
178+
() => withAuthFileLock(home, op),
179+
() => withAuthFileLock(home, op),
180+
);
181+
updateChains.set(
182+
path,
183+
run.then(
184+
() => undefined,
185+
() => undefined,
186+
),
187+
);
188+
return run;
189+
}
190+
161191
return {
162192
authPath,
163193
async listProfiles(
@@ -179,7 +209,7 @@ export function createAuthStore<TTokens extends BaseTokens>(
179209
profile: AuthProfile<TTokens>,
180210
home: string = homedir(),
181211
): Promise<void> {
182-
await withAuthFileLock(home, async () => {
212+
await enqueueAuthFileOp(home, async () => {
183213
const file = await readAuthFile(home);
184214
file.profiles[profile.name] = profile;
185215
await writeAuthFile(file, home);
@@ -192,7 +222,7 @@ export function createAuthStore<TTokens extends BaseTokens>(
192222
tokens: TTokens,
193223
home: string = homedir(),
194224
): Promise<void> {
195-
await withAuthFileLock(home, async () => {
225+
await enqueueAuthFileOp(home, async () => {
196226
const file = await readAuthFile(home);
197227
const existing = file.profiles[name];
198228
if (existing === undefined) return;
@@ -204,7 +234,7 @@ export function createAuthStore<TTokens extends BaseTokens>(
204234
name: string | undefined,
205235
home: string = homedir(),
206236
): Promise<string[]> {
207-
return withAuthFileLock(home, async () => {
237+
return enqueueAuthFileOp(home, async () => {
208238
const file = await readAuthFile(home);
209239
if (name === undefined) {
210240
const removed = Object.keys(file.profiles);

0 commit comments

Comments
 (0)