Skip to content

fix(core): recover orphaned downloads on startup - #62

Merged
mpiton merged 2 commits into
mainfrom
fix/issue-57-orphaned-downloads-recovery
Apr 14, 2026
Merged

fix(core): recover orphaned downloads on startup#62
mpiton merged 2 commits into
mainfrom
fix/issue-57-orphaned-downloads-recovery

Conversation

@mpiton

@mpiton mpiton commented Apr 14, 2026

Copy link
Copy Markdown
Owner

Summary

• Add startup_recovery service that transitions orphaned downloads (Downloading/Waiting/Checking/Extracting) to Error on app boot
• Re-schedule Queued/Retry downloads via on_slot_freed() after QueueManager starts
• 7 unit tests covering all orphan states and non-orphan preservation

Fixes #57

Type

fix


Summary by cubic

On startup, the app marks downloads left in Downloading/Waiting/Checking/Extracting as Error so users can retry, and automatically re-schedules any Queued or Retry downloads once QueueManager starts. Fixes #57.

Written for commit c813119. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Improved startup recovery for downloads interrupted by an app restart: items left in intermediate states (e.g., downloading, checking, extracting) are now transitioned to Error to avoid stalled entries.
    • Persisted queued/retry downloads are automatically re-scheduled on startup so interrupted downloads resume without manual intervention.

On app restart, downloads stuck in Downloading/Waiting/Checking/Extracting
are transitioned to Error so the user can retry. Queued/Retry downloads
are re-scheduled automatically via on_slot_freed().
@github-actions github-actions Bot added documentation Improvements or additions to documentation rust labels Apr 14, 2026
@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

On startup, the app reconciles persisted downloads with missing in-memory tasks: downloads in Downloading, Waiting, Checking, or Extracting are set to Error, and persisted Queued/Retry downloads are re-scheduled via the queue manager.

Changes

Cohort / File(s) Summary
Changelog
CHANGELOG.md
Added entry documenting startup recovery for orphaned downloads in intermediate states.
Service Module Setup
src-tauri/src/application/services/mod.rs
Exported new startup_recovery module.
Startup Recovery Implementation
src-tauri/src/application/services/startup_recovery.rs
New module with pub fn recover_orphaned_downloads(&dyn DownloadRepository) -> Result<usize, DomainError>: finds downloads in orphan states (Downloading, Waiting, Checking, Extracting), marks them Error with message "Interrupted: app restarted", persists changes; includes unit tests covering scenarios.
Application Initialization
src-tauri/src/lib.rs
Calls recover_orphaned_downloads(...) during boot and logs result; after starting queue manager listening, spawns an async task to call QueueManager::on_slot_freed() to re-schedule persisted Queued/Retry downloads, logging warnings on failures.

Sequence Diagram

sequenceDiagram
    participant App as App Startup
    participant Repo as Download Repository
    participant Recovery as Startup Recovery Service
    participant QM as Queue Manager
    participant Engine as Download Engine

    App->>Recovery: recover_orphaned_downloads(repo)
    Recovery->>Repo: query downloads in Orphan States\n(Downloading, Waiting, Checking, Extracting)
    Repo-->>Recovery: list of orphaned downloads
    loop per download
        Recovery->>Recovery: transition to Error ("Interrupted: app restarted")
        Recovery->>Repo: save(updated_download)
        Repo-->>Recovery: persisted
    end
    Recovery-->>App: return count

    App->>QM: queue_manager.clone().start_listening()
    QM-->>App: listening started

    App->>App: tokio::spawn async task
    activate App
    App->>Repo: query downloads in Queued/Retry
    Repo-->>App: queued/retry list
    App->>QM: on_slot_freed().await (trigger re-scheduling)
    QM->>Engine: schedule downloads onto engine slots
    Engine-->>QM: ack / start tasks
    deactivate App
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped in at dawn to mend the mess,
Found downloads stuck in tangled stress,
I nudged them “Interrupted” to wake,
Queued friends hopped back for task and take,
Now logs sing tidy, and users press! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(core): recover orphaned downloads on startup' accurately and concisely describes the main change: implementing orphaned download recovery logic at application startup.
Linked Issues check ✅ Passed The pull request successfully addresses all coding requirements from issue #57: orphaned downloads in Downloading/Waiting/Checking/Extracting states are transitioned to Error, Queued/Retry downloads are re-scheduled via on_slot_freed(), and unit tests validate the state transitions.
Out of Scope Changes check ✅ Passed All changes are directly related to the linked issue #57: startup recovery service, queue manager integration, and unit tests all address the orphaned downloads problem.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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/issue-57-orphaned-downloads-recovery

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Apr 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a startup_recovery service that marks orphaned active downloads (Downloading/Waiting/Checking/Extracting) as Error on app boot, and re-schedules surviving Queued/Retry downloads by calling on_slot_freed() once the QueueManager is initialized. The ORPHAN_STATES constant exactly matches the states accepted by Download::fail(), the recovery runs before any event subscribers are wired (avoiding spurious events), and active_count is correctly 0 at startup because all orphans are cleared before QueueManager is created.

