Skip to content

Recovered: fix(credentials): serialize profile writes with cross-process file locking (#32 by @SahilRakhaiya05)#244

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

Recovered: fix(credentials): serialize profile writes with cross-process file locking (#32 by @SahilRakhaiya05)#244
jangjos-128 wants to merge 9 commits into
TestSprite:mainfrom
SahilRakhaiya05:fix/credentials-write-lock

Conversation

@jangjos-128

@jangjos-128 jangjos-128 commented Jul 13, 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

  • Bug Fixes

    • Improved credential profile updates to prevent data loss when multiple processes write simultaneously.
    • Ensured credential changes complete before authentication commands report success or failure.
    • Added recovery for abandoned credential file locks.
  • Tests

    • Expanded coverage for concurrent credential updates and asynchronous operations.
  • Chores

    • Streamlined test builds by reusing up-to-date build output instead of rebuilding repeatedly.

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.
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.
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.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Credential persistence is now asynchronous and protected by an inter-process lock. Callers and tests await writes and deletions, while test infrastructure performs one conditional build before workers run.

Changes

Credentials and test infrastructure

Layer / File(s) Summary
Credential locking and async persistence
src/lib/credentials.ts
writeProfile and deleteProfile now return promises and perform locked read-modify-write operations with stale-lock recovery and cleanup.
Async callers and credential validation
src/commands/auth.ts, src/commands/*.test.ts, src/lib/*test.ts, test/helpers/*
Commands and tests await credential operations; coverage includes concurrent writes, lock cleanup, formatting, malformed profiles, and configuration reads.
Centralized conditional test builds
test/global-setup.mjs, vitest.config.ts, test/*.test.ts, .github/workflows/ci.yml
Global setup conditionally builds dist/, individual suites stop rebuilding it, and CI builds before testing.

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

Possibly related issues

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 13.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 file 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
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/credentials-write-lock

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: lockfile failed supply-chain policy check. Run pnpm install locally to update the lockfile.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/ci.yml (1)

49-58: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Pin workflow actions to immutable commit SHAs.

actions/checkout@v5 and actions/setup-node@v5 are mutable references. Replace them with reviewed full 40-character commit SHAs before merging.

As per path instructions, all third-party actions must be pinned to a full 40-char commit SHA.

🤖 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 @.github/workflows/ci.yml around lines 49 - 58, Replace the mutable v5
references in the checkout and Setup Node.js workflow steps with reviewed, full
40-character immutable commit SHAs. Keep the existing action inputs and workflow
behavior unchanged, and ensure both actions/checkout and actions/setup-node are
pinned.

Source: Path instructions

🤖 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 219-252: Update acquireCredentialsLock and releaseCredentialsLock
to track the lock identity established by each successful acquisition, such as
its unique token or ownership metadata. Before unlinking in
releaseCredentialsLock, verify the lock still exists and matches this
acquisition’s ownership; skip removal when it was reclaimed or replaced by
another owner, while preserving teardown’s non-throwing behavior.

---

Outside diff comments:
In @.github/workflows/ci.yml:
- Around line 49-58: Replace the mutable v5 references in the checkout and Setup
Node.js workflow steps with reviewed, full 40-character immutable commit SHAs.
Keep the existing action inputs and workflow behavior unchanged, and ensure both
actions/checkout and actions/setup-node are pinned.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4851e85e-0cfc-49b6-96b3-29efd16fb62e

📥 Commits

Reviewing files that changed from the base of the PR and between 60d55e4 and 7e1d172.

📒 Files selected for processing (12)
  • .github/workflows/ci.yml
  • 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/cli.subprocess.test.ts
  • test/global-setup.mjs
  • test/help.snapshot.test.ts
  • test/helpers/credentials-write-child.mjs
  • vitest.config.ts

Comment thread src/lib/credentials.ts
Comment on lines +219 to +252
async function acquireCredentialsLock(credentialsPath: string): Promise<void> {
const lockPath = credentialsLockPath(credentialsPath);
// Ensure the credentials directory exists before creating the lock file.
// writeCredentialsAtomic also mkdirs, but only after the lock is held.
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
const deadline = Date.now() + CREDENTIALS_LOCK_MAX_WAIT_MS;
while (Date.now() < deadline) {
try {
writeFileSync(lockPath, `${process.pid}\n${Date.now()}\n`, { flag: 'wx', encoding: 'utf8' });
return;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'EEXIST') throw err;
if (isStaleCredentialsLock(lockPath)) {
try {
unlinkSync(lockPath);
} catch {
// Another waiter may have claimed or released the lock.
}
continue;
}
await new Promise(resolve => setTimeout(resolve, CREDENTIALS_LOCK_RETRY_MS));
}
}
throw new Error(`Timed out acquiring credentials lock: ${lockPath}`);
}

function releaseCredentialsLock(credentialsPath: string): void {
try {
unlinkSync(credentialsLockPath(credentialsPath));
} catch {
// Lock already released or never acquired — teardown must not mask errors.
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and inspect the locking helpers around the cited lines.
ast-grep outline src/lib/credentials.ts --view expanded || true
echo '---'
nl -ba src/lib/credentials.ts | sed -n '150,320p'
echo '---'
# Find all references to the lock helpers and stale-lock detection.
rg -n "acquireCredentialsLock|releaseCredentialsLock|isStaleCredentialsLock|credentialsLockPath|CREDENTIALS_LOCK_" src/lib/credentials.ts src -g '!**/dist/**' -g '!**/build/**'

Repository: TestSprite/testsprite-cli

Length of output: 1859


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '200,280p' src/lib/credentials.ts | cat -n

Repository: TestSprite/testsprite-cli

Length of output: 3396


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "withCredentialsLock\\(" src/lib/credentials.ts src -g '!**/dist/**' -g '!**/build/**'
echo '---'
sed -n '1,220p' src/lib/credentials.ts | cat -n

Repository: TestSprite/testsprite-cli

Length of output: 9218


Guard lock release with ownership checks. isStaleCredentialsLock() can reclaim a lock on age alone, so a long-running holder can be preempted and later releaseCredentialsLock() may unlink a newer owner’s lock. Track ownership per acquisition and only unlink if this process still owns the lock.

🤖 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 219 - 252, Update acquireCredentialsLock
and releaseCredentialsLock to track the lock identity established by each
successful acquisition, such as its unique token or ownership metadata. Before
unlinking in releaseCredentialsLock, verify the lock still exists and matches
this acquisition’s ownership; skip removal when it was reclaimed or replaced by
another owner, while preserving teardown’s non-throwing behavior.

@jangjos-128

Copy link
Copy Markdown
Contributor Author

Hi @SahilRakhaiya05 👋 — this is the recovery of your PR #32 , which was auto-closed by an error in our 2026-07-09 release process. Your commits are intact — thank you for the contribution!

It currently shows merge conflicts because main has moved on since your branch was cut. To clear them, update your PR branch (BRANCH) against the latest main — either option works and both update this PR in place:

Option A — merge (no force-push):

git checkout BRANCH
git fetch upstream          # your remote for TestSprite/testsprite-cli (may be "origin")
git merge upstream/main     # resolve conflicts, commit
git push

Option B — rebase (cleaner history, needs force):

git checkout BRANCH
git fetch upstream
git rebase upstream/main    # resolve conflicts
git push --force-with-lease

Once you push, the conflicts here resolve automatically and we'll take it into review.

Sorry again for the extra step!

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