fix(auth): serialize token refresh and make config/session writes atomic - #230
Conversation
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.
| // 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) |
There was a problem hiding this comment.
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)
Codecov Report❌ Patch coverage is
📢 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.
| 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() { |
There was a problem hiding this comment.
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)
- 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.
There was a problem hiding this comment.
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.
…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>
Problem
Concurrent
hotdatainvocations race on the shared state in~/.hotdata. Found while load-testingdatabases createwith N parallel processes:databases createcallssave_current_database, an unlocked read-modify-write ofconfig.ymlthat ends in a truncatingfs::write. Parallel commands read half-written YAML (error parsing config file., exit 1) and silently drop each other's entries./o/token/. The server treats reuse of a rotated refresh token as compromise and revokes the session family; the losing processes then callclear_session(), deleting the winner's freshly saved session. Escalation observed live: 261session expired or revokederrors 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)
config.yml,session.json, anddatabase_session.jsonare written via tempfile-in-same-dir + rename, so readers never observe a partial file.flock(~/.hotdata/config.lock) serializes config read-modify-writes.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:
reqwest::blocking::Client::new()(no timeout). With refresh now holdingsession.lock, a stalled/o/token/would hang the lock holder and, with it, every concurrent invocation. They now useraw_http::build_http_client()(300s cap + keepalive), which theraw_httpmodule doc already designated for exactly these endpoints.RefreshError—Rejected(server refused: clear session, fall through to re-mint, as before) vsTransport(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).save_sessionafter a successful refresh now warns on stderr instead oflet _ =; an unpersisted rotation sends the next process back to the spent refresh token.lock_filewarns on unexpected errors (perms, I/O) instead of silently degrading; flock-unsupported filesystems (ENOTSUP/ENOLCK) still degrade silently to the pre-lock behavior.update_configprimitive — all five config read-modify-write functions route through one locked helper, so future mutations can't forget the lock.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 forconfig.yml, preserving its previous mode).Known accepted behavior: rename-based writes replace a symlinked destination with a regular file (relevant only if
~/.hotdatafiles 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 (mockitoexpect(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.Verification against production
60-second create bursts, before → after (fixed binary, 24,006 creates total, 5 errors ≈ 0.02%):
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.rsstill use un-timeoutedClient::new()(not lock-holding paths, so out of scope here).