Skip to content

fix(credentials): serialize profile writes with cross-process file locking#32

Open
SahilRakhaiya05 wants to merge 8 commits into
TestSprite:mainfrom
SahilRakhaiya05:fix/credentials-write-lock
Open

fix(credentials): serialize profile writes with cross-process file locking#32
SahilRakhaiya05 wants to merge 8 commits into
TestSprite:mainfrom
SahilRakhaiya05:fix/credentials-write-lock

Conversation

@SahilRakhaiya05

@SahilRakhaiya05 SahilRakhaiya05 commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

writeProfile and deleteProfile perform read-modify-write on ~/.testsprite/credentials. The final rename is atomic, but the read and write are not one operation. Two concurrent CLI processes (e.g. two terminals running testsprite setup for different profiles, or setup + auth remove) can each read the same snapshot; the last rename wins and silently drops the other profile update - real credentials data loss.

The fix

  • Wrap writeProfile / deleteProfile in withCredentialsLock()
  • Exclusive lock file at {credentialsPath}.lock via openSync(..., 'wx')
  • Stale-lock reclamation when holder pid is dead or lock age > 30s
  • Bounded retry (25ms back-off, 10s max wait)

Tests

  • Subprocess regression: parallel writers for dev + staging profiles - both survive
  • Lock file is removed after writeProfile / deleteProfile complete

Notes

Summary by CodeRabbit

Summary

  • Bug Fixes

    • Prevented concurrent credential updates from clobbering each other by serializing profile writes/deletes with a cross-process lock.
    • Improved reliability of configure and logout flows to ensure credential persistence/removal completes before reporting results.
  • Tests

    • Added a concurrency test that performs simultaneous profile writes and verifies both updates persist.
    • Added assertions that lock artifacts are cleaned up after write/delete operations.
    • Updated existing tests to properly await credential operations.
  • Chores

    • CI now builds before tests, and the test runner avoids parallel workers to reduce flakiness.

Solving Issue #109

…cking

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.
Resolve conflict in credentials.test.ts by keeping both the
cross-process write-lock regression tests and the profile-name
validation tests added in TestSprite#21.
acquireCredentialsLock now creates the credentials directory before
openSync on the lock path, so first-time setup on a fresh HOME no longer
throws ENOENT (fixes subprocess setup/auth tests). Add trailing newline
to credentials-write-child.mjs for format:check.
@zeshi-du

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds inter-process locking for credential file writes and makes credential persistence awaited across commands and tests. CI and Vitest are updated to build before tests and avoid parallel file-worker races.

Changes

Credentials Write Lock

Layer / File(s) Summary
Lock implementation and integration
src/lib/credentials.ts
Adds lock-file creation, stale-lock detection, retry/polling, timeout handling, and wraps writeProfile/deleteProfile in withCredentialsLock.
Child writer helper script
test/helpers/credentials-write-child.mjs
Reads CRED_PROFILE, CRED_PATH, and CRED_API_KEY, exits when any are missing, otherwise calls writeProfile.
Concurrency and lock-removal tests
src/lib/credentials.test.ts
Adds child-process and path imports, builds once, spawns concurrent writers, asserts both profiles persist, and verifies .lock cleanup after write/delete sequences.
Build-first test execution
.github/workflows/ci.yml, vitest.config.ts
Runs npm run build before CI tests and disables Vitest file parallelism to avoid races on built output.

Awaited Credential Persistence

Layer / File(s) Summary
Await credential writes in commands
src/commands/auth.ts
Awaits writeProfile and deleteProfile in runConfigure and runLogout before continuing command flow.
Await profile setup in command tests
src/commands/auth.test.ts, src/commands/usage.test.ts, src/lib/config.test.ts
Updates command, usage, and config tests to await profile setup and to use async rejection assertions where needed.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • TestSprite/testsprite-cli#77 — Matches the same credentials write-lock behavior and concurrent-write coverage added here.

Suggested reviewers: ruili-testsprite, zeshi-du

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: serializing credential profile writes with cross-process locking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/credentials.ts`:
- Around line 226-244: Fresh locks are being treated as stale in the lock
acquisition flow, which can let a second process reclaim a just-created lock
before its contents are fully written. Update the lock handling in the
credentials locking logic around the openSync/writeFileSync path and
isStaleCredentialsLock so only a lock that is definitively owned by a dead
process can be reclaimed; do not unlink or reuse a lock with missing or
incomplete PID/timestamp contents. Make the stale-lock check ownership-safe in
the retry loop that calls syncSleep, using the existing lockPath and
isStaleCredentialsLock helpers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8321e47c-d2e2-41b8-9a49-8664fd8edbea

📥 Commits

Reviewing files that changed from the base of the PR and between 15e95de and f607eaf.

📒 Files selected for processing (3)
  • src/lib/credentials.test.ts
  • src/lib/credentials.ts
  • test/helpers/credentials-write-child.mjs

Comment thread src/lib/credentials.ts Outdated
@zeshi-du

zeshi-du commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Thanks @SahilRakhaiya05 — strong work here. The cross-process test that spawns real child processes is exactly the rigor we want to see. 🙌

One thing to close before merge: the lock has a create-then-write TOCTOU. openSync(lockPath, 'wx') followed by a separate writeFileSync of the PID means a contender that hits EEXIST in that window can read an empty/partial lock, judge it stale, and barge into the critical section — reintroducing the exact race this fixes. Could you make lock creation atomic — write PID+timestamp to a temp file and renameSync it into place (the same pattern writeCredentialsAtomic already uses in this file)? CodeRabbit flagged this as Critical as well.

Once that's in, happy to merge. Thanks! 🚀

Write PID and timestamp to a temp file and renameSync into place,
matching writeCredentialsAtomic. Prevents a contender from reclaiming
an empty/partial lock during the old open-then-write window.

Only reclaim locks with complete PID+timestamp content; treat
incomplete locks as in-flight acquisition.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/credentials.ts`:
- Around line 223-235: The lock acquisition in credentials.ts is using the wrong
primitive in the temp-file swap path around renameSync, which can overwrite an
existing lock and break mutual exclusion. Update the lock creation logic to use
an exclusive-create approach instead, such as openSync with the wx flag or
linkSync, and keep the temp-file cleanup and EEXIST handling in the same
lock-acquisition flow. Use the existing lockPath temp-file sequence in the
credentials lock helper to make the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5a1e39dd-e983-4c03-b57e-a3e0b8033f66

