Skip to content

fix(ai-hist): survive a truncated .sync-state.json instead of wedging sync - #44

Merged
khaliqgant merged 2 commits into
mainfrom
fix/sync-state-corruption
Aug 3, 2026
Merged

fix(ai-hist): survive a truncated .sync-state.json instead of wedging sync#44
khaliqgant merged 2 commits into
mainfrom
fix/sync-state-corruption

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 2, 2026

Copy link
Copy Markdown
Member

Problem

ai-hist sync had been failing on every run for three days with:

Error: EOF while parsing a value at line 1 column 0

~/.local/share/ai-hist/.sync-state.json was 0 bytes. serde_json::from_str("") produces exactly that message, and load_sync_state propagated it — aborting sync_basic before any work happened.

How it got truncated

The disk filled up. From /tmp/ai-hist-sync.err:

Error code 13: Insertion failed because database is full
Error code 261: Another process is recovering a WAL mode database file
Error: Too many open files in system (os error 23)
Error: No space left on device (os error 28)
   ↓ then, 3463 times:
Error: EOF while parsing a value at line 1 column 0

save_sync_state used a plain fs::write, which truncates first, then writes. It was interrupted by ENOSPC mid-write, leaving an empty file — and the loader then treated that empty file as fatal on every subsequent run.

This also silently killed the background com.ai-hist.sync launchd agent, which runs every 60s: it exited 1 each time for three days. Recovering required knowing to delete the file by hand.

Fix

Sync state is an optimization, not a source of truth. Losing it should cost a re-scan, never stop sync.

  • load_sync_state falls back to empty state with a warning on an empty, corrupt, or unreadable file. Re-scanning is safe: every insert path upserts (ON CONFLICT ... DO UPDATE), so no rows are duplicated.
  • save_sync_state writes to a temp file then fs::renames it into place. An interrupted or out-of-space write now leaves the previous state intact rather than a truncated file.
  • Both paths carry .with_context() so a future failure names the file instead of surfacing a bare serde error.

Testing

New test load_sync_state_recovers_from_empty_or_corrupt_file covers missing, empty, corrupt, and valid-round-trip states, and asserts no .tmp file is left behind. It fails against the old code. Full workspace suite passes (58 tests).

Verified end-to-end against a scratch DB with a deliberately emptied state file:

# old binary
$ AI_HIST_DB=$SCRATCH/test.db ai-hist-rust-bin.bak sync
Error: EOF while parsing a value at line 1 column 0

# new binary
$ AI_HIST_DB=$SCRATCH/test.db ai-hist-rust-bin sync
ai-hist: .../.sync-state.json is corrupt (EOF while parsing a value at line 1 column 0); starting from empty sync state
  [claude] syncing 147624087 new bytes...

Note

This makes sync resilient to the symptom. The underlying trigger was a full disk, which is an operational issue, not addressed here.

🤖 Generated with Claude Code

Review in cubic

… sync

When the disk filled up, save_sync_state's plain fs::write truncated
.sync-state.json and then failed with ENOSPC, leaving a 0-byte file.
load_sync_state fed that to serde_json and propagated the error, so every
later run aborted with "EOF while parsing a value at line 1 column 0" --
before doing any work. The launchd sync agent failed this way every 60s
for three days; recovery required knowing to delete the file by hand.

Sync state is an optimization, not a source of truth, so losing it should
cost a re-scan rather than stop sync entirely:

- load_sync_state falls back to empty state with a warning on an empty,
  corrupt, or unreadable file. Re-scanning is safe because every insert
  path upserts (ON CONFLICT ... DO UPDATE), so no rows are duplicated.
- save_sync_state writes a temp file and renames it into place, so an
  interrupted or out-of-space write leaves the previous state intact
  instead of a truncated one.

Both paths carry context so a future failure names the file rather than
surfacing a bare serde error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 2, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a711d8e7-22f7-4156-8dbc-e369a8f4238a

📥 Commits

Reviewing files that changed from the base of the PR and between 5c70f07 and 56b9aeb.

📒 Files selected for processing (1)
  • crates/ai-hist/src/lib.rs

📝 Walkthrough

Walkthrough

Sync-state loading now treats unreadable, empty, and corrupt files as empty state with diagnostics. Saving uses unique temporary files and atomic renames. Tests cover recovery, round-tripping, cleanup, and concurrent writers.

Changes

Sync-state persistence

Layer / File(s) Summary
Resilient state loading and atomic saving
crates/ai-hist/src/lib.rs
load_sync_state recovers from read and JSON errors. save_sync_state writes to uniquely named temporary files, atomically renames them, and cleans up failed temporary writes.
Sync-state behavior coverage
crates/ai-hist/src/lib.rs
Tests cover missing, empty, corrupt, and valid state files. Tests also verify cleanup and concurrent saves with complete published state.

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