Confidence Score: 5/5

  • Safe to merge — no runtime defects identified; only a minor test coverage gap remains.
  • All findings are P2 (style/test coverage). The recovery logic is correct: ORPHAN_STATES aligns exactly with fail()'s accepted states, active_count starts at 0 after orphans are cleared, startup ordering prevents race conditions with event bridges, and the QueueManager lock ensures idempotent scheduling. The only gap is that the combined mixed-state test omits Extracting and Retry fixtures.
  • src-tauri/src/application/services/startup_recovery.rs — mixed-state test fixture is incomplete

Important Files Changed

Filename Overview
src-tauri/src/application/services/startup_recovery.rs New service correctly transitions all 4 orphan states to Error via the domain state machine; ORPHAN_STATES exactly matches fail()'s accepted states, so no invalid-transition errors at runtime. Minor test coverage gap: the mixed-state test omits Extracting and Retry from its fixture.
src-tauri/src/lib.rs Recovery is correctly placed before QueueManager construction so active_count starts at 0 with no phantom active slots. Event bridges are connected after tokio::spawn(on_slot_freed()), but since setup runs synchronously the spawned task doesn't execute until setup returns — no ordering race. Startup on_slot_freed() correctly bypasses backoff for Retry-state survivors (intentional UX).
src-tauri/src/application/services/mod.rs Trivial addition of pub mod startup_recovery; — correct.
CHANGELOG.md Changelog entry accurately describes the fix and references issue #57.

Sequence Diagram

sequenceDiagram
    participant Setup as lib.rs setup()
    participant Recovery as startup_recovery
    participant DB as SQLite (DownloadRepo)
    participant QM as QueueManager
    participant Engine as DownloadEngine

    Setup->>Recovery: recover_orphaned_downloads(repo)
    Recovery->>DB: find_by_state(Downloading/Waiting/Checking/Extracting)
    DB-->>Recovery: orphaned downloads
    Recovery->>Recovery: download.fail("Interrupted: app restarted")
    Recovery->>DB: "save(download) — state = Error"
    Recovery-->>Setup: Ok(n recovered)

    Setup->>QM: QueueManager::new(repo, engine, bus, 4)
    Setup->>QM: start_listening()
    Note over QM: event loop running (active_count=0)

    Setup->>Setup: spawn_tauri_event_bridge / spawn_notification_bridge / etc.

    Setup->>QM: tokio::spawn(on_slot_freed())
    Note over QM: runs after setup() returns
    QM->>DB: find_by_state(Queued) + find_by_state(Retry)
    DB-->>QM: surviving downloads
    QM->>QM: download.start() — Queued/Retry → Downloading
    QM->>DB: save(download)
    QM->>Engine: engine.start(download)
    QM->>QM: "active_count += 1"
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(core): recover orphaned downloads on..." | Re-trigger Greptile

Comment on lines +211 to +231
#[test]
fn test_recover_mixed_states_only_transitions_orphans() {
let repo = InMemoryRepo::new(vec![
make_downloading(1),
make_completed(2),
make_waiting(3),
make_paused(4),
make_checking(5),
make_download(6), // Queued
]);

let count = recover_orphaned_downloads(&repo).expect("recovery");

assert_eq!(count, 3); // 1 (Downloading), 3 (Waiting), 5 (Checking)
assert_eq!(repo.get(1).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(2).unwrap().state(), DownloadState::Completed);
assert_eq!(repo.get(3).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(4).unwrap().state(), DownloadState::Paused);
assert_eq!(repo.get(5).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(6).unwrap().state(), DownloadState::Queued);
}

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 Mixed-state test missing Extracting and Retry fixtures

