← All courses ← Course home

Lesson 04 of 05

Survive downloads and process death

Model files are large. Downloads stop, apps get killed, and phones reboot. Resume the download, check the file before use, and bring the chat back after process death.

Agent brief (llms.md)

Environment setup

Same setup as lesson 01: Xcode 27 with an iOS 27 device, Android Studio with minSdk 30 or higher on a real phone. Python tools run with uv run inside the llm-survival project (Python 3.11+, uv add torch mlx mlx-lm).

Generate a checksum for your model file on the Mac before you upload it:

shasum -a 256 model.bin | cut -d ' ' -f 1 > model.bin.sha256

What goes wrong

Rules: download in a way the system can keep going, resume instead of restart, and never load a file you did not check.

iOS: background URLSession and resume data

A URLSession with a background configuration keeps running after your app leaves memory. If a download fails or you cancel it, you can get resume data and continue with downloadTask(withResumeData:).

import Foundation
import CryptoKit

final class ModelDownloader: NSObject, URLSessionDownloadDelegate {
    static let shared = ModelDownloader()
    private let resumeKey = "model.resumeData"
    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.background(withIdentifier: "dev.edgefde.model-download")
        config.isDiscretionary = false
        config.sessionSendsLaunchEvents = true
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()

    var finalURL: URL {
        FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("model.bin")
    }
    var expectedSHA256 = ""   // from your manifest, next to the model URL

    func start(from remote: URL) {
        if let data = UserDefaults.standard.data(forKey: resumeKey) {
            UserDefaults.standard.removeObject(forKey: resumeKey)
            session.downloadTask(withResumeData: data).resume()
        } else {
            session.downloadTask(with: remote).resume()
        }
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        guard let error = error as NSError? else { return }
        if let data = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data {
            UserDefaults.standard.set(data, forKey: resumeKey)   // resume next time
        }
    }

    func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
        // Check the file before it gets near the runtime.
        guard let digest = try? sha256(of: location), digest == expectedSHA256 else {
            try? FileManager.default.removeItem(at: location)
            print("download: checksum mismatch, file removed")
            return
        }
        let parent = finalURL.deletingLastPathComponent()
        try? FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
        _ = try? FileManager.default.replaceItemAt(finalURL, withItemAt: location)
    }
}

func sha256(of url: URL) throws -> String {
    let handle = try FileHandle(forReadingFrom: url)
    defer { try? handle.close() }
    var hasher = SHA256()
    while let chunk = try handle.read(upToCount: 8 * 1024 * 1024), !chunk.isEmpty {
        hasher.update(data: chunk)
    }
    return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}

Two details matter. First, the file at location is temporary, so move it inside the delegate method. Second, hash in chunks. Reading a 2 GB file into one Data can cause the jetsam kill you are trying to avoid. Also handle application(_:handleEventsForBackgroundURLSession:completionHandler:) in your app delegate so the system can wake your app when the background download ends.

If your model is a Core AI .aimodelc from ahead-of-time compilation, Apple suggests the Background Assets framework for hosted model files. The Core AI first token course covers that path.

Android: WorkManager and HTTP Range

On Android, run the download in a CoroutineWorker. WorkManager keeps the job across process death and reboots. Write to a .part file and ask the server for the rest with an HTTP Range header.

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest

class ModelDownloadWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
    override suspend fun doWork(): Result {
        val url = inputData.getString("url") ?: return Result.failure()
        val expected = inputData.getString("sha256") ?: return Result.failure()
        val part = File(applicationContext.filesDir, "model.bin.part")
        val done = File(applicationContext.filesDir, "model.bin")

        val conn = URL(url).openConnection() as HttpURLConnection
        if (part.exists()) conn.setRequestProperty("Range", "bytes=${part.length()}-")
        val append = conn.responseCode == HttpURLConnection.HTTP_PARTIAL
        if (!append && conn.responseCode != HttpURLConnection.HTTP_OK) return Result.retry()

        conn.inputStream.use { input ->
            FileOutputStream(part, append).use { out -> input.copyTo(out, 1 shl 20) }
        }

        if (sha256(part) != expected) {
            part.delete()
            return Result.retry()
        }
        return if (part.renameTo(done)) Result.success() else Result.retry()
    }

    private fun sha256(file: File): String {
        val md = MessageDigest.getInstance("SHA-256")
        file.inputStream().use { input ->
            val buf = ByteArray(1 shl 20)
            while (true) {
                val n = input.read(buf)
                if (n <= 0) break
                md.update(buf, 0, n)
            }
        }
        return md.digest().joinToString("") { "%02x".format(it) }
    }
}

If the server answers 200 instead of 206, it ignored the range, so start again from zero. The code handles that: append is false and the stream overwrites the part file.

Process death in the middle of a chat

The system can kill your app while it is in the background, even in the middle of a long answer. When the user comes back, they expect the chat to still be there.

For long jobs that must finish, see Durable On-Device AI Workflows.

Sources

Key concepts

  • Use a background URLSession on iOS and WorkManager on Android so downloads survive process death.
  • Resume with resume data on iOS and an HTTP Range header on Android.
  • Check a SHA-256 checksum in chunks before you move the file into place.
  • After process death, restore saved messages and mark the cut answer as stopped.

Takeaways

  • Never hand a runtime a file you have not checked.
  • Never hash a big model by reading it into memory at once.
  • Save chat messages as they finish, and load the model lazily after launch.