Skip to content

Commit 6a3ec3a

Browse files
fix(credentials): serialize profile writes with cross-process file locking
writeProfile and deleteProfile read-modify-write the credentials file; without a lock, concurrent CLI processes can each read the same snapshot and the last atomic rename wins, silently dropping the other update. Acquire an exclusive lock file ({path}.lock) before read-modify-write, with stale-lock reclamation and a bounded retry loop. Add subprocess regression tests proving concurrent writes to different profiles both survive.
1 parent 18f6e6e commit 6a3ec3a

3 files changed

Lines changed: 183 additions & 13 deletions

File tree

src/lib/credentials.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { execSync } from 'node:child_process';
2+
import type { ChildProcess } from 'node:child_process';
3+
import { spawn } from 'node:child_process';
14
import { mkdtempSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
25
import { tmpdir } from 'node:os';
36
import { join } from 'node:path';
4-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7+
import { fileURLToPath } from 'node:url';
8+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
59
import {
610
DEFAULT_PROFILE,
711
defaultCredentialsPath,
@@ -168,3 +172,62 @@ describe('defaultCredentialsPath', () => {
168172
expect(defaultCredentialsPath().endsWith('/.testsprite/credentials')).toBe(true);
169173
});
170174
});
175+
176+
const projectRoot = fileURLToPath(new URL('../..', import.meta.url));
177+
const credentialsWriteChild = join(projectRoot, 'test/helpers/credentials-write-child.mjs');
178+
179+
function waitForChild(child: ChildProcess): Promise<void> {
180+
return new Promise((resolve, reject) => {
181+
let stderr = '';
182+
child.stderr?.on('data', chunk => {
183+
stderr += String(chunk);
184+
});
185+
child.on('error', reject);
186+
child.on('close', code => {
187+
if (code === 0) resolve();
188+
else reject(new Error(`child exited with code ${code}: ${stderr}`));
189+
});
190+
});
191+
}
192+
193+
function spawnCredentialsWriter(profile: string, apiKey: string, path: string): ChildProcess {
194+
return spawn(process.execPath, [credentialsWriteChild], {
195+
env: {
196+
...process.env,
197+
CRED_PROFILE: profile,
198+
CRED_PATH: path,
199+
CRED_API_KEY: apiKey,
200+
},
201+
stdio: ['ignore', 'ignore', 'pipe'],
202+
});
203+
}
204+
205+
describe('credentials write lock', () => {
206+
beforeAll(() => {
207+
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' });
208+
});
209+
210+
it('serializes cross-process writes so concurrent profile updates are not lost', async () => {
211+
const children = [
212+
spawnCredentialsWriter('dev', 'sk-dev', credentialsPath),
213+
spawnCredentialsWriter('staging', 'sk-staging', credentialsPath),
214+
];
215+
await Promise.all(children.map(waitForChild));
216+
217+
const file = readCredentialsFile({ path: credentialsPath });
218+
expect(file.dev).toEqual({ apiKey: 'sk-dev' });
219+
expect(file.staging).toEqual({ apiKey: 'sk-staging' });
220+
});
221+
222+
it('removes the lock file after writeProfile completes', () => {
223+
writeProfile('default', { apiKey: 'sk-lock-cleanup' }, { path: credentialsPath });
224+
expect(existsSync(`${credentialsPath}.lock`)).toBe(false);
225+
});
226+
227+
it('removes the lock file after deleteProfile completes', () => {
228+
writeProfile('default', { apiKey: 'sk-d' }, { path: credentialsPath });
229+
writeProfile('dev', { apiKey: 'sk-dev' }, { path: credentialsPath });
230+
expect(deleteProfile('dev', { path: credentialsPath })).toBe(true);
231+
expect(existsSync(`${credentialsPath}.lock`)).toBe(false);
232+
});
233+
});

src/lib/credentials.ts

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {
22
chmodSync,
3+
closeSync,
34
existsSync,
45
mkdirSync,
6+
openSync,
57
readFileSync,
68
renameSync,
79
statSync,
10+
unlinkSync,
811
writeFileSync,
912
} from 'node:fs';
1013
import { homedir } from 'node:os';
@@ -109,22 +112,26 @@ export function writeProfile(
109112
options: CredentialsOptions = {},
110113
): void {
111114
const path = resolvePath(options);
112-
const file = readCredentialsFile(options);
113-
file[profile] = { ...file[profile], ...entry };
114-
writeCredentialsAtomic(path, file);
115+
withCredentialsLock(path, () => {
116+
const file = readCredentialsFile(options);
117+
file[profile] = { ...file[profile], ...entry };
118+
writeCredentialsAtomic(path, file);
119+
});
115120
}
116121