📥 Commits

Reviewing files that changed from the base of the PR and between f607eaf and a92720a.

📒 Files selected for processing (1)
  • src/lib/credentials.ts

Comment thread src/lib/credentials.ts Outdated
renameSync atomically replaces an existing lock file on POSIX, so two
contending processes can overwrite lockPath and both enter the critical
section. writeFileSync with flag 'wx' creates the lock with full PID and
timestamp content in one syscall, preserving mutual exclusion without the
open-then-write TOCTOU window.
Multiple suites (cli.subprocess, help.snapshot, credentials lock) each run
npm run build in beforeAll. With fileParallelism enabled, workers can corrupt
dist/ mid-run and subprocess smoke tests flake with exit 1. Run build once in
CI before npm test and disable vitest file parallelism.
@SahilRakhaiya05

Copy link
Copy Markdown
Contributor Author

@zeshi-du Thanks for the detailed review I have updated all the requested changes.

@zeshi-du

zeshi-du commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Substance looks right — the wx-flag atomic create + pid/timestamp stale-lock detection is the correct core. One change needed before merge: the busy-wait syncSleep (25 ms spin) burns a full CPU core for up to the whole lock-wait window. The withCredentialsLock callers are already async, so make the wait a real timer:

await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_DELAY_MS));

If there's a sync-context constraint I'm missing, Atomics.wait on a SharedArrayBuffer is the non-spinning sync alternative. With the spin removed, this merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/lib/credentials.test.ts (1)

212-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the concurrency regression force overlap.

These children are spawned back-to-back, but one can still finish before the other reads the credentials file, so the test can pass without exercising the lost-update race. Add a small start barrier in test/helpers/credentials-write-child.mjs before writeProfile, then release both children together.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/credentials.test.ts` around lines 212 - 217, The concurrency
regression test is not guaranteed to overlap the two writers, so it can pass
without hitting the lost-update path. Update the credential write child helper,
test/helpers/credentials-write-child.mjs, to wait on a small start barrier
before calling writeProfile, then have src/lib/credentials.test.ts release both
spawned children together in the serializes cross-process writes test so the
concurrent profile updates actually race.
src/lib/credentials.ts (1)

210-214: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Await async callbacks before releasing the credentials lock. withCredentialsLock should accept T | Promise<T> and return await fn() so an async caller can’t release the lock before its work finishes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/credentials.ts` around lines 210 - 214, withCredentialsLock currently
releases the credentials lock before an async callback finishes because it
returns fn() directly; update withCredentialsLock to accept T | Promise<T> and
use return await fn() inside the try block so the lock is held until the
callback completes. Keep the fix centered on withCredentialsLock and any callers
that rely on it for serialized credentials access.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/lib/credentials.test.ts`:
- Around line 212-217: The concurrency regression test is not guaranteed to
overlap the two writers, so it can pass without hitting the lost-update path.
Update the credential write child helper,
test/helpers/credentials-write-child.mjs, to wait on a small start barrier
before calling writeProfile, then have src/lib/credentials.test.ts release both
spawned children together in the serializes cross-process writes test so the
concurrent profile updates actually race.

In `@src/lib/credentials.ts`:
- Around line 210-214: withCredentialsLock currently releases the credentials
lock before an async callback finishes because it returns fn() directly; update
withCredentialsLock to accept T | Promise<T> and use return await fn() inside
the try block so the lock is held until the callback completes. Keep the fix
centered on withCredentialsLock and any callers that rely on it for serialized
credentials access.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d5a3c3a-0121-49ef-86ce-d38f2169eb2e

📥 Commits

Reviewing files that changed from the base of the PR and between 272b196 and 80bb450.

📒 Files selected for processing (7)
  • src/commands/auth.test.ts
  • src/commands/auth.ts
  • src/commands/usage.test.ts
  • src/lib/config.test.ts
  • src/lib/credentials.test.ts
  • src/lib/credentials.ts
  • test/helpers/credentials-write-child.mjs
✅ Files skipped from review due to trivial changes (1)
  • src/lib/config.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/helpers/credentials-write-child.mjs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants