Skip to content

test: cover cross-process credential writes - #280

Open
naufalfx805-source wants to merge 3 commits into
TestSprite:mainfrom
naufalfx805-source:fix/credentials-cross-process-race
Open

test: cover cross-process credential writes#280
naufalfx805-source wants to merge 3 commits into
TestSprite:mainfrom
naufalfx805-source:fix/credentials-cross-process-race

Conversation

@naufalfx805-source

@naufalfx805-source naufalfx805-source commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Closes #279.

#272 put a lock around credential profile mutations. Nothing in the suite proves it works across processes — which is the only place the bug it fixes can happen. This adds that regression test.

What is actually being protected

writeProfile is read-modify-write on a shared file (src/lib/credentials.ts:178):

mutateCredentialsFile(path, file => {
  file[profile] = { ...file[profile], ...entry };
  return file;
});

mutateCredentialsFile reads the whole credentials file, hands it to the mutator, and writes the whole thing back (credentials.ts:265). Two processes interleaving that sequence is a classic lost update: both read the same starting state, each adds its own profile, and the second renameSync overwrites the first. The profile does not fail to write — it silently disappears, and the user finds out later when a command that needs it reports AUTH_REQUIRED.

This is not hypothetical usage. Two shells, a CI job overlapping a local testsprite setup, or an agent running commands in parallel all hit it, and every one of those is a separate process — so no in-process mutex would have helped.

The guard is an O_EXCL lock file (credentials.ts:289): writeFileSync(lockPath, …, { flag: 'wx' }) succeeds for exactly one process, everyone else spins until CREDENTIALS_LOCK_WAIT_MS (5 s), with stale-lock reclaim and an assertHeld() token re-check immediately before the write in case a reclaim stole ownership mid-flight.

Every one of those branches is cross-process by construction. A single-process test cannot reach any of them.

How the test creates real contention

test/credentials-cross-process.test.ts spawns 8 real child processesspawn(process.execPath, [CHILD_PATH]), not workers or mocked fs — each writing a distinct profile to one shared credentials file.

The hard part is that spawning 8 processes staggers them by tens of milliseconds, which is longer than the critical section. They would queue politely and the test would pass without ever exercising the lock. So the children are held at a starting gate:

  1. Each child writes a <profile>.ready marker, then spin-waits on existsSync(startPath).
  2. The parent blocks until all 8 ready markers exist.
  3. The parent writes the start file, releasing all 8 into the critical section together.

The child sleeps between polls with Atomics.wait on a throwaway SharedArrayBuffer rather than a timer, so the wait is a genuine blocking sleep with no event-loop scheduling in the path.

Children import from dist/lib/credentials.js, so the test exercises the built artifact users actually run. beforeAll runs npm run build for that reason.

Assertions

  • All 8 children exit 0 with empty stdout and empty stderr — a lock timeout or a failed assertHeld() would surface here as a non-zero exit.
  • Every one of the 8 profiles is present in the final file with its correct apiKey. This is the lost-update assertion: under the pre-fix(auth): lock credential profile mutations #272 behaviour, profiles go missing.
  • ${credentialsPath}.lock no longer exists — the finally release ran, so the next writer is not blocked for 5 s by an orphan lock.

Three tests covering the harness itself

A concurrency test that silently stops testing anything is worse than no test, so the child contract is pinned by its exit codes:

Case Exit Asserted
Missing required env vars 1 names all four required vars
Start marker never appears 2 Timed out waiting for start marker: <path>
writeProfile rejects (bad] profile name) 3 stderr contains Invalid request.

Without these, a typo in an env var name would make all 8 children exit early and the main test would pass green while asserting nothing.

Notes

  • Tests only — no src/ changes, so nothing ships to users from this PR.
  • Each case builds its own mkdtempSync root and removes it in finally; nothing touches the real credentials path.
  • Timeouts are bounded: 10 s per child, 20 s for the concurrent case, 15 s for the timeout case.

Validation

npm test -- test/credentials-cross-process.test.ts
npm run typecheck
npm run lint -- test/credentials-cross-process.test.ts test/helpers/credentials-write-child.mjs
npx prettier --check test/credentials-cross-process.test.ts test/helpers/credentials-write-child.mjs

All CI checks are currently green on this branch.

Summary by CodeRabbit

  • Tests
    • Expanded the cross-process credentials write test suite with additional coordination logic to reliably synchronize concurrent writers.
    • Added polling-based waiting for per-profile readiness and a shared start marker to ensure all child processes exit cleanly.
    • Covered more failure modes: missing required environment variables, start marker timeout, and invalid write requests propagating as expected errors.
    • Verified the final credentials file includes all expected profile entries and that no credentials lock file remains afterward.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds cross-process credential write coverage using configurable child-process coordination, validating failure handling, concurrent profile preservation, and credentials lock-file cleanup.

Changes

Cross-process credential writes

Layer / File(s) Summary
Credential writer process
test/helpers/credentials-write-child.mjs
Validates required and polling-related environment variables, creates optional ready markers, waits for a start marker, writes a profile, and reports distinct failure codes.
Concurrent write orchestration and assertions
test/credentials-cross-process.test.ts
Builds the project, spawns child writers, validates missing-input, timeout, and write-error outcomes, coordinates eight concurrent writes, and checks profile preservation and lock-file cleanup.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: zeshi-du

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR adds the requested regression test for real concurrent cross-process writes and verifies all profiles persist.
Out of Scope Changes check ✅ Passed The new helper and test coordination changes support the requested race coverage and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the added regression test for cross-process credential writes.
✨ 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: 2

