fix: per-song cloud sync silently disabled by a losing try_lock - #393
Conversation
spawn_history_collector snapshotted the sync token via try_lock into a non-mut binding. Startup pre-work pushed that read into the live render loop, and tokio's fair mutex hands a freed permit straight to a queued waiter, so the try_lock intermittently lost and pinned the token to None for the whole process, dumping every session onto the exit sync. - take the lock properly at startup and refresh the token each tick - log whether cloud sync is enabled, and on token change - move the listens-file IO into spawn_blocking - advance the sync cursor by max() rather than last() - restore the terminal before the exit network calls - bound each exit call separately (2s history, 1s now-playing clear), keeping the clear strictly last so the upsert cannot undo it
📝 WalkthroughWalkthroughCloud history synchronization now tracks network tasks, handles shutdown through a returned collector handle, and persists append-position cursors with legacy timestamp compatibility. Application exit shuts down collection, restores the terminal, then runs bounded history sync and now-playing cleanup. ChangesHistory synchronization and graceful exit
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant start_ui
participant HistoryCollectorHandle
participant ratatui
participant sync_history_to_cloud
participant clear_now_playing_from_cloud
start_ui->>HistoryCollectorHandle: shutdown collector
start_ui->>ratatui: restore terminal state
start_ui->>sync_history_to_cloud: run with 2-second timeout
sync_history_to_cloud-->>start_ui: return or timeout
start_ui->>clear_now_playing_from_cloud: run with 1-second timeout
clear_now_playing_from_cloud-->>start_ui: return or timeout
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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 `@src/infra/history.rs`:
- Around line 2261-2271: The cursor fallback in the `spawn_blocking` closure
still uses non-test `unwrap()` calls. Create the epoch timestamp as a fallible
value, propagate errors with `?`, and reuse that value for both the read and
parse fallback branches while preserving the existing `load_listens` result
flow.
- Around line 2291-2295: Update the sync cursor logic around new_listens and
last_ended_at so concurrent out-of-order appends cannot be skipped by the
ended_at > last_synced_at filter. Replace the timestamp-only advancement with an
append sequence, or use a replay overlap backed by server-side idempotency, and
add coverage for an out-of-order append occurring during an in-flight sync.
In `@src/tui/runner.rs`:
- Around line 1378-1399: Update the exit flow in start_ui around the
history_sync and clear_now_playing calls to signal spawn_history_collector
shutdown and await all outstanding now-playing work before invoking
clear_now_playing_from_cloud. Ensure the final clear occurs only after the
collector and its pause-triggered uploads have stopped, while preserving the
existing independent timeouts and warning handling.
🪄 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: 1ce42960-a36d-49bf-a597-319c6f23d7c6
📒 Files selected for processing (2)
src/infra/history.rssrc/tui/runner.rs
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/tui/runner.rs (1)
1362-1405: 🩺 Stability & Availability | 🔵 TrivialResidual risk: a push already in flight when shutdown aborts can still land at the server after the final clear.
Aborting the collector's
JoinSetstops further client-side processing, but if a push request's bytes were already flushed to the socket before the abort took effect, the server can still apply that push after this clear runs (these are upserts with no ordering guarantee). Not fixable cheaply client-side; would need a sequence number or timestamp on the now-playing payload that the server uses to reject stale updates.🤖 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 `@src/tui/runner.rs` around lines 1362 - 1405, Add ordering metadata to the now-playing payload and update the server-side upsert handling so stale updates are rejected based on sequence number or timestamp. Ensure the final clear cannot be overwritten by an already in-flight push, while preserving current collector shutdown and exit-clear flow.src/infra/history.rs (1)
354-372: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy liftNo test for the shutdown timeout/abort path.
This is the exact mechanism that fixes the previously flagged now-playing/exit-clear race. A regression test would catch future changes that break the "abort collector -> JoinSet drop -> child tasks cancelled" chain, e.g. by adding a slow fake task to the collector's
JoinSetand assertingshutdown()returns within ~1s and no further completion is observed.🤖 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 `@src/infra/history.rs` around lines 354 - 372, Add a regression test covering HistoryCollectorHandle::shutdown when the collector task remains blocked: include a slow fake child task owned by its JoinSet, invoke shutdown, assert it returns within roughly one second, and verify no child completion occurs afterward. Ensure the test exercises the timeout, task abort, and JoinSet cancellation chain without changing normal shutdown behavior.
🤖 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.
Nitpick comments:
In `@src/infra/history.rs`:
- Around line 354-372: Add a regression test covering
HistoryCollectorHandle::shutdown when the collector task remains blocked:
include a slow fake child task owned by its JoinSet, invoke shutdown, assert it
returns within roughly one second, and verify no child completion occurs
afterward. Ensure the test exercises the timeout, task abort, and JoinSet
cancellation chain without changing normal shutdown behavior.
In `@src/tui/runner.rs`:
- Around line 1362-1405: Add ordering metadata to the now-playing payload and
update the server-side upsert handling so stale updates are rejected based on
sequence number or timestamp. Ensure the final clear cannot be overwritten by an
already in-flight push, while preserving current collector shutdown and
exit-clear flow.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: c8d91685-4c89-4cbd-9074-9fa5a5b6b197
📒 Files selected for processing (3)
src/infra/history.rssrc/runtime.rssrc/tui/runner.rs
Summary
Per-song listening-history sync to spotatui.com was silently never firing, so every session's records were uploaded in one batch during the awaited exit sync, making quit slow.
Root cause:
spawn_history_collectorsnapshotteduser_config.behavior.sync_tokenonce viaapp.try_lock()into a non-mutbinding. Startup pre-work added later (recap check +spawn_blocking(load_listens)) pushed that read into the live render loop, and tokio's fair mutex hands a freed permit straight to a queued waiter, so thetry_lockintermittently lost and pinned the token toNonefor the whole process. The exit path reads the same field with a reallock().await, which is why exit sync worked and per-song did not.Six changes across
src/infra/history.rsandsrc/tui/runner.rs:try_lockspawn_blocking, keeping ever-growing blocking IO off a runtime workermax()rather thanlast(), so an out-of-orderlistens.jsonlcannot regress the cursor and re-POST synced recordsOrdering note: the now-playing clear stays strictly the last network call. The collector keeps ticking after
pause_native_playback_before_exitflipsis_playingand re-pushes now-playing; the website route is anonConflictDoUpdateupsert, so a clear that landed first would be undone and leave a stale "paused" card until the 5-minute online threshold expires. Running the two undertokio::join!was tried and reverted for this reason. Separate timeout budgets also mean a slow history upload cannot starve the clear.Testing
cargo fmt --all --check- cleancargo clippy --no-default-features --features telemetry -- -D warnings- cleancargo test --no-default-features --features telemetry- 476 passed, 0 failedManually smoke tested on the full
cargo runbuild rather than slim, sincepause_native_playback_before_exitis#[cfg(feature = "streaming")]. Checked:successfully synchronized listening history to cloud (1 tracks)lines appear mid-session rather than one large batch at exitAdditional notes
None.
Summary by CodeRabbit