Skip to content

Commit d4b421d

Browse files
authored
feat(session): add JSONL-based session persistence with resume and listing (#13)
## Summary Add session persistence so conversations survive across process exits. Sessions are stored as JSONL files (append-only, crash-safe) under `$XDG_DATA_HOME/ox/sessions/{project}/`. Users can `ox --list` to see past sessions and `ox -c [<prefix>]` to resume one; `--all` / `-a` widens both flags across every project. Resumed conversations restore in the TUI with full fidelity — text, tool calls + results, and (when `show_thinking` is on) thinking blocks. Works in TUI, bare REPL, and headless modes. Session ID flows through to the `x-claude-code-session-id` API header. ### Schema and storage - **Forward-compatible JSONL** with discriminated entry types — `header` (carries a `version` readers reject when newer), `message` (UUID + `parent_uuid` chain), `title` (re-appendable, with `source`: `first_prompt` / `ai_generated` / `user_provided`), `summary` (exit marker), and an `Unknown` catch-all so new variants land additively without a schema bump. - **Project-scoped layout** — `sessions/{project}/{epoch}-{uuid}.jsonl`. `{project}` derives from CWD with reserved chars collapsed to `-`; long paths truncate and append an xxh64 hash of the original `OsStr` bytes so distinct paths cannot collide after truncation. The epoch prefix keeps `ls` chronological; lookup by UUID suffix stays O(readdir). One-time startup sweeps migrate any legacy flat-layout / unprefixed files. - **Lazy file creation** — `start()` only allocates the session ID and stages the header in memory; the on-disk file is materialized by the first `record_message`. A session that exits without recording any message leaves no artifact behind, so launching `ox` and quitting without typing does not pollute `--list`. Mirrors claude-code's `sessionStorage` behaviour. - **`0o600` perms on Unix** — verbatim tool output may include secrets, so files stay owner-only. ### Concurrent resume - **No file-level lock.** Two processes resuming the same session both acquire append handles immediately and record new messages with `parent_uuid` pointing at whichever tip they saw at load time. The resulting fork is resolved on the *next* load: `load_session_data` walks the recorded UUID DAG, picks the newest-timestamped leaf, and walks back via `parent_uuid` to the root. Cycles (seen-set) and orphan `parent_uuid` references are chain terminators, so a corrupted file yields a trusted prefix rather than hanging or erroring; duplicate UUIDs resolve last-append-wins. Fork losers stay in the file for audit but are invisible to later resumes. Matches claude-code's `loadMessagesFromJsonlPath` + `--fork-session` semantics. - An in-process per-write `Mutex<SessionManager>` keeps tasks within a single process from interleaving writes; the lock is never held across API streaming or tool execution, so future tasks (telemetry, slash commands) can share the session without waiting for a turn to finish. ### Resume sanitization and crash safety - On load, the transcript runs through a fixed pipeline so a mid-turn crash never produces an API-invalid history: strip trailing `thinking` → drop unresolved assistant `tool_use` and orphan user `tool_result` blocks → drop emptied messages → merge any adjacent same-role survivors → prepend a synthetic user sentinel if the head is now `assistant` → append an assistant sentinel if the tail is a user turn of only tool results. - `load_session_data` reads byte-by-line with warn-skip on non-UTF8 / malformed lines, so a session SIGKILLed mid-`writeln!` (possibly truncated mid-UTF8 codepoint, missing trailing `\n`) still resumes. Interleaved fragments from concurrent large (> `PIPE_BUF`) writes go through the same path. ### Graceful shutdown `shutdown_signal()` awaits the first of SIGINT (portable) / SIGTERM (Unix) / SIGHUP (Unix). All three modes race their main work against this future via `tokio::select!`, so `finish()` runs on `kill`, SSH disconnect, or Ctrl+C in cooked mode. (TUI raw mode consumes SIGINT before it reaches us, but SIGTERM / SIGHUP still arrive.) Mid-stream Ctrl+C in the TUI aborts the streaming future before the assistant message is recorded; the next resume sees a clean user-ending transcript with nothing for sanitization to fix — pinned by a regression test. **Bare-REPL / headless signal exit.** `tokio::io::stdin()` uses an uncancellable blocking thread that keeps the runtime alive until stdin EOFs, so without care a signal-induced exit would hang until the user pressed Enter. After `finish()` has run, we `std::process::exit(0)` to bypass runtime Drop — explicit cleanup already ran, so there's nothing left to lose. Normal EOF / error paths still return through `main` so the exit code reflects the result. ### CLI surface - **`--list` / `-l`** — reads the header + first-prompt line and the last 4 KiB of each file (never parses the full transcript), sorted by file mtime so resumed sessions bubble to the top. Under `--all`, a `Project` column shows the tildified `cwd` so cross-project rows stay disambiguable. Long titles truncate with `...` to fit detected terminal width (`unicode-width`-aware, CJK / emoji billed correctly); piped output and undetectable widths render untruncated so downstream tools can wrap at their own width. - **`--continue` / `-c`** — bare flag resumes the most-recent session; `-c <prefix>` matches by ID prefix with explicit ambiguous-prefix and empty-prefix errors (the latter hints at the bare form). `--all` widens scope; a clap `ArgGroup` enforces that `--all` is only accepted alongside `--list` or `--continue` so `ox --all` no longer silently no-ops. - List rendering extracted to `session::list_view` and resume resolution to `session::resolver`. `main.rs` is excluded from coverage, so both modules now have direct unit tests (~14 and ~12 respectively). ### TUI history rendering Resumed sessions render text, tool calls (`ToolUse` / `ServerToolUse`) paired with their results via a per-load `tool_use_id → label` map (with a `(result)` fallback for orphans), and thinking blocks gated by `show_thinking` (our analogue of claude-code's `verbose` / `isTranscriptMode`). `RedactedThinking` is always dropped (opaque ciphertext). The result matches the live view block-for-block. ### TUI wrap fixes Three pre-existing TUI wrap bugs surfaced while reviewing resumed sessions and are fixed in the same PR: - **Blockquote continuation lines lost their marker.** Long blockquoted paragraphs wrapped with a plain 2-space indent instead of repeating `> `, because the markdown renderer called `wrap_line` without a styled continuation prefix. Now the flattened `indent_stack` (which already stores the correct continuation form — `> ` for blockquotes, spaces for lists) is fed in as the styled prefix. - **Tight list items never wrapped at all.** pulldown-cmark emits tight `Tag::Item` blocks directly as `Text` events with no paragraph wrapper, so the existing `end_paragraph → wrap_last_line` path never ran and long list lines got clipped at the right edge of the terminal. Fixed by wrapping in `TagEnd::Item` before the indent is popped; idempotent on loose items that already wrapped via `end_paragraph`. - **Tool call and tool result labels never wrapped.** `push_tool_call_line` and `push_tool_result_line` emitted the border prefix + icon + label as a single `Line`, so long shell commands (e.g. `cd /Users/... && ls "${XDG_DATA_HOME:-$HOME/.local/share}/ox/sessions/"`) ran off the viewport. Now both wrap with a styled continuation prefix aligned under the label body (`TOOL_RESULT_PREFIX` for calls, `TOOL_OUTPUT_PREFIX` for results). Along the way, the two `tui::wrap::wrap_line` / `wrap_line_styled` entry points were consolidated into a single `wrap_line(line, max_width, continuation_indent, continuation_prefix: Option<&[Span]>)` so there's one wrap path for every consumer. ### Cross-cutting - **First write failure per session is surfaced to the user via `AgentEvent::Error`**, then warn-logs only — a temporary disk hiccup informs the user once instead of flooding the UI. - **`LOCAL_OFFSET` is captured before the tokio runtime starts** and cached in a `OnceLock` (`time`'s `current_local_offset()` is unsound under multi-threaded runtimes on Linux). - Adjacent housekeeping: `rust-toolchain.toml` pinning the stable channel; clippy fixes for Rust 1.95 lints surfaced alongside. ## Changes | Area | Files | Notes | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Schema | `session/entry.rs` | JSONL `Entry` enum (`Header` / `Message` / `Title` / `Summary` / `Unknown`); `version`, UUID + `parent_uuid`; `SessionInfo` / `TitleInfo` / `ExitInfo` listing metadata | | Storage | `session/store.rs`, `session/path.rs` | `SessionStore` / `SessionWriter`; XDG path resolution; project subdirectories (xxh64 hash on overlong paths); `{epoch}-{uuid}.jsonl`; mtime sort; UUID-DAG `load_session_data`; byte-by-line crash-safe reads; legacy-layout migration sweeps | | Lifecycle | `session/manager.rs`, `message.rs` | `start` / `resume` / `record_message` / `finish`; first-prompt title capture (≤ 80 chars); `sanitize_resumed_messages` pipeline; first-failure surfacing; mid-stream-abort regression test; `strip_trailing_thinking` exposed for sanitization reuse | | CLI dispatch | `main.rs`, `session/resolver.rs`, `session/list_view.rs` | `--list` / `--continue` / `--all` with `ArgGroup` coupling; `ResumeMode` + `resolve_session`; `record_session_message` per-write mutex helper; `shutdown_signal()` + `tokio::select!` arms; surface session write errors via `AgentEvent::Error`; list-table renderer with Project column + width-aware truncation | | TUI integration | `tui/app.rs`, `tui/components/chat.rs`, `tui/markdown/render.rs`, `tui/wrap.rs` | Share session manager with agent loop; full-fidelity `load_history` (text, tool calls + paired results, gated thinking); new `ChatEntry::Thinking` variant. Wrap fixes: blockquote markers repeat on wrapped continuations, tight list items wrap, tool call / result labels wrap; `wrap_line` API consolidated | | Client integration | `client/anthropic.rs`, `config/oauth.rs` | `Client::new` accepts session ID for the `x-claude-code-session-id` header; OAuth lock ported to the shared `util::lock::retry_acquire` | | Shared utilities | `util.rs`, `util/lock.rs`, `util/path.rs` | `retry_acquire` advisory-lock helper; `tildify` (`$HOME` → `~/`) shared by `--list` and the TUI status bar | | Build / deps | `Cargo.toml` (workspace + member), `rust-toolchain.toml` | Add `fs4` (oauth lock helper) and `xxhash-rust` (project subdir hash); broaden `time` features for header (de)serialization; add `serde` to `uuid`; pin stable toolchain | | Docs | `docs/guide/sessions.md` (+ guide index), `docs/research/session-persistence.md` (+ research index), `docs/roadmap.md`, `README.md`, `CLAUDE.md` | User guide; design notes (schema, sanitization, fork-friendly DAG, comparison vs. claude-code / codex); roadmap entries (Working Today + Current Focus + Later); crate structure refresh | ## Test plan ### Automated (verified on the current tip) - [x] `cargo fmt --all --check` — clean - [x] `cargo build` — compiles cleanly - [x] `cargo clippy --all-targets -- -D warnings` — zero warnings - [x] `cargo test` — 575 tests pass - [x] `cargo llvm-cov --ignore-filename-regex 'main\.rs'` — 90.94% line / 91.93% function / 91.56% region overall. Session-module floors: - `session/path.rs` — 100.00% - `session/manager.rs` — 99.50% - `tui/components/chat.rs` — 98.63% - `tui/markdown/render.rs` — 98.00% - `tui/wrap.rs` — 98.11% - `session/resolver.rs` — 97.01% - `session/entry.rs` — 96.00% - `session/list_view.rs` — 94.33% - `util/lock.rs` — 93.06% - `session/store.rs` — 93.24% - `util/path.rs` — 91.30% ### Manual smoke tests (re-run on the current tip) - [x] `XDG_DATA_HOME=tmpdir ox --no-tui </dev/null` then `ox -l` — listing reports "No sessions found in this project"; the project subdir is empty (lazy-creation regression check from PR review). - [x] `ox --all` alone and `ox --all -p hi` — clap rejects both with `<--list|--continue [<SESSION_ID>]>` usage hint. - [x] `ox -l` and `ox -la` against constructed sessions — header / mtime sort / message count / title shown correctly; `-la` adds the `Project` column with tildified `~/GitHub/oxide-code`. - [x] `ox -c ""` and `ox -c " "` — both error with `empty session ID prefix` and hint at using bare `ox -c` to resume the latest session. - [x] `ox -c zzzz` (no match) — errors with `no session matching prefix 'zzzz'`. - [x] `ox -c 9` (ambiguous, 2 sessions starting with `9`) — errors with `ambiguous prefix '9' matches 2 sessions: <id>, <id>`. - [x] `ox -c 27 -p hi` — clap rejects (`--continue` and `--prompt` are mutually exclusive). - [x] `ox -l | cat` — full title rendered untruncated when stdout is piped. ### Pinned by automated tests (not separately re-run by hand) These behaviors are exercised end-to-end by unit / integration tests; the test plan items map to specific `cargo test` cases so reviewers can audit the coverage: - `0o600` perms on session files — `session::store::tests::create_sets_user_only_file_permissions_on_unix`. - Title truncation in narrow terminals — `session::list_view::tests::render_sessions_truncates_title_when_term_width_too_narrow` and the `truncate_to_width_*` cluster. - Resume sanitization (mid-tool-turn crash, sentinel injection, orphan tool_result drop, collapse same-role) — 12 tests in `session::manager::tests::resume_*`. - Mid-stream-quit clean transcript — `session::manager::tests::resume_after_mid_stream_abort_yields_clean_user_ending_transcript`. - Concurrent fork resume / UUID DAG selection — `session::store::tests::load_session_data_picks_newest_leaf_*` and the rest of the `load_session_data_*` cluster. - Crash-safe reads (truncated UTF-8, malformed line) — `session::store::tests::load_session_data_warn_skips_*`. - Resumed-history rendering (text, tool calls + paired results, gated thinking, `RedactedThinking` always dropped) — `tui::components::chat::tests::load_history_*`. - Markdown wrap regressions — `tui::markdown::render::tests::blockquote_wraps_with_marker_on_continuations`, `nested_blockquote_wraps_with_nested_markers`, `tight_list_item_wraps_without_repeating_marker`, `blockquote_list_item_wraps_with_blockquote_marker_only`, plus `tui::wrap::tests::continuation_prefix_spans_applied`. - Tool call / result label wrap — `tui::components::chat::tests::push_tool_call_line_wraps_long_label` and `push_tool_result_line_wraps_long_label`. ### End-to-end live scenarios (verified on the current tip) - [x] Fresh session + one prompt (headless `ox -p "..."`) — JSONL created at `sessions/{project}/{epoch}-{uuid}.jsonl` with `0o600` perms; entries are header + title + 2 messages + summary; `message_count: 2`. - [x] SIGTERM / SIGHUP / SIGINT against bare REPL (verified via `expect`) — all three fire the handler arm, run `finish()`, and now exit cleanly (`eof, ok` in ≤ 1 s) thanks to the `std::process::exit(0)` post-`finish()` fix in this PR; previously they hung on the tokio-stdin blocking thread until SIGKILL. JSONL remains intact across all three. - [x] Fresh session + one prompt in bare REPL + SIGTERM during the next `> ` wait — process exits cleanly; JSONL has header + title + 2 messages + `Summary { message_count: 2 }`. - [x] Two terminals concurrent resume — `echo "A" | ox --no-tui -c <id>` and `echo "B" | ox --no-tui -c <id>` run in parallel; both exit 0, both append; the resulting JSONL has 2 `summary` entries and a genuine fork (two user messages sharing a `parent_uuid` at the pre-resume tip, each with its own assistant reply). A third resume picks the newer-timestamped leaf branch and new messages correctly chain from there, with the losing branch preserved in the file but invisible to the API prefix. ### Requires interactive TUI (not run this session) - [x] `ox` (TUI) with a real conversation, then `ox -c` — visually verify the TUI renders prior text + tool call markers + tool results + gated thinking, and that blockquotes / tight lists / tool labels wrap correctly at narrow widths. The rendering path is pinned by `tui::components::chat::tests::load_history_*` (6 tests including one per content block type) and the new wrap regression tests listed above; the remaining gap is the live render / scroll / terminal paint, which needs a human reviewer at a real TTY.
1 parent ca9e0ad commit d4b421d

34 files changed

Lines changed: 5987 additions & 174 deletions

.cspell/words.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ codex
1010
creds
1111
crossterm
1212
deserialize
13+
disambiguable
1314
dtolnay
1415
feff
1516
gitui
@@ -36,8 +37,12 @@ sysname
3637
SYSPROMPT
3738
thiserror
3839
throbber
40+
tildified
41+
tildify
42+
TOCTOU
3943
tokio
4044
tracing
45+
unresumable
4146
venv
4247
xxhash
4348
yazi

CLAUDE.md

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,22 @@ ox # Start an interactive session
3030
├── config.rs # Configuration loading and layered merging
3131
├── config/
3232
│ ├── file.rs # TOML config file discovery, parsing, and merge (user + project)
33-
│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, file locking
33+
│ └── oauth.rs # Claude Code OAuth credentials (macOS Keychain + file), token refresh, directory-based advisory lock
3434
├── main.rs # CLI entry point, agent loop, TUI / REPL / headless dispatch
3535
├── message.rs # Conversation message types
3636
├── prompt.rs # System prompt builder (section assembly)
3737
├── prompt/
3838
│ ├── environment.rs # Runtime environment detection (platform, git, date, marketing name)
3939
│ ├── instructions.rs # Instruction file discovery and loading (CLAUDE.md, AGENTS.md)
4040
│ └── sections.rs # Static prompt section constants (intro, guidance, style)
41+
├── session.rs # Session module root
42+
├── session/
43+
│ ├── entry.rs # JSONL entry types (Header, Message, Title, Summary) and metadata structs
44+
│ ├── list_view.rs # `ox --list` table rendering (writes to any `impl Write`)
45+
│ ├── manager.rs # SessionManager: lifecycle (start, resume, record, finish)
46+
│ ├── path.rs # Filesystem-safe project subdirectory derivation (sanitize_cwd)
47+
│ ├── resolver.rs # CLI `--continue` argument resolution (ResumeMode, resolve_session)
48+
│ └── store.rs # SessionStore / SessionWriter: file I/O, XDG path, listing
4149
├── tool.rs # Tool trait, registry, definitions
4250
├── tool/
4351
│ ├── bash.rs # Shell command execution with timeout
@@ -47,22 +55,26 @@ ox # Start an interactive session
4755
│ ├── read.rs # File reading with line numbers and pagination
4856
│ └── write.rs # File writing with directory creation
4957
├── tui.rs # TUI module root
50-
└── tui/
51-
├── app.rs # Root App struct, tokio::select! event loop, render dispatch
52-
├── component.rs # Component trait and Action enum
53-
├── components.rs # Components module root
54-
├── components/
55-
│ ├── chat.rs # Scrollable chat with markdown, tool styling, thinking display
56-
│ ├── input.rs # Multi-line input area (ratatui-textarea)
57-
│ └── status.rs # Status bar (model, spinner, status, working directory)
58-
├── event.rs # AgentEvent, UserAction, AgentSink trait, ChannelSink, StdioSink
59-
├── markdown.rs # Markdown module root (pulldown-cmark + syntect renderer)
60-
├── markdown/
61-
│ ├── highlight.rs # Syntax highlighting (syntect lazy-loaded SyntaxSet / ThemeSet)
62-
│ └── render.rs # pulldown-cmark event walker, inline / block / list / table rendering
63-
├── terminal.rs # Terminal init / restore, synchronized output, panic hook
64-
├── theme.rs # Catppuccin Mocha palette, style helpers
65-
└── wrap.rs # Word-wrap with continuation indent for styled lines
58+
├── tui/
59+
│ ├── app.rs # Root App struct, tokio::select! event loop, render dispatch
60+
│ ├── component.rs # Component trait and Action enum
61+
│ ├── components.rs # Components module root
62+
│ ├── components/
63+
│ │ ├── chat.rs # Scrollable chat with markdown, tool styling, thinking display
64+
│ │ ├── input.rs # Multi-line input area (ratatui-textarea)
65+
│ │ └── status.rs # Status bar (model, spinner, status, working directory)
66+
│ ├── event.rs # AgentEvent, UserAction, AgentSink trait, ChannelSink, StdioSink
67+
│ ├── markdown.rs # Markdown module root (pulldown-cmark + syntect renderer)
68+
│ ├── markdown/
69+
│ │ ├── highlight.rs # Syntax highlighting (syntect lazy-loaded SyntaxSet / ThemeSet)
70+
│ │ └── render.rs # pulldown-cmark event walker, inline / block / list / table rendering
71+
│ ├── terminal.rs # Terminal init / restore, synchronized output, panic hook
72+
│ ├── theme.rs # Catppuccin Mocha palette, style helpers
73+
│ └── wrap.rs # Word-wrap with continuation indent for styled lines
74+
├── util.rs # Shared utilities module root
75+
└── util/
76+
├── lock.rs # Async retry helper for advisory locks (used by oauth)
77+
└── path.rs # Path display helpers (`tildify`: rewrite $HOME prefix as ~/)
6678
```
6779

6880
## Coding Conventions

Cargo.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ anyhow = "1"
2020
clap = { version = "4", features = ["derive"] }
2121
crossterm = { version = "0.29", features = ["event-stream"] }
2222
dirs = "6"
23+
fs4 = { version = "0.13", features = ["sync"] }
2324
futures = "0.3"
2425
globset = "0.4"
2526
ignore = "0.4"
@@ -44,7 +45,14 @@ syntect = { version = "5", default-features = false, features = [
4445
"regex-onig",
4546
] }
4647
tempfile = "3"
47-
time = { version = "0.3", features = ["local-offset"] }
48+
time = { version = "0.3", features = [
49+
"formatting",
50+
"local-offset",
51+
"macros",
52+
"parsing",
53+
"serde",
54+
"serde-well-known",
55+
] }
4856
toml = "0.8"
4957
tokio = { version = "1", features = [
5058
"io-std",
@@ -63,7 +71,7 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [
6371
"fmt",
6472
] }
6573
unicode-width = "0.2"
66-
uuid = { version = "1", features = ["v4"] }
74+
uuid = { version = "1", features = ["serde", "v4"] }
6775
xxhash-rust = { version = "0.8", features = ["xxh64"] }
6876

6977
[profile.release]

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Early development. What works today:
2121
- System prompt with CLAUDE.md / AGENTS.md injection
2222
- Authentication (API key and Claude Code OAuth)
2323
- TOML config file with layered loading
24+
- Session persistence with JSONL conversation logs, resume, and listing
2425

2526
See [`docs/roadmap.md`](docs/roadmap.md) for current focus and plans.
2627

@@ -38,6 +39,7 @@ ox
3839
| [Quickstart](docs/guide/quickstart.md) | Install, first run, basic usage |
3940
| [Configuration](docs/guide/configuration.md) | API credentials, model selection, environment |
4041
| [Instruction Files](docs/guide/instructions.md) | CLAUDE.md / AGENTS.md setup and discovery rules |
42+
| [Sessions](docs/guide/sessions.md) | Session persistence, listing, and resume |
4143

4244
## Building from Source
4345

crates/oxide-code/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ anyhow.workspace = true
1717
clap.workspace = true
1818
crossterm.workspace = true
1919
dirs.workspace = true
20+
fs4.workspace = true
2021
futures.workspace = true
2122
globset.workspace = true
2223
ignore.workspace = true

crates/oxide-code/src/client/anthropic.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,8 @@ pub struct Client {
233233
}
234234

235235
impl Client {
236-
pub fn new(config: Config) -> Result<Self> {
237-
let session_id = Uuid::new_v4().to_string();
236+
pub fn new(config: Config, session_id: Option<String>) -> Result<Self> {
237+
let session_id = session_id.unwrap_or_else(|| Uuid::new_v4().to_string());
238238
let mut headers = HeaderMap::new();
239239

240240
let mut betas = vec![

crates/oxide-code/src/config/oauth.rs

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use std::path::{Path, PathBuf};
22
use std::time::Duration;
33

4-
use anyhow::{Context, Result, bail};
4+
use anyhow::{Context, Result, anyhow, bail};
55
use serde::{Deserialize, Serialize};
66
use tracing::warn;
77

8+
use crate::util::lock;
9+
810
const OAUTH_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token";
911
const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
1012
const OAUTH_SCOPES: &[&str] = &[
@@ -17,8 +19,9 @@ const OAUTH_SCOPES: &[&str] = &[
1719
const TOKEN_EXPIRY_BUFFER_MS: u64 = 5 * 60 * 1000;
1820
const REFRESH_TIMEOUT: Duration = Duration::from_secs(15);
1921

20-
const LOCK_MAX_RETRIES: u32 = 5;
21-
const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(1000);
22+
/// Directory mtime threshold above which an existing lock is treated
23+
/// as stale and removed before re-attempting acquisition. Guards
24+
/// against a peer that crashed without cleaning up its lock dir.
2225
const LOCK_STALE_THRESHOLD: Duration = Duration::from_secs(30);
2326

2427
#[cfg(target_os = "macos")]
@@ -306,36 +309,36 @@ impl Drop for LockGuard {
306309
}
307310
}
308311

309-
/// Acquire a directory-based lock, compatible with `proper-lockfile` (used by
310-
/// Claude Code). Retries with fixed interval and breaks stale locks.
312+
/// Acquire a directory-based lock, compatible with `proper-lockfile`
313+
/// (used by Claude Code). Retries contended locks via the shared
314+
/// [`lock::retry_acquire`] helper and breaks stale lock directories
315+
/// on each attempt.
311316
async fn acquire_lock(path: &Path) -> Result<LockGuard> {
312-
for attempt in 0..=LOCK_MAX_RETRIES {
313-
match std::fs::create_dir(path) {
314-
Ok(()) => {
315-
return Ok(LockGuard {
316-
path: path.to_owned(),
317-
});
318-
}
317+
lock::retry_acquire(
318+
|| match std::fs::create_dir(path) {
319+
Ok(()) => Ok(Some(LockGuard {
320+
path: path.to_owned(),
321+
})),
319322
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
320323
if is_stale_lock(path) {
321324
_ = std::fs::remove_dir_all(path);
322-
continue;
323325
}
324-
if attempt == LOCK_MAX_RETRIES {
325-
bail!(
326-
"failed to acquire credentials lock after {LOCK_MAX_RETRIES} retries \
327-
— another process may be refreshing"
328-
);
329-
}
330-
tokio::time::sleep(LOCK_RETRY_INTERVAL).await;
331-
}
332-
Err(e) => {
333-
return Err(e)
334-
.with_context(|| format!("failed to create lock at {}", path.display()));
326+
Ok(None)
335327
}
336-
}
337-
}
338-
unreachable!()
328+
Err(e) => Err(anyhow::Error::new(e)
329+
.context(format!("failed to create lock at {}", path.display()))),
330+
},
331+
lock::MAX_RETRIES,
332+
lock::RETRY_INTERVAL,
333+
|| {
334+
anyhow!(
335+
"failed to acquire credentials lock after {} retries \
336+
— another process may be refreshing",
337+
lock::MAX_RETRIES,
338+
)
339+
},
340+
)
341+
.await
339342
}
340343

341344
fn is_stale_lock(path: &Path) -> bool {

0 commit comments

Comments
 (0)