test_recover_mixed_states_only_transitions_orphans covers only 3 of the 4 ORPHAN_STATES in combination, so if Extracting were accidentally removed from ORPHAN_STATES the count assertion (assert_eq!(count, 3)) would still pass. Likewise, test_recover_ignores_completed_paused_error_queued never puts a download in Retry state, so it cannot detect an accidental inclusion of Retry in ORPHAN_STATES.

Adding both cases to the mixed-state fixture makes it a true regression net for the complete set:

Suggested change
#[test]
fn test_recover_mixed_states_only_transitions_orphans() {
let repo = InMemoryRepo::new(vec![
make_downloading(1),
make_completed(2),
make_waiting(3),
make_paused(4),
make_checking(5),
make_download(6), // Queued
]);
let count = recover_orphaned_downloads(&repo).expect("recovery");
assert_eq!(count, 3); // 1 (Downloading), 3 (Waiting), 5 (Checking)
assert_eq!(repo.get(1).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(2).unwrap().state(), DownloadState::Completed);
assert_eq!(repo.get(3).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(4).unwrap().state(), DownloadState::Paused);
assert_eq!(repo.get(5).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(6).unwrap().state(), DownloadState::Queued);
}
fn test_recover_mixed_states_only_transitions_orphans() {
let repo = InMemoryRepo::new(vec![
make_downloading(1),
make_completed(2),
make_waiting(3),
make_paused(4),
make_checking(5),
make_download(6), // Queued
make_extracting(7), // Extracting — 4th orphan state
{
let mut d = make_downloading(8);
d.fail("err".into()).expect("Downloading → Error");
d.retry().expect("Error → Retry"); // Retry — must be preserved
d
},
]);
let count = recover_orphaned_downloads(&repo).expect("recovery");
assert_eq!(count, 4); // 1 (Downloading), 3 (Waiting), 5 (Checking), 7 (Extracting)
assert_eq!(repo.get(1).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(2).unwrap().state(), DownloadState::Completed);
assert_eq!(repo.get(3).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(4).unwrap().state(), DownloadState::Paused);
assert_eq!(repo.get(5).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(6).unwrap().state(), DownloadState::Queued);
assert_eq!(repo.get(7).unwrap().state(), DownloadState::Error);
assert_eq!(repo.get(8).unwrap().state(), DownloadState::Retry); // must NOT be orphaned
}
<a href="https://app.greptile.com/ide/claude-code?prompt=This%20is%20a%20comment%20left%20during%20a%20code%20review.%0APath%3A%20src-tauri%2Fsrc%2Fapplication%2Fservices%2Fstartup_recovery.rs%0ALine%3A%20211-231%0A%0AComment%3A%0A**Mixed-state%20test%20missing%20%60Extracting%60%20and%20%60Retry%60%20fixtures**%0A%0A%60test_recover_mixed_states_only_transitions_orphans%60%20covers%20only%203%20of%20the%204%20%60ORPHAN_STATES%60%20in%20combination%2C%20so%20if%20%60Extracting%60%20were%20accidentally%20removed%20from%20%60ORPHAN_STATES%60%20the%20count%20assertion%20%28%60assert_eq!%28count%2C%203%29%60%29%20would%20still%20pass.%20Likewise%2C%20%60test_recover_ignores_completed_paused_error_queued%60%20never%20puts%20a%20download%20in%20%60Retry%60%20state%2C%20so%20it%20cannot%20detect%20an%20accidental%20inclusion%20of%20%60Retry%60%20in%20%60ORPHAN_STATES%60.%0A%0AAdding%20both%20cases%20to%20the%20mixed-state%20fixture%20makes%20it%20a%20true%20regression%20net%20for%20the%20complete%20set%3A%0A%0A%60%60%60suggestion%0A%20%20%20%20fn%20test_recover_mixed_states_only_transitions_orphans%28%29%20%7B%0A%20%20%20%20%20%20%20%20let%20repo%20%3D%20InMemoryRepo%3A%3Anew%28vec!%5B%0A%20%20%20%20%20%20%20%20%20%20%20%20make_downloading%281%29%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20make_completed%282%29%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20make_waiting%283%29%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20make_paused%284%29%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20make_checking%285%29%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20make_download%286%29%2C%20%20%20%20%2F%2F%20Queued%0A%20%20%20%20%20%20%20%20%20%20%20%20make_extracting%287%29%2C%20%20%2F%2F%20Extracting%20%E2%80%94%204th%20orphan%20state%0A%20%20%20%20%20%20%20%20%20%20%20%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20let%20mut%20d%20%3D%20make_downloading%288%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20d.fail%28%22err%22.into%28%29%29.expect%28%22Downloading%20%E2%86%92%20Error%22%29%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20d.retry%28%29.expect%28%22Error%20%E2%86%92%20Retry%22%29%3B%20%20%2F%2F%20Retry%20%E2%80%94%20must%20be%20preserved%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20d%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%2C%0A%20%20%20%20%20%20%20%20%5D%29%3B%0A%0A%20%20%20%20%20%20%20%20let%20count%20%3D%20recover_orphaned_downloads%28%26repo%29.expect%28%22recovery%22%29%3B%0A%0A%20%20%20%20%20%20%20%20assert_eq!%28count%2C%204%29%3B%20%2F%2F%201%20%28Downloading%29%2C%203%20%28Waiting%29%2C%205%20%28Checking%29%2C%207%20%28Extracting%29%0A%20%20%20%20%20%20%20%20assert_eq!%28repo.get%281%29.unwrap%28%29.state%28%29%2C%20DownloadState%3A%3AError%29%3B%0A%20%20%20%20%20%20%20%20assert_eq!%28repo.get%282%29.unwrap%28%29.state%28%29%2C%20DownloadState%3A%3ACompleted%29%3B%0A%20%20%20%20%20%20%20%20assert_eq!%28repo.get%283%29.unwrap%28%29.state%28%29%2C%20DownloadState%3A%3AError%29%3B%0A%20%20%20%20%20%20%20%20assert_eq!%28repo.get%284%29.unwrap%28%29.state%28%29%2C%20DownloadState%3A%3APaused%29%3B%0A%20%20%20%20%20%20%20%20assert_eq!%28repo.get%285%29.unwrap%28%29.state%28%29%2C%20DownloadState%3A%3AError%29%3B%0A%20%20%20%20%20%20%20%20assert_eq!%28repo.get%286%29.unwrap%28%29.state%28%29%2C%20DownloadState%3A%3AQueued%29%3B%0A%20%20%20%20%20%20%20%20assert_eq!%28repo.get%287%29.unwrap%28%29.state%28%29%2C%20DownloadState%3A%3AError%29%3B%0A%20%20%20%20%20%20%20%20assert_eq!%28repo.get%288%29.unwrap%28%29.state%28%29%2C%20DownloadState%3A%3ARetry%29%3B%20%2F%2F%20must%20NOT%20be%20orphaned%0A%20%20%20%20%7D%0A%0AHow%20can%20I%20resolve%20this%3F%20If%20you%20propose%20a%20fix%2C%20please%20make%20it%20concise.&repo=mpiton%2Fvortex"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaudeDark.svg?v=2"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=2"><img alt="Fix in Claude Code" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixInClaude.svg?v=2" height="20"></picture></a>

@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.

No issues found across 4 files

@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.

🧹 Nitpick comments (1)
src-tauri/src/application/services/startup_recovery.rs (1)

13-20: Avoid duplicating orphan-state definitions across modules.

Lines 15-20 mirror the transition gate in Download::fail(). If one side changes later, startup recovery can silently drift. Consider moving this state list to a shared domain helper/constant and reusing it here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src-tauri/src/application/services/startup_recovery.rs` around lines 13 - 20,
The ORPHAN_STATES constant duplicates the same state list used as the transition
gate in Download::fail(), which risks drifting; extract the array into a single
shared constant or helper (e.g., a pub const or function in a common domain
module like download::shared or domain::download_states) and replace the
ORPHAN_STATES definition with a reference to that shared symbol, updating
startup_recovery.rs to use the shared constant and ensuring Download::fail()
also references the same shared symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src-tauri/src/application/services/startup_recovery.rs`:
- Around line 13-20: The ORPHAN_STATES constant duplicates the same state list
used as the transition gate in Download::fail(), which risks drifting; extract
the array into a single shared constant or helper (e.g., a pub const or function
in a common domain module like download::shared or domain::download_states) and
replace the ORPHAN_STATES definition with a reference to that shared symbol,
updating startup_recovery.rs to use the shared constant and ensuring
Download::fail() also references the same shared symbol.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 17160d36-2839-46f6-8db4-7ba00bf25458

📥 Commits

Reviewing files that changed from the base of the PR and between c9ac689 and c813119.

📒 Files selected for processing (1)
  • src-tauri/src/application/services/startup_recovery.rs

@mpiton
mpiton merged commit a58fad1 into main Apr 14, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QA] HIGH: Regression #46 — orphaned downloads from previous session still stuck in Downloading state

1 participant