🤖 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 `@test/credentials-cross-process.test.ts`:
- Around line 57-93: Add focused tests for the failure branches of
runCredentialWriter alongside the existing success test: omit a required
credential environment variable and assert exit code 1, and configure an
uncreated CRED_START_PATH with a shortened timeout and assert exit code 2. Reuse
the existing temporary-directory setup and child-result assertions, and cover
the writeProfile error path with exit code 3 if the helper exposes a
controllable failure trigger.
- Around line 52-55: Set an explicit timeout on the beforeAll build hook in the
“credentials cross-process writes” suite, long enough for execNpm(['run',
'build'], ...) to complete reliably. Keep the existing build command and test
behavior unchanged.
🪄 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: 9e8ce548-81fa-42cc-abfe-dfe9b90d615d

📥 Commits

Reviewing files that changed from the base of the PR and between fe07bc9 and ff5ee23.

📒 Files selected for processing (2)
  • test/credentials-cross-process.test.ts
  • test/helpers/credentials-write-child.mjs

Comment thread test/credentials-cross-process.test.ts
Comment thread test/credentials-cross-process.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

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

⚠️ Outside diff range comments (1)
test/credentials-cross-process.test.ts (1)

1-50: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make process timing and barrier coordination injectable.

The test suite relies on hard-coded wall-clock waits: the child helper waits 5 seconds, runCredentialWriter has a fixed kill timeout, and the race test uses a short delay before creating the marker. This makes the suite slower/flakier under load, and the delay does not guarantee every child reached the barrier.

Expose timeout/sleep controls through test dependencies and use an explicit child-readiness handshake before releasing the marker.

As per path instructions, tests must be deterministic and offline: no real network and no real timers — inject fetch/sleep via the test deps.

Also applies to: 75-96, 123-163

🤖 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 `@test/credentials-cross-process.test.ts` around lines 1 - 50, Update the
cross-process credential tests and runCredentialWriter to inject sleep, timeout,
and fetch behavior through test dependencies instead of using hard-coded delays,
kill timers, or real network access. Replace the race test’s marker delay with
an explicit readiness handshake from every child before releasing the marker,
and make the child helper’s barrier wait use the injected timing controls.
Preserve the existing credential assertions while ensuring all synchronization
is deterministic and timer-free.

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.

Outside diff comments:
In `@test/credentials-cross-process.test.ts`:
- Around line 1-50: Update the cross-process credential tests and
runCredentialWriter to inject sleep, timeout, and fetch behavior through test
dependencies instead of using hard-coded delays, kill timers, or real network
access. Replace the race test’s marker delay with an explicit readiness
handshake from every child before releasing the marker, and make the child
helper’s barrier wait use the injected timing controls. Preserve the existing
credential assertions while ensuring all synchronization is deterministic and
timer-free.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e51b8517-0b23-40f2-8e70-ce7a9eb4f7f5

📥 Commits

Reviewing files that changed from the base of the PR and between ff5ee23 and 74f345b.

📒 Files selected for processing (1)
  • test/credentials-cross-process.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.

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 `@test/credentials-cross-process.test.ts`:
- Around line 52-62: Update waitForFiles to accept injectable clock and sleep
dependencies instead of directly calling Date.now and setTimeout, while
preserving its polling and timeout behavior. Update the readiness and
timeout-path tests to provide controlled fake time and sleep advancement,
removing reliance on real timer delays and child-process scheduling.
🪄 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: 74e8ad9d-6b8c-4330-8839-8235f42961b6

📥 Commits

Reviewing files that changed from the base of the PR and between 74f345b and f7926f4.

📒 Files selected for processing (2)
  • test/credentials-cross-process.test.ts
  • test/helpers/credentials-write-child.mjs
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/helpers/credentials-write-child.mjs

Comment on lines +52 to +62
async function waitForFiles(paths: string[], timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs;

while (true) {
const missing = paths.filter(path => !existsSync(path));
if (missing.length === 0) return;
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for files: ${missing.join(', ')}`);
}
await new Promise(resolveDelay => setTimeout(resolveDelay, 5));
}

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 | 🏗️ Heavy lift

Inject time control instead of polling wall-clock time.

waitForFiles directly uses Date.now()/setTimeout, while the timeout-path test depends on real 1 ms/100 ms child timing. Inject clock/sleep dependencies so readiness and timeout cases can run under controlled time rather than CI scheduling.

As per path instructions, “Tests must be deterministic and offline: No real network and no real timers — inject fetch/sleep via the test deps.”

Also applies to: 97-99

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawn } from 'node:child_process';
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 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 `@test/credentials-cross-process.test.ts` around lines 52 - 62, Update
waitForFiles to accept injectable clock and sleep dependencies instead of
directly calling Date.now and setTimeout, while preserving its polling and
timeout behavior. Update the readiness and timeout-path tests to provide
controlled fake time and sleep advancement, removing reliance on real timer
delays and child-process scheduling.

Source: Path instructions

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.

test: cover cross-process credential mutation race

1 participant