Skip to content

Sweep orphaned .partial downloads left by the resumable-download preflight #5855

Description

@atomantic

Problem

server/lib/downloadPreflight.js's streamResumableDownload (added in #5817) intentionally keeps a ${destPath}.partial (plus a .partial.etag sidecar) after any transport failure, so a retry can Range-resume — only a user-initiated cancel discards it. Before this change, downloadToFile/streamToFile deleted the temp file on any stream failure.

That's the correct behavior for a retry that actually happens soon. But if the user never retries — network drops mid-transfer, they pick a different model, close the tab — the partial sits on disk forever. Nothing sweeps it:

  • listLoras() (server/services/loras.js) filters .partial files out by extension, so they're invisible to the LoRA manager UI and its disk-usage readout.
  • No startup/periodic cleanup exists anywhere in the tree — grep -rn '\.partial' under server/ only finds the writers (downloadPreflight.js, specDecodeModels.js, loras.js), never a sweeper.

A 12 GB LoRA or a 20 GB GGUF that dies on a dropped connection then sits invisible in data/loras/ or the spec-decode temp dir indefinitely — silently eating exactly the disk space the whole preflight feature exists to protect.

Decision

Add a startup + periodic age-based sweep, not a UI-surfacing-only fix — surfacing in the UI doesn't reclaim the space, and a stale .partial a user never intended to resume is dead weight regardless of whether they can see it.

  • New helper, e.g. sweepOrphanedPartials(dirs, { maxAgeMs }) in server/lib/downloadPreflight.js (or a sibling module it exports from) — given one or more directories, readdir for *.partial (and their *.partial.etag siblings), stat each, and unlink any partial (+ its etag sidecar) whose mtimeMs is older than maxAgeMs. A sane default cutoff is 7 days — long enough that a user who steps away overnight and resumes tomorrow isn't punished, short enough that a genuinely abandoned transfer doesn't linger for months.
  • Wire it into server/services/bootstrap.js following the existing "Legacy artifact prune" pattern there (search pruneLegacyFiles around line ~667) — a boot-time async step, not a blocking one, over the known download destinations: PATHS.loras (server/lib/fileUtils.js), the spec-decode preset paths (server/lib/specDecodePresets.js / wherever resolveSpecModelPath lands), and the Ollama/LM Studio model dirs (ollamaManager.getModelsDir() / lmStudioManager.getModelsDir()).
  • Do NOT sweep a .partial that has an in-flight download actively writing to it — the safest guard is the age cutoff itself (an active transfer's mtimeMs keeps advancing as bytes land), but double-check against each surface's own "is a download active for this dest" tracking (e.g. specDecodeModels.js's in-flight state map) before deleting, so a sweep tick that lands mid-download can't race a live write.
  • Add a focused unit test for the sweep helper (old partial removed, in-flight/recent partial kept, missing dir handled) — this is exactly the kind of pure edge-case logic AGENTS.md's Test Strategy calls out for a dedicated test rather than only an integration boundary test.

Acceptance criteria

  • A .partial (+ .partial.etag) older than the cutoff is removed on boot and on a periodic tick, across every destination the four #5817 download entry points can leave one in.
  • A recent or actively-downloading .partial is never touched by the sweep.
  • New/updated tests cover the sweep helper directly.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:devtoolsDevtools/workspace/code-review surfaceseffort:mediumEffort: mediumin-progressClaimed and being workedmodel:mediumModel size: mediumplanTracked by /do:replanplanner:sonnet-5Plan authored by the sonnet-5 model

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions