Skip to content

fix(auth): serialize token refresh and make config/session writes atomic - #230

Merged
eddietejeda merged 3 commits into
mainfrom
fix/concurrent-config-session-races
Jul 20, 2026
Merged

fix(auth): serialize token refresh and make config/session writes atomic#230
eddietejeda merged 3 commits into
mainfrom
fix/concurrent-config-session-races

Conversation

@eddietejeda

@eddietejeda eddietejeda commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Concurrent hotdata invocations race on the shared state in ~/.hotdata. Found while load-testing databases create with N parallel processes:

  1. Torn config writes — every databases create calls save_current_database, an unlocked read-modify-write of config.yml that ends in a truncating fs::write. Parallel commands read half-written YAML (error parsing config file., exit 1) and silently drop each other's entries.
  2. Refresh-token stampede — when the 5-minute access token expires mid-burst, every process spends the same refresh token against /o/token/. The server treats reuse of a rotated refresh token as compromise and revokes the session family; the losing processes then call clear_session(), deleting the winner's freshly saved session. Escalation observed live: 261 session expired or revoked errors at 32-way concurrency, then total session death — a 128-way burst went 0 for 35,146, and the login session was permanently revoked (re-auth required).

Fix (commit 1)

  • Atomic writes: config.yml, session.json, and database_session.json are written via tempfile-in-same-dir + rename, so readers never observe a partial file.
  • Config lock: a blocking exclusive flock (~/.hotdata/config.lock) serializes config read-modify-writes.
  • Serialized refresh: ensure_access_token's refresh/re-mint slow path runs under ~/.hotdata/session.lock, re-checking the cached session after acquiring the lock — waiters reuse the winner's fresh token instead of spending the refresh token again. The valid-cache fast path stays lock-free.

Review hardening (commit 2)

An 8-angle adversarial review (plus an independent Codex CLI pass) of commit 1 surfaced these, all applied:

  • Timeout on token calls — the mint/refresh endpoints used bare reqwest::blocking::Client::new() (no timeout). With refresh now holding session.lock, a stalled /o/token/ would hang the lock holder and, with it, every concurrent invocation. They now use raw_http::build_http_client() (300s cap + keepalive), which the raw_http module doc already designated for exactly these endpoints.
  • Typed RefreshErrorRejected (server refused: clear session, fall through to re-mint, as before) vs Transport (request never completed: the refresh token was not consumed, so keep the session and surface the real connection error instead of forcing a re-login on a flaky network).
  • Persistence failure is visible — a failed save_session after a successful refresh now warns on stderr instead of let _ =; an unpersisted rotation sends the next process back to the spent refresh token.
  • Lock failures are visiblelock_file warns on unexpected errors (perms, I/O) instead of silently degrading; flock-unsupported filesystems (ENOTSUP/ENOLCK) still degrade silently to the pre-lock behavior.
  • Locked update_config primitive — all five config read-modify-write functions route through one locked helper, so future mutations can't forget the lock.
  • Shared util::atomic_write(path, bytes, mode) — replaces three copies of the tempfile+rename block; file modes are now explicit (0600 for both session files, 0644 for config.yml, preserving its previous mode).

Known accepted behavior: rename-based writes replace a symlinked destination with a regular file (relevant only if ~/.hotdata files are managed by a dotfile symlinker).

Tests

  • concurrent_saves_keep_all_entries_and_parse_cleanly — 8 threads doing config read-modify-write; all entries must survive and parse.
  • ensure_concurrent_expiry_refreshes_once_and_shares_result — 8 threads hitting an expired session must produce exactly one refresh request (mockito expect(1)) and all share its token.
  • ensure_keeps_session_when_refresh_fails_at_transport_level — connection failure keeps the session and surfaces the real error.
  • config_file_stays_world_readable — config.yml stays 0644 through the atomic-write path.
  • Full suite: 304 passed, 0 failed; fmt/clippy clean for touched files.

Verification against production

60-second create bursts, before → after (fixed binary, 24,006 creates total, 5 errors ≈ 0.02%):

Concurrency Broken binary Fixed binary
32 3,141 created, 274 errors 5,048 created, 1 error
64 3,667 created, 12 errors 5,950 created, 0 errors
128 0 created, 35,146 errors (session revoked) 6,292 created, 2 errors
256 not reached 6,716 created, 2 errors

Post-review commit re-smoked with a 32-wide parallel create burst: 32/32 succeeded, cleanup clean.

The handful of remaining errors print an empty message — a separate CLI error-reporting issue, tracked as a follow-up. Also follow-up: auth.rs/credentials.rs still use un-timeouted Client::new() (not lock-holding paths, so out of scope here).

Concurrent hotdata invocations raced on the shared state in ~/.hotdata:

- config.yml updates (e.g. save_current_database on every `databases
  create`) were unlocked read-modify-write cycles ending in a truncating
  fs::write, so parallel commands read half-written YAML ("error parsing
  config file") and dropped each other's entries.
- When the 5-minute access token expired mid-burst, every process spent
  the same refresh token against /o/token/. The server treats reuse of a
  rotated refresh token as compromise and revokes the session; the losers
  then cleared session.json, deleting the winner's fresh session. Under
  sustained concurrency this killed the login session entirely (observed:
  a 128-way burst going 0-for-35k with "session expired or revoked").

Fix:

- Write config.yml, session.json, and database_session.json via
  tempfile-in-same-dir + rename so readers never observe partial files
  (tempfile creates 0600 and rename preserves it for the session files).
- Take a blocking exclusive flock (config.lock) around the config
  read-modify-write helpers.
- Serialize ensure_access_token's refresh/re-mint slow path under
  session.lock, re-checking the cached session after acquiring the lock
  so waiters reuse the winner's token instead of spending the refresh
  token again. The valid-cache fast path stays lock-free. Locks are
  advisory and best-effort: without flock support behavior degrades to
  the previous unserialized writes.

Verified with a 60s create burst at 32-256 concurrent processes: zero
auth/config errors across ~11k creates, where the same run previously
revoked the session.
Comment thread src/config.rs Outdated
// Write-to-temp + rename so concurrent readers never observe a
// truncated or half-written file (a plain fs::write truncates first,
// and parallel invocations were hitting "error parsing config file").
let mut tmp = tempfile::NamedTempFile::new_in(parent)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

super nit: NamedTempFile cleans up on Drop, but if a process is killed between new_in and persist, the temp file leaks in ~/.hotdata. Under the exact heavy-concurrency-with-kills scenario this PR targets (128-way bursts, some processes terminated), these .tmpXXXXXX files could slowly accumulate. Not a correctness problem — config load reads config.yml directly and ignores them — just orphaned files worth being aware of. (not blocking)

claude[bot]
claude Bot previously approved these changes Jul 20, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Atomic write-then-rename and flock serialization are implemented correctly; the refresh slow path re-checks the cached session after acquiring the lock, and both regression tests cover the documented races. One super nit inline, non-blocking.

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.27731% with 16 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/config.rs 89.47% 12 Missing ⚠️
src/client/jwt.rs 96.29% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

…esh errors, locked config primitive

Review follow-ups on the concurrency fix (PR #230):

- Token endpoints (jwt.rs mints/refresh, database_session.rs refresh) now
  use raw_http::build_http_client() (300s timeout + keepalive) instead of
  bare reqwest Client::new(), which has no timeout. With the refresh now
  running under session.lock, a stalled /o/token/ would otherwise hang the
  lock holder — and with it every concurrent hotdata invocation.
- refresh() returns a typed RefreshError: Rejected (server refused the
  grant → clear the session and fall through to re-mint, as before) vs
  Transport (request never completed → keep the session, which was not
  consumed, and surface the real connection error instead of forcing a
  re-login).
- A failed save_session after a successful refresh now prints a warning
  instead of being silently dropped — an unpersisted rotation sends the
  next process back to the spent refresh token.
- lock_file() warns on unexpected failures (perms, I/O) instead of
  silently degrading; flock-unsupported filesystems still degrade silently.
- All five config read-modify-write functions now go through one locked
  update_config() primitive, so future mutations can't forget the lock.
- The tempfile+rename block is extracted to util::atomic_write(path,
  bytes, mode); modes are explicit — 0600 for both session files, 0644
  for config.yml (preserving its pre-atomic-write mode).

New tests: transport-level refresh failure keeps the session; config.yml
stays 0644.
Comment thread src/client/jwt.rs
util::send_debug_with_redaction(&client, req, Some(&body_log), TOKEN_REDACT_KEYS)
.map_err(|e| format!("connection error: {e}"))?;
.map_err(|e| RefreshError::Transport(format!("connection error: {e}")))?;
if !status.is_success() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: any non-2xx here is classified as Rejected, which makes ensure_access_token call clear_session() and fall through to re-mint. That's correct for 400 invalid_grant (the session really is dead), but it also fires for transient server failures — 429 Too Many Requests, 500, 502/503. Under the exact 128-way burst this PR targets, an overloaded token endpoint returning 503/429 is plausible, and none of those consume/rotate the refresh token — the session is still good. Since refresh is now serialized under session.lock, clearing on a transient 5xx/429 needlessly kills the session for the whole burst (forced re-login for interactive sessions with no api_key fallback), which partly undercuts the goal here.

Worth treating 5xx/429 like the Transport case (keep the session, surface the error) and only clearing on a genuine 4xx invalid_grant. Note this is still strictly better than the pre-PR Err(_) => clear_session(), so not blocking. (not blocking)

claude[bot]
claude Bot previously approved these changes Jul 20, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Locking + atomic-write approach is sound and well-tested (concurrent config saves, single-refresh regression, transport-vs-rejected split). One non-blocking nit on 5xx/429 error classification left inline.

- ensure_returns_token_even_when_session_persist_fails: a read-only
  config dir must not fail the refresh — the caller still gets the
  token, the on-disk session stays stale, and the failed lock
  acquisition exercises the unlocked degrade.
- lock_file_degrades_to_none_when_config_dir_unavailable: pointing the
  config dir at a regular file must yield None (proceed unlocked), not
  an error or a hang.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the concurrency fix end to end: atomic write/chmod/rename ordering, the single locked update_config primitive, the lock-free fast path + re-check under session.lock, the Transport/Rejected split, and the timeout-bounded token client. Lock ordering is clean (no nesting, flock auto-released on death) and the tests cover the races, transport-retention, persist-failure, and lock-degrade paths. LGTM.

@eddietejeda
eddietejeda merged commit 85146a8 into main Jul 20, 2026
14 checks passed
@eddietejeda
eddietejeda deleted the fix/concurrent-config-session-races branch July 20, 2026 03:37
eddietejeda added a commit that referenced this pull request Jul 20, 2026
…d auth probe (#233)

Follow-up from #230: cache_workspaces, api_key_authorized_workspaces,
and credentials::check_status used bare reqwest::blocking::Client::new(),
which has no request timeout — a stalled server could hang 'auth login'
or the 4xx auth-status probe (which runs inside error printing)
indefinitely. They now share raw_http::build_http_client() (300s cap +
TCP keepalive) like every other raw-HTTP path. Test-only localhost
clients are left as-is.

Co-authored-by: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com>
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.

1 participant