Skip to content

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

Open
jangjos-128 wants to merge 11 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 11 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 updates to remain reliable when multiple operations occur at the same time.
    • Ensured configuration, logout, and related flows report results only after credential persistence changes finish.
  • Tests

    • Expanded coverage for concurrent credential updates, lock/cleanup behavior, and asynchronous command behavior.
    • Streamlined test setup by building once per run (global setup) and updated help snapshots and TTY expectations accordingly.

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cc5f7ba2-b3d6-4f7d-a3fa-a04501a044e5

📥 Commits

Reviewing files that changed from the base of the PR and between 18ecebf and f8bd679.

📒 Files selected for processing (3)
  • src/commands/doctor.test.ts
  • src/lib/config.test.ts
  • src/lib/ticker.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/config.test.ts

Walkthrough

Credential writes and deletions now complete asynchronously under a filesystem lock. Command and test callers await these operations, credential concurrency coverage is expanded, Vitest uses conditional global build setup, and TTY ticker tests force ANSI output.

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 credential updates inside an exclusive, stale-lock-aware critical section.
Async callers and credential validation
src/commands/auth.*, src/commands/usage.test.ts, src/commands/doctor.test.ts, src/lib/config.test.ts, src/lib/credentials.test.ts, test/helpers/credentials-write-child.mjs
Commands and tests await credential operations; credential tests cover formatting, malformed profiles, concurrent child-process writes, and lock cleanup.
Centralized conditional test builds
test/global-setup.mjs, vitest.config.ts, test/cli.subprocess.test.ts, test/help.snapshot.test.ts
Global setup builds dist/ only when missing or stale, allowing subprocess and snapshot suites to reuse the shared build.
Deterministic TTY ticker coverage
src/lib/ticker.spec.ts
TTY tests force color-enabled ANSI output and verify raw stderr behavior consistently across environments.

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

Sequence Diagram(s)

sequenceDiagram
  participant AuthCommand
  participant writeProfile
  participant withCredentialsLock
  participant CredentialsFile
  AuthCommand->>writeProfile: persist profile
  writeProfile->>withCredentialsLock: execute locked update
  withCredentialsLock->>CredentialsFile: create lock and update credentials
  CredentialsFile-->>withCredentialsLock: completed write
  withCredentialsLock-->>writeProfile: resolve promise
  writeProfile-->>AuthCommand: continue after persistence
Loading

Possibly related issues

Possibly related PRs

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: cross-process locking to serialize credential profile writes.
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

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 Outdated
@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!

Resolve conflicts and align with latest main:
- vitest.config: keep globalSetup + hermetic setupFiles + fileParallelism
- credentials: guard lock release with ownership token check
- cli.subprocess: drop unused execFileSync import after globalSetup

Closes TestSprite#109

@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/config.test.ts`:
- Around line 89-109: Mark both tests covering whitespace TESTSPRITE_API_URL and
TESTSPRITE_API_KEY as async, and await writeProfile before calling loadConfig.
Preserve the existing environment inputs and assertions.
🪄 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: 3341ffbb-96ab-49ee-be0d-baa3a8fced37

📥 Commits

Reviewing files that changed from the base of the PR and between 7e1d172 and 18ecebf.

📒 Files selected for processing (8)
  • src/commands/auth.test.ts
  • src/commands/auth.ts
  • src/lib/config.test.ts
  • src/lib/credentials.test.ts
  • src/lib/credentials.ts
  • test/cli.subprocess.test.ts
  • test/help.snapshot.test.ts
  • vitest.config.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • vitest.config.ts
  • test/help.snapshot.test.ts
  • src/commands/auth.ts
  • test/cli.subprocess.test.ts
  • src/lib/credentials.ts
  • src/commands/auth.test.ts

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

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/config.test.ts`:
- Around line 89-109: Mark both tests covering whitespace TESTSPRITE_API_URL and
TESTSPRITE_API_KEY as async, and await writeProfile before calling loadConfig.
Preserve the existing environment inputs and assertions.
🪄 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: 3341ffbb-96ab-49ee-be0d-baa3a8fced37

