Skip to content

Bug: failed/resumed model downloads leak multi-GB background_downloader temp files #383

Description

@alan-insam

Description

Failed or interrupted network model installations can leave multi-gigabyte
background_downloader temporary files in Android's persistent filesDir.

Repeated retries or application restarts may create additional partial files.
The application can therefore consume several gigabytes even though
FlutterGemma.listInstalledModels() reports no installed model.

I reproduced this with:

  • flutter_gemma: 1.2.3
  • background_downloader: 9.5.5
  • Android, profile build
  • A ~2.4 GB .litertlm model
  • A custom server supporting Range, Accept-Ranges: bytes, and a stable strong ETag

The relevant implementation defects are also still present on current main
/ release 1.3.0.

Example

final cancelToken = CancelToken();

await FlutterGemma.installModel(
  modelType: ModelType.gemmaIt,
)
    .fromNetwork(
      url,
      token: bearerToken,
      foreground: true,
    )
    .withCancelToken(cancelToken)
    .withProgress((progress) {
      // Display progress.
    })
    .install();

Steps to reproduce

  1. Start downloading a multi-gigabyte model on Android.

  2. Let the download reach substantial progress, for example 50–60%.

  3. Interrupt the network, terminate the process, or trigger a resume/watchdog
    failure.

  4. Restart the application and retry the installation several times.

  5. Inspect application storage:

    adb shell run-as sh -c
    'ls -lh files/com.bbflight.background_downloader*'

  6. Call:

    await FlutterGemma.listInstalledModels();
    await FlutterGemma.getOrphanedFiles();
    await FlutterGemma.cleanupStorage();

Actual result

  • Multiple files named similarly to
    com.bbflight.background_downloader remain under Android filesDir.

  • These files can each contain a large portion of the model.

  • Application data grows by several gigabytes.

  • No model is reported as installed.

  • getOrphanedFiles() does not report these files.

  • cleanupStorage() does not delete them.

Expected result

  • A logical model download should have at most one resumable partial file.

  • Retrying after a process restart should reuse that partial or remove it before
    starting a replacement.

  • Explicit cancellation should delete the partial file and resume metadata.

  • getOrphanedFiles() should report abandoned downloader fragments.

  • cleanupStorage() should remove abandoned downloader fragments and their
    metadata.

Source-level findings

1. Downloader group mismatch

SmartDownloader creates tasks in smart_downloads:

https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L93

However, cleanup resets flutter_gemma_downloads:

https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/managers/mobile_model_manager.dart#L263

https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/managers/mobile_model_manager.dart#L591

Resume detection also queries flutter_gemma_downloads:

https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/utils/resume_checker.dart#L96

As a result, cleanup and resume detection do not operate on the tasks created by
SmartDownloader.

2. Persisted task IDs are derived from hashCode

The task ID is currently constructed from:

'${url.hashCode...}_${targetPath.hashCode...}'

https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L446-L448

Dart documents that hash codes need not be consistent between executions:

https://api.dart.dev/dart-core/Object/hashCode.html

A retry after process restart can therefore receive a different task ID and
create a new temporary download, detaching the previous resumable file.

Signed URLs or changing query parameters can cause the same problem even if a
deterministic hash implementation is used.

3. cleanupStorage() cannot see downloader temporary files

ModelFileSystemManager.getOrphanedFiles() scans only
getModelStorageDirectory() and filters for supported model extensions:

https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/core/model_management/utils/file_system_manager.dart#L103-L112

background_downloader partial files are extensionless files named similarly
to com.bbflight.background_downloader and may live in Android
filesDir, outside the final model directory.

Therefore the public orphan/cleanup API cannot discover them.

4. Resume watchdog does not cancel the native task

When the resume watchdog fires, it closes the Dart progress stream and reports
an error, but does not call cancelTaskWithId(taskId):

https://github.com/DenisovAV/flutter_gemma/blob/main/packages/flutter_gemma/lib/mobile/smart_downloader.dart#L1090-L1105

This can leave the native task, resume metadata, and partial file behind after
the Dart installation future has failed.

5. reset() is insufficient for paused/resumable files

Even after correcting the group name, cleanup should enumerate tasks and call
cancelTasksWithIds() before resetting the group.

In background_downloader, cancellation of paused tasks explicitly deletes the
associated temp file. A reset primarily removes task state and is not a
replacement for deleting resumable data.

Why the underlying downloader retains the file

This behavior is intentional in background_downloader: failed downloads may
be resumed when the server supports ranges and provides a compatible ETag.

For large downloads, Android uses persistent filesDir instead of cacheDir.
Its documentation warns that remnant temp files in filesDir must be cleaned
by the application because Android does not remove them automatically:

https://github.com/781flyingdutchman/background_downloader/blob/main/CHANGELOG.md#L866-L874

Suggested fixes

  1. Define one download-group constant and reuse it everywhere.

  2. Replace runtime hashCode task IDs with a deterministic digest.

  3. Ideally allow callers to provide a stable logical downloadId, because
    signed URLs may change between retries.

  4. When the resume watchdog fires, cancel the native task and await
    cancellation before settling the installation future.

  5. In explicit cleanup:

    • enumerate smart_downloads tasks;
    • cancel them using cancelTasksWithIds;
    • remove resume metadata;
    • remove detached legacy temp files when no matching task exists.
  6. Extend getOrphanedFiles() to include downloader fragments and their sizes.

  7. Guarantee that only one partial exists for each logical model installation.

  8. Document whether a failure remains resumable and how callers can explicitly
    discard a partial download.

Suggested tests

  • Interrupt a ranged download after several megabytes and verify one resumable
    partial exists.

  • Restart the Dart process and verify the same task ID/partial is reused.

  • Fire the resume watchdog and verify the native task and temp file are removed.

  • Call cleanupStorage() and verify paused-task temp files and metadata are
    deleted.

  • Assert that downloader, cleanup, and resume detection use the same group.

  • Retry with a changed bearer token or signed URL and verify the old partial is
    reused or safely removed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions