Verify iOS download resumption with a local HTTP Range server

Verify iOS download resumption with a local HTTP Range server

When developing an iOS app remotely that downloads a large offline resource, a progress bar that picks up where it left off after a network interruption does not prove the client reused any downloaded bytes. The app may have started the download again while displaying the old progress. On a cloud Mac, you can have the simulator access a local server on the same machine and run a repeatable acceptance test using server logs and file checks. No public file-hosting service is needed.

Define success criteria first

This test covers one file and a single byte range, not parallel chunks. Prepare a file with no sensitive data that is large enough to cancel mid-download. The first request should receive 200. If the client retains valid resumption information, a subsequent request may include Range: bytes=N-, with the server returning 206 and an accurate Content-Range. The final file length and SHA-256 must match the source file.

resumeData contains what the client needs to attempt a download resume; it does not prove the server has returned a partial response. Cancelling a download does not always produce usable resumeData, either.

First, generate the file in a dedicated test directory and record a baseline. Its size merely provides time to cancel manually; it is not a product specification or performance target:

mkdir -p ~/range-lab && cd ~/range-lab
python3 - <<'PY'
with open("fixture.bin", "wb") as out:
    for _ in range(64):
        out.write(b"XCZone-range-check" * 65536)
PY
shasum -a 256 fixture.bin
stat -f '%z bytes' fixture.bin

Set up a local-only Range server

This minimal server serves only fixture.bin from the current directory. It generates an ETag for the file and supports reading from a specified offset through the end; unsupported Range requests receive 416. A brief pause after each block makes it easier to interrupt the download in the simulator. This is not a file server for public deployment.

from hashlib import sha256
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from time import sleep
import re

path = Path("fixture.bin")
size = path.stat().st_size
etag = '"' + sha256(path.read_bytes()).hexdigest() + '"'

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/fixture.bin":
            self.send_error(404)
            return
        requested = self.headers.get("Range")
        if self.headers.get("If-Range") not in (None, etag):
            requested = None
        start = 0
        if requested:
            match = re.fullmatch(r"bytes=(\d+)-", requested)
            if not match or int(match.group(1)) >= size:
                self.send_response(416)
                self.send_header("Content-Range", f"bytes */{size}")
                self.end_headers()
                return
            start = int(match.group(1))
        self.send_response(206 if requested else 200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("ETag", etag)
        self.send_header("Content-Length", str(size - start))
        if requested:
            self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
        self.end_headers()
        with path.open("rb") as source:
            source.seek(start)
            while chunk := source.read(65536):
                try:
                    self.wfile.write(chunk)
                    self.wfile.flush()
                except (BrokenPipeError, ConnectionResetError):
                    break
                sleep(0.01)

HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

Save it as range_server.py and run python3 range_server.py in that directory. In another terminal, start with curl -i -r 1024-2047 http://127.0.0.1:8765/fixture.bin -o /dev/null to check the status code and response headers. Its output may show transfer progress; do not use that to judge the content. Alternatively, run curl -sD - -r 1024-2047 -o /dev/null http://127.0.0.1:8765/fixture.bin to explicitly check for 206, Content-Range: bytes 1024-2047/…, and Content-Length. When the test is over, interrupt the server from the keyboard. Do not change the listening address to an externally accessible network interface.

Interrupt and resume in the simulator

Have a test build of the app download http://127.0.0.1:8765/fixture.bin using URLSessionDownloadTask. The simulator can use this local test address on its host Mac. On a physical device, however, 127.0.0.1 refers to the device itself, so you cannot reuse the address unchanged. If plain HTTP requires an exception, grant the necessary network security permission only in a separate test configuration, and verify that the production build does not include it.

After the download starts, call cancel(byProducingResumeData:) and save the data supplied by its callback. Then create a new task with downloadTask(withResumeData:). Keep the server terminal output, and record each request’s Range, response status, and final file size. Confirm that an uninterrupted download completes before testing cancellation; otherwise, connection problems and resumption problems become difficult to distinguish.

Distinguish resumption from a fresh download

If the second request has no Range and the server returns 200, the client may have chosen to download the whole file again. That is not a successful partial resume. If a request has a Range but a changed file ETag results in a full response, the client must also write the file from the beginning. In neither case should it append a full response to the old fragment. The test code should also handle cancellation that produces no resumeData, allowing an explicit restart rather than leaving the task stuck “waiting to resume.”

Verify the resulting file

When the download task finishes, copy its temporary file into the test directory. Run shasum -a 256 下载后的文件路径 with the placeholder replaced by the downloaded file’s path, compare the hash exactly against the source file’s hash, and check the length as well. A 206 only establishes that the server returned partial data. If the hashes differ, inspect the offset and write behavior, and check whether the file changed between requests.

Finally, run a negative test: interrupt the download, modify the source file, and restart the test server so its ETag changes. The client must not treat the old fragment as the first part of the new file. Keeping request logs, status codes, lengths, and hashes in one test record makes regressions easier to diagnose than a screenshot of the progress bar alone.

Frequently asked questions

Does an HTTP 206 response prove the download resumed successfully?

No. Check the requested offset, final file size, and SHA-256 hash as well.

What if the server returns HTTP 200 on a resume attempt?

Treat it as a complete response and write the file from the beginning. Never append it to the existing fragment.

XCZone · Cloud Mac

Need a dedicated physical Mac you can access remotely?

Compare the specs, locations, and billing cycles for M4 Start, M4 Work, and M4 Pro, then choose the plan that fits your workload.

Explore Cloud Mac Plans