Skip to content

ChunkFetchDelegate reserves the whole file's Content-Length on a bodyless HEAD probe: malloc returns NULL and the force-unwrap kills the app on large sources #255

Description

@dlev02

What happened?

Opening a large progressive HTTP source crashes the app outright — EXC_BREAKPOINT / "Unexpectedly found nil while unwrapping an Optional value" — before a single frame plays. It reproduces on a 12.4 GB MKV; small files are fine.

The crashing thread is a URLSession delegate queue:

0  Foundation  Swift runtime failure: Unexpectedly found nil while unwrapping an Optional value
1  Foundation  __DataStorage.init(capacity:) + 284
2  Foundation  Data._Representation.reserveCapacity(_:) + 644
3  AetherEngine  specialized ChunkFetchDelegate.urlSession(_:dataTask:didReceive:completionHandler:)  (AVIOReader.swift:2215)
4  CFNetwork   __81-[__NSCFURLSessionDelegateWrapper dataTask:didReceiveResponse:completionHandler:]_block_invoke

x0 is 0x0 in the crashed thread's register state — that is malloc returning NULL, and it's the value being force-unwrapped. x19/x2 hold 0x318ffc000 (13,304,315,904 — 12.39 GiB), consistent with the requested capacity being the source's full Content-Length.

The cause is ChunkFetchDelegate.urlSession(_:dataTask:didReceive:completionHandler:):

self.response = response
if let http = response as? HTTPURLResponse {
    let len = Int(http.expectedContentLength)
    if len > 0 { body.reserveCapacity(len) }
    ...
}

The reservation is whatever the origin declares, with no ceiling. ChunkFetchDelegate is reached through syncRequest(_:budget:), and one of syncRequest's callers is headProbeFileSize() — a HEAD request. A HEAD response carries Content-Length: <entire file> and delivers zero body bytes, so the delegate asks the allocator for the whole movie in order to buffer nothing. On a 6 GB device a 12.4 GiB malloc fails, and __DataStorage.init(capacity:) force-unwraps the NULL.

Two other threads in the same report were parked in headProbeFileSize()syncRequestawaitSignal waiting on exactly that response, which lines up: the source needed the HEAD fallback (the range probes hadn't yielded a length), and the response handler blew up on arrival.

headProbeFileSize() is the clearest trigger, but the same line is reachable from ordinary bounded chunk fetches whenever an origin ignores Range and answers 200 with the full length instead of 206 — the reservation is then the whole file again rather than chunkSize.

Worth noting this is a distinct failure from the gradual memory-exhaustion reports (#31, #220, #243): there's no growth curve and no jetsam here. It's one oversized allocation at open time, and it traps rather than throwing, so a host app can't catch or degrade it.

Steps to reproduce

  1. Host an MKV large enough that a single malloc of its byte length fails on the target device (12.4 GB reproduced this on a 6 GB iPhone; an Apple TV's 4 GB should bite lower).
  2. Serve it from an origin that makes the reader fall back to the HEAD probe — i.e. the bytes=0- range probes don't resolve a length.
  3. Open it on the FFmpeg/AVIOReader path.

Crash lands on the URLSession delegate queue as the HEAD response arrives, before playback.

Suggested fix

Cap the reservation, and skip it entirely where no body is coming:

if let http = response as? HTTPURLResponse {
    // Size probes (HEAD, and Range GETs that origins answer with the full
    // length) declare the whole source while delivering little or no body,
    // so the declared length is not a safe reservation.
    if dataTask.currentRequest?.httpMethod != "HEAD" {
        let len = Int(http.expectedContentLength)
        if len > 0 { body.reserveCapacity(min(len, Self.maxBodyReserve)) }
    }
    ...
}

maxBodyReserve at a small multiple of chunkSize would keep the allocation useful for real chunk fetches while making a bogus or whole-file length harmless. Data grows on demand, so the cap costs nothing but a reallocation in the rare case a legitimate body exceeds it.

AetherEngine version or commit SHA

Crash captured on d34e02dd (5.23.7). The line is byte-for-byte identical on 2bfcbd19 (6.0.0) and on 6.1.0, so current main is still affected.

Host app

Custom / my own integration

Platform

iOS

OS version

iOS 26.5.2 (23F84)

Device / chip

iPhone 14 Pro (iPhone15,2), 6 GB RAM

Playback path

FFmpeg / software (custom AVIO)

Source media (for playback bugs)

MKV, ~12.4 GB, over HTTP

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