📥 Commits

Reviewing files that changed from the base of the PR and between 7e1d172 and 18ecebf.

📒 Files selected for processing (8)
  • src/commands/auth.test.ts
  • src/commands/auth.ts
  • src/lib/config.test.ts
  • src/lib/credentials.test.ts
  • src/lib/credentials.ts
  • test/cli.subprocess.test.ts
  • test/help.snapshot.test.ts
  • vitest.config.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • vitest.config.ts
  • test/help.snapshot.test.ts
  • src/commands/auth.ts
  • test/cli.subprocess.test.ts
  • src/lib/credentials.ts
  • src/commands/auth.test.ts
🛑 Comments failed to post (1)
src/lib/config.test.ts (1)

89-109: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Awaiting writeProfile is required for asynchronous profile writes.

The writeProfile function is now asynchronous. Because the tests at lines 89 and 102 are not marked async and do not await writeProfile(...), the configuration loading executes before the credentials file is actually written, which causes the assertions to fail (as seen in the pipeline failures).

💚 Proposed fix to await asynchronous profile writes
-  it('treats empty / whitespace TESTSPRITE_API_URL as unset (falls through to profile)', () => {
-    writeProfile(
+  it('treats empty / whitespace TESTSPRITE_API_URL as unset (falls through to profile)', async () => {
+    await writeProfile(
       'default',
       { apiKey: 'sk-file', apiUrl: 'https://api.example.com:8443' },
       { path: credentialsPath },
     );
     const config = loadConfig({
       env: { TESTSPRITE_API_URL: '   ' },
       credentialsPath,
     });
     expect(config.apiUrl).toBe('https://api.example.com:8443');
   });
 
-  it('treats empty / whitespace TESTSPRITE_API_KEY as unset (falls through to profile)', () => {
-    writeProfile('default', { apiKey: 'sk-file' }, { path: credentialsPath });
+  it('treats empty / whitespace TESTSPRITE_API_KEY as unset (falls through to profile)', async () => {
+    await writeProfile('default', { apiKey: 'sk-file' }, { path: credentialsPath });
     const config = loadConfig({
       env: { TESTSPRITE_API_KEY: '' },
       credentialsPath,
     });
     expect(config.apiKey).toBe('sk-file');
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  it('treats empty / whitespace TESTSPRITE_API_URL as unset (falls through to profile)', async () => {
    await writeProfile(
      'default',
      { apiKey: 'sk-file', apiUrl: 'https://api.example.com:8443' },
      { path: credentialsPath },
    );
    const config = loadConfig({
      env: { TESTSPRITE_API_URL: '   ' },
      credentialsPath,
    });
    expect(config.apiUrl).toBe('https://api.example.com:8443');
  });

  it('treats empty / whitespace TESTSPRITE_API_KEY as unset (falls through to profile)', async () => {
    await writeProfile('default', { apiKey: 'sk-file' }, { path: credentialsPath });
    const config = loadConfig({
      env: { TESTSPRITE_API_KEY: '' },
      credentialsPath,
    });
    expect(config.apiKey).toBe('sk-file');
  });
🧰 Tools
🪛 GitHub Actions: CI / 4_Unit Tests (Node 20).txt

[error] 99-99: loadConfig: expected TESTSPRITE_API_URL containing only whitespace to be treated as unset; apiUrl should be 'https://api.example.com:8443', but was 'https://api.testsprite.com'.


[error] 108-108: loadConfig: expected TESTSPRITE_API_KEY containing only whitespace to be treated as unset; apiKey should be 'sk-file', but was undefined.

🪛 GitHub Actions: CI / Unit Tests (Node 20)

[error] 99-99: loadConfig: treats empty/whitespace TESTSPRITE_API_URL as unset; expected apiUrl to be "https://api.example.com:8443" but received "https://api.testsprite.com"


[error] 108-108: loadConfig: treats empty/whitespace TESTSPRITE_API_KEY as unset; expected apiKey to be "sk-file" but received undefined

🪛 GitHub Actions: Test Coverage / 0_Coverage (= 80%).txt

[error] 99-99: loadConfig: treats empty/whitespace TESTSPRITE_API_URL as unset; expected apiUrl "https://api.example.com:8443" but got "https://api.testsprite.com".


[error] 108-108: loadConfig: treats empty/whitespace TESTSPRITE_API_KEY as unset; expected apiKey "sk-file" but got undefined.

🪛 GitHub Actions: Test Coverage / Coverage (= 80%)

[error] 99-99: loadConfig: expected empty/whitespace TESTSPRITE_API_URL to be treated as unset and fall through to profile value "https://api.example.com:8443", but received "https://api.testsprite.com".


[error] 108-108: loadConfig: expected empty/whitespace TESTSPRITE_API_KEY to be treated as unset and fall through to profile value "sk-file", but received undefined.

🪛 GitHub Check: Coverage (>= 80%)

[failure] 108-108: src/lib/config.test.ts > loadConfig > treats empty / whitespace TESTSPRITE_API_KEY as unset (falls through to profile)
AssertionError: expected undefined to be 'sk-file' // Object.is equality

  • Expected:
    "sk-file"
  • Received:
    undefined

❯ src/lib/config.test.ts:108:27


[failure] 99-99: src/lib/config.test.ts > loadConfig > treats empty / whitespace TESTSPRITE_API_URL as unset (falls through to profile)
AssertionError: expected 'https://api.testsprite.com' to be 'https://api.example.com:8443' // Object.is equality

Expected: "https://api.example.com:8443"
Received: "https://api.testsprite.com"

❯ src/lib/config.test.ts:99:27

🪛 GitHub Check: Unit Tests (Node 20)

[failure] 108-108: src/lib/config.test.ts > loadConfig > treats empty / whitespace TESTSPRITE_API_KEY as unset (falls through to profile)
AssertionError: expected undefined to be 'sk-file' // Object.is equality

  • Expected:
    "sk-file"
  • Received:
    undefined

❯ src/lib/config.test.ts:108:27


[failure] 99-99: src/lib/config.test.ts > loadConfig > treats empty / whitespace TESTSPRITE_API_URL as unset (falls through to profile)
AssertionError: expected 'https://api.testsprite.com' to be 'https://api.example.com:8443' // Object.is equality

Expected: "https://api.example.com:8443"
Received: "https://api.testsprite.com"

❯ src/lib/config.test.ts:99:27

🪛 GitHub Check: Unit Tests (Node 22)

[failure] 108-108: src/lib/config.test.ts > loadConfig > treats empty / whitespace TESTSPRITE_API_KEY as unset (falls through to profile)
AssertionError: expected undefined to be 'sk-file' // Object.is equality

  • Expected:
    "sk-file"
  • Received:
    undefined

❯ src/lib/config.test.ts:108:27


[failure] 99-99: src/lib/config.test.ts > loadConfig > treats empty / whitespace TESTSPRITE_API_URL as unset (falls through to profile)
AssertionError: expected 'https://api.testsprite.com' to be 'https://api.example.com:8443' // Object.is equality

Expected: "https://api.example.com:8443"
Received: "https://api.testsprite.com"

❯ src/lib/config.test.ts:99:27

🤖 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/config.test.ts` around lines 89 - 109, Mark both tests covering
whitespace TESTSPRITE_API_URL and TESTSPRITE_API_KEY as async, and await
writeProfile before calling loadConfig. Preserve the existing environment inputs
and assertions.

Source: Pipeline failures

- await writeProfile in config and doctor unit tests
- pass noColor=false in ticker TTY tests (CI sets NO_COLOR=1)

Closes TestSprite#77
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