117122
export function deleteProfile(profile: string, options: CredentialsOptions = {}): boolean {
118123
const path = resolvePath(options);
119-
const file = readCredentialsFile(options);
120-
if (!(profile in file)) return false;
121-
delete file[profile];
122-
if (Object.keys(file).length === 0) {
123-
writeCredentialsAtomic(path, {});
124-
} else {
125-
writeCredentialsAtomic(path, file);
126-
}
127-
return true;
124+
return withCredentialsLock(path, () => {
125+
const file = readCredentialsFile(options);
126+
if (!(profile in file)) return false;
127+
delete file[profile];
128+
if (Object.keys(file).length === 0) {
129+
writeCredentialsAtomic(path, {});
130+
} else {
131+
writeCredentialsAtomic(path, file);
132+
}
133+
return true;
134+
});
128135
}
129136

130137
export function ensureRestrictiveMode(path: string): void {
@@ -144,3 +151,91 @@ function writeCredentialsAtomic(path: string, file: CredentialsFile): void {
144151
renameSync(tmp, path);
145152
ensureRestrictiveMode(path);
146153
}
154+
155+
/** Max wall-clock wait when another process holds the credentials lock. */
156+
const CREDENTIALS_LOCK_MAX_WAIT_MS = 10_000;
157+
/** Back-off between lock attempts. */
158+
const CREDENTIALS_LOCK_RETRY_MS = 25;
159+
/** Reclaim a lock file when the holder pid is gone or the file is older than this. */
160+
const CREDENTIALS_LOCK_STALE_MS = 30_000;
161+
162+
function credentialsLockPath(credentialsPath: string): string {
163+
return `${credentialsPath}.lock`;
164+
}
165+
166+
/**
167+
* Serialize read-modify-write on the credentials file across processes.
168+
* `writeCredentialsAtomic` only makes the final rename atomic; without this
169+
* lock, concurrent `writeProfile` / `deleteProfile` calls can each read the
170+
* same snapshot and the last rename wins — silently dropping the other update.
171+
*/
172+
function withCredentialsLock<T>(credentialsPath: string, fn: () => T): T {
173+
acquireCredentialsLock(credentialsPath);
174+
try {
175+
return fn();
176+
} finally {
177+
releaseCredentialsLock(credentialsPath);
178+
}
179+
}
180+
181+
function acquireCredentialsLock(credentialsPath: string): void {
182+
const lockPath = credentialsLockPath(credentialsPath);
183+
const deadline = Date.now() + CREDENTIALS_LOCK_MAX_WAIT_MS;
184+
while (Date.now() < deadline) {
185+
try {
186+
const fd = openSync(lockPath, 'wx');
187+
try {
188+
writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
189+
} finally {
190+
closeSync(fd);
191+
}
192+
return;
193+
} catch (err) {
194+
const code = (err as NodeJS.ErrnoException).code;
195+
if (code !== 'EEXIST') throw err;
196+
if (isStaleCredentialsLock(lockPath)) {
197+
try {
198+
unlinkSync(lockPath);
199+
} catch {
200+
// Another waiter may have claimed or released the lock.
201+
}
202+
continue;
203+
}
204+
syncSleep(CREDENTIALS_LOCK_RETRY_MS);
205+
}
206+
}
207+
throw new Error(`Timed out acquiring credentials lock: ${lockPath}`);
208+
}
209+
210+
function releaseCredentialsLock(credentialsPath: string): void {
211+
try {
212+
unlinkSync(credentialsLockPath(credentialsPath));
213+
} catch {
214+
// Lock already released or never acquired — teardown must not mask errors.
215+
}
216+
}
217+
218+
function isStaleCredentialsLock(lockPath: string): boolean {
219+
try {
220+
const stat = statSync(lockPath);
221+
if (Date.now() - stat.mtimeMs > CREDENTIALS_LOCK_STALE_MS) return true;
222+
const firstLine = readFileSync(lockPath, 'utf8').split('\n')[0] ?? '';
223+
const pid = Number.parseInt(firstLine, 10);
224+
if (!Number.isFinite(pid) || pid <= 0) return true;
225+
try {
226+
process.kill(pid, 0);
227+
return false;
228+
} catch {
229+
return true;
230+
}
231+
} catch {
232+
return false;
233+
}
234+
}
235+
236+
function syncSleep(ms: number): void {
237+
const until = Date.now() + ms;
238+
while (Date.now() < until) {
239+
// Busy-wait: credentials I/O is sync-only; sub-ms precision is unnecessary.
240+
}
241+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { writeProfile } from '../../dist/lib/credentials.js';
2+
3+
const profile = process.env.CRED_PROFILE;
4+
const credentialsPath = process.env.CRED_PATH;
5+
const apiKey = process.env.CRED_API_KEY;
6+
7+
if (!profile || !credentialsPath || !apiKey) {
8+
console.error('CRED_PROFILE, CRED_PATH, and CRED_API_KEY are required');
9+
process.exit(1);
10+
}
11+
12+
writeProfile(profile, { apiKey }, { path: credentialsPath });

0 commit comments

Comments
 (0)