Sequence Diagram(s)

sequenceDiagram
  participant Writer as save_sync_state
  participant TempFile as unique temporary file
  participant StateFile as sync-state file
  Writer->>TempFile: write serialized state
  TempFile->>StateFile: atomically rename
  StateFile-->>Writer: publish complete state
Loading

Poem

I’m a rabbit guarding state tonight,
Temporary files keep writes airtight.
Corrupt files become empty ground,
Atomic renames keep data sound.
Two writers leave one whole file found.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix for truncated sync state files that caused synchronization failures.
Description check ✅ Passed The description directly explains the sync failure, the recovery behavior, the atomic save, and the added tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sync-state-corruption

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.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread crates/ai-hist/src/lib.rs Outdated
Comment on lines +2918 to +2922
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, serde_json::to_string_pretty(state)? + "\n")
.with_context(|| format!("writing sync state to {}", tmp_path.display()))?;
fs::rename(&tmp_path, path)
.with_context(|| format!("replacing sync state at {}", path.display()))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Two syncs running at once can make one of them fail outright

The saved progress file is staged through a single fixed side file that every run shares (path.with_extension("json.tmp") at crates/ai-hist/src/lib.rs:2918-2922), so when the scheduled run and a manual run overlap, one of them finds the side file already taken away and stops with an error.

Impact: Overlapping syncs can abort with a confusing "replacing sync state" failure, and the saved progress can end up as a mix of two runs' data.

Race between the shared temp path and rename

sync_basic (crates/ai-hist/src/lib.rs:1667-1700) is invoked both by the 60s launchd agent and by manual ai-hist sync. Both processes compute the same tmp_path (.sync-state.json.tmp). Sequence: A writes tmp, B writes tmp (truncating/interleaving A's bytes), A renames tmp→state, B's fs::rename then fails with ENOENT and, being propagated with ?, aborts the whole sync command — the exact class of failure this PR set out to remove. Even if both renames succeed, the renamed content may be a blend of the two writers.

Using a per-process unique temp name (e.g. including std::process::id() or a random suffix) makes each writer independent; the last rename simply wins.

Suggested change
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, serde_json::to_string_pretty(state)? + "\n")
.with_context(|| format!("writing sync state to {}", tmp_path.display()))?;
fs::rename(&tmp_path, path)
.with_context(|| format!("replacing sync state at {}", path.display()))?;
let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id()));
fs::write(&tmp_path, serde_json::to_string_pretty(state)? + "\n")
.with_context(|| format!("writing sync state to {}", tmp_path.display()))?;
fs::rename(&tmp_path, path)
.with_context(|| format!("replacing sync state at {}", path.display()))?;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5c70f07902

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/ai-hist/src/lib.rs Outdated
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(state)? + "\n")?;
let tmp_path = path.with_extension("json.tmp");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Give each sync writer a distinct temporary file

When two syncs overlap—for example, a minute-based cron run is still processing while the next run or a manual ai-hist sync starts—both writers use the same .sync-state.json.tmp. After both calls to fs::write succeed, the first rename removes that shared source path, so the second rename fails with No such file or directory and reports the whole sync as failed after its database work has already completed. Use a per-invocation temporary file or serialize state updates before renaming.

Useful? React with 👍 / 👎.

@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 `@crates/ai-hist/src/lib.rs`:
- Around line 2918-2922: The sync-state save logic around the temporary path
must avoid a shared filename during concurrent saves. Replace the deterministic
with_extension temporary path with a uniquely named file created exclusively in
the destination directory, ensure cleanup occurs when writing or renaming fails,
and add a test exercising concurrent saves without lost or missing state.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 88be77b7-376e-491b-872a-1b31f362afc0

📥 Commits

Reviewing files that changed from the base of the PR and between e83902b and 5c70f07.

📒 Files selected for processing (1)
  • crates/ai-hist/src/lib.rs

Comment thread crates/ai-hist/src/lib.rs Outdated

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 1 file

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/ai-hist/src/lib.rs Outdated
Review feedback: the temp path was derived from the destination alone, so
every writer shared `.sync-state.json.tmp`. sync_basic runs from the CLI,
from watch_loop, and from the in-process napi binding, so saves overlap in
practice. Two writers would open the same temp path, interleave their
writes, and the slower rename would then fail with ENOENT on a path the
faster writer already moved -- aborting a sync after its database work had
already completed, which is the class of failure this change exists to
prevent.

Name the temp file per writer (pid + process-local counter) so each save is
independent and the last rename simply wins, and remove the temp file when
a save fails so a failure doesn't leave one behind.

concurrent_saves_never_publish_a_torn_state_file drives 8 concurrent savers
with different payload sizes and asserts the published file is one writer's
state, intact. It fails reliably against the shared-path version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.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