Skip to content

avformat/http: unknown-total Content-Range ("bytes X-Y/*") ends in EIO via a 416 follow-up instead of clean EOF when reconnect_partial is off #46

Description

@ronag

Summary

For responses with an unknown total size (Content-Range: bytes X-Y/*), the fork-specific parsing introduced in 3e345ae (avformat/http: parse Content-Range: <range>/*) sets s->filesize_unknown, which makes s->filesize = UINT64_MAX while s->range_end = Y+1 is known. When such a resource has been fully read and is not actually growing, http_buf_read() returns AVERROR(EAGAIN) at the range end, http_read_stream() issues a follow-up Range: bytes=<end>- request, and the server answers 416. With reconnect_partial=0 (the default, added in 5319653 / db08bc9) the 416 is a hard error, and because is_premature is computed as s->filesize > 0 && s->off < s->filesize with filesize == UINT64_MAX, the function returns AVERROR(EIO) at the true end of a fully and correctly downloaded resource instead of AVERROR_EOF. With -reconnect 1 it instead retries with backoff until reconnect_delay_total_max and then fails.

Location

  • parse_content_range()* handling:

    FFmpeg/libavformat/http.c

    Lines 952 to 972 in 9a83bff

    static void parse_content_range(URLContext *h, const char *p)
    {
    HTTPContext *s = h->priv_data;
    const char *slash, *end;
    if (!strncmp(p, "bytes ", 6)) {
    p += 6;
    s->off = strtoull(p, NULL, 10);
    if ((end = strchr(p, '-')) && strlen(end) > 0)
    s->range_end = strtoull(end + 1, NULL, 10) + 1;
    if ((slash = strchr(p, '/')) && strlen(slash) > 0) {
    const char *size = slash + 1;
    if (!strcmp(size, "*"))
    s->filesize_unknown = 1;
    else
    s->filesize_from_content_range = strtoull(size, NULL, 10);
    }
    }
    if (s->seekable == -1 && (!s->is_akamai || s->filesize != 2147483647))
    h->is_streamed = 0; /* we _can_ in fact seek */
    }
  • http_read_header()filesize_unknowns->filesize = UINT64_MAX:

    FFmpeg/libavformat/http.c

    Lines 1521 to 1526 in 9a83bff

    // filesize from Content-Range can always be used, even if using chunked Transfer-Encoding
    if (s->filesize_from_content_range != UINT64_MAX) {
    s->filesize = s->filesize_from_content_range;
    s->filesize_unknown = 0; /* the case of a 416 error is already handled above */
    } else if (s->filesize_unknown)
    s->filesize = UINT64_MAX;
  • http_buf_read()EAGAIN at end of content range:

    FFmpeg/libavformat/http.c

    Lines 1824 to 1829 in 9a83bff

    uint64_t file_end = s->end_off ? s->end_off : s->filesize;
    uint64_t target_end = s->range_end ? s->range_end : file_end;
    if ((!s->willclose || s->chunksize == UINT64_MAX) && s->off >= file_end)
    return AVERROR_EOF;
    if (s->off == target_end && target_end < file_end)
    return AVERROR(EAGAIN); /* reached end of content range */
  • http_read_stream()EAGAIN handler and is_premature EIO path:

    FFmpeg/libavformat/http.c

    Lines 1908 to 1937 in 9a83bff

    retry:
    read_ret = http_buf_read(h, buf, size);
    while (read_ret < 0) {
    uint64_t target = h->is_streamed ? 0 : s->off;
    bool is_premature = s->filesize > 0 && s->off < s->filesize;
    if (read_ret == AVERROR_EXIT)
    break;
    else if (read_ret == AVERROR(EAGAIN)) {
    /* send new request for more data on existing connection */
    AVDictionary *options = NULL;
    if (s->willclose)
    ffurl_closep(&s->hd);
    s->initial_requests = 0; /* continue streaming uninterrupted from now on */
    read_ret = http_open_cnx(h, &options);
    av_dict_free(&options);
    if (read_ret == 0)
    goto retry;
    }
    if (h->is_streamed && !s->reconnect_streamed)
    break;
    if (!(s->reconnect && is_premature) &&
    !(s->reconnect_at_eof && read_ret == AVERROR_EOF)) {
    if (is_premature)
    return AVERROR(EIO);
    else
    break;
    }
  • Supporting: check_http_code() 416 handling

    FFmpeg/libavformat/http.c

    Lines 923 to 937 in 9a83bff

    static int check_http_code(URLContext *h, int http_code, const char *end)
    {
    HTTPContext *s = h->priv_data;
    /* error codes are 4xx and 5xx, but regard 401 and 416 as a success, so we
    * don't abort until all headers have been parsed. */
    if (http_code >= 400 && http_code < 600 &&
    (http_code != 401 || s->auth_state.auth_type != HTTP_AUTH_NONE) &&
    (http_code != 407 || s->proxy_auth_state.auth_type != HTTP_AUTH_NONE) &&
    (http_code != 416 || !s->reconnect_partial)) {
    end += strspn(end, SPACE_CHARS);
    av_log(h, AV_LOG_WARNING, "HTTP error %d %s\n", http_code, end);
    return ff_http_averror(http_code, AVERROR(EIO));
    }
    return 0;
    }
    , http_should_reconnect()

    FFmpeg/libavformat/http.c

    Lines 356 to 368 in 9a83bff

    switch (err) {
    case AVERROR_HTTP_RANGE_NOT_SATISFIABLE:
    if (s->reconnect_partial)
    return 1;
    av_fallthrough;
    case AVERROR_HTTP_BAD_REQUEST:
    case AVERROR_HTTP_UNAUTHORIZED:
    case AVERROR_HTTP_FORBIDDEN:
    case AVERROR_HTTP_NOT_FOUND:
    case AVERROR_HTTP_TOO_MANY_REQUESTS:
    case AVERROR_HTTP_OTHER_4XX:
    status_group = "4xx";
    break;

Details

parse_content_range() (http.c:962-968):

        if ((slash = strchr(p, '/')) && strlen(slash) > 0) {
            const char *size = slash + 1;
            if (!strcmp(size, "*"))
                s->filesize_unknown = 1;
            else
                s->filesize_from_content_range = strtoull(size, NULL, 10);
        }

http_read_header() (http.c:1525-1526):

    } else if (s->filesize_unknown)
        s->filesize = UINT64_MAX;

http_buf_read() (http.c:1824-1829):

        uint64_t file_end   = s->end_off   ? s->end_off   : s->filesize;
        uint64_t target_end = s->range_end ? s->range_end : file_end;
        if ((!s->willclose || s->chunksize == UINT64_MAX) && s->off >= file_end)
            return AVERROR_EOF;
        if (s->off == target_end && target_end < file_end)
            return AVERROR(EAGAIN); /* reached end of content range */

http_read_stream() (http.c:1908-1937):

retry:
    read_ret = http_buf_read(h, buf, size);
    while (read_ret < 0) {
        uint64_t target = h->is_streamed ? 0 : s->off;
        bool is_premature = s->filesize > 0 && s->off < s->filesize;

        if (read_ret == AVERROR_EXIT)
            break;
        else if (read_ret == AVERROR(EAGAIN)) {
            /* send new request for more data on existing connection */
            AVDictionary *options = NULL;
            if (s->willclose)
                ffurl_closep(&s->hd);
            s->initial_requests = 0; /* continue streaming uninterrupted from now on */
            read_ret = http_open_cnx(h, &options);
            av_dict_free(&options);
            if (read_ret == 0)
                goto retry;
        }

        if (h->is_streamed && !s->reconnect_streamed)
            break;

        if (!(s->reconnect && is_premature) &&
            !(s->reconnect_at_eof && read_ret == AVERROR_EOF)) {
            if (is_premature)
                return AVERROR(EIO);
            else
                break;
        }

Step-by-step trace (e.g. a static 1000-byte resource whose server reports an unknown total):

  1. The client sends Range: bytes=0-; the server replies 206 with Content-Range: bytes 0-999/*. parse_content_range() sets s->range_end = 1000 and, for the * total, s->filesize_unknown = 1 (http.c:960-965). http_read_header() then sets s->filesize = UINT64_MAX (http.c:1525-1526).
  2. All 1000 bytes are read. On the next http_buf_read() the buffer is empty; file_end = s->filesize = UINT64_MAX, target_end = s->range_end = 1000, so s->off == target_end && target_end < file_end holds and it returns AVERROR(EAGAIN) (http.c:1828-1829).
  3. In http_read_stream(), the EAGAIN handler (http.c:1916-1925) calls http_open_cnx(), which re-requests Range: bytes=1000-. The resource is not growing, so the server replies 416. In check_http_code() (http.c:928-934), (http_code != 416 || !s->reconnect_partial) is true because reconnect_partial defaults to 0 (http.c:223), so the open fails with ff_http_averror(416, ...) = AVERROR_HTTP_RANGE_NOT_SATISFIABLE (http.c:628). http_should_reconnect() falls through the reconnect_partial check to the generic 4xx group (http.c:357-368) and returns 0 with reconnect_on_http_error unset, so http_open_cnx() fails and read_ret = AVERROR_HTTP_RANGE_NOT_SATISFIABLE; goto retry is not taken.
  4. Execution continues in the same loop iteration with is_premature already computed at http.c:1912 as s->filesize > 0 && s->off < s->filesizeUINT64_MAX > 0 && 1000 < UINT64_MAX → true. With reconnect=0 and read_ret != AVERROR_EOF, the branch at http.c:1931-1934 takes return AVERROR(EIO);.

The caller therefore receives a hard I/O error at the clean end of a fully downloaded resource. With -reconnect 1 the loop instead sleeps/seeks/re-requests, getting 416 every time, until reconnect_delay_total > reconnect_delay_total_max and then returns AVERROR(EIO) (http.c:1939-1941).

This was introduced by the fork-specific commit 3e345ae ("avformat/http: parse Content-Range: /*", together with 5319653 / db08bc9 which added -reconnect_partial and the 416 error mapping). Before that commit, the * was parsed by strtoull() as filesize 0 — a different bug that made such resources fail at open. The fork's parsing makes the data readable but propagates the wrong terminal status. The commit message of 3e345ae itself notes the flow "until the server stops giving us data (416 Range Not Satisfiable)", but the 416 only terminates cleanly when reconnect_partial is enabled; the default path was not handled.

Impact

Severity: medium.

  • Any HTTP source that answers range requests with Content-Range: .../​* (unknown total length) but is in fact complete/static causes ffmpeg/libavformat clients to fail with AVERROR(EIO) ("Input/output error") at end of file instead of a clean EOF. Demuxing of the full content succeeds, but the terminal status is an error, which callers typically surface as a failed transfer/transcode.
  • With -reconnect 1 (common in ingest pipelines), end-of-file turns into a retry storm: repeated Range: bytes=<end>- requests answered with 416, with exponential backoff, blocking for up to reconnect_delay_total_max (default 256 s) before finally failing with EIO.
  • No memory safety implications; behavior-only regression relative to a correct EOF, fork-specific.

Suggested fix

Treat a 416 on the follow-up range request as end of data when growth is not expected (reconnect_partial off), and record the now-known size. Note that just setting s->filesize is not enough: is_premature is computed at the top of the loop iteration (http.c:1912), before the EAGAIN handler runs, so it must be reset too, otherwise the stale value still triggers the return AVERROR(EIO) at http.c:1933-1934.

--- a/libavformat/http.c
+++ b/libavformat/http.c
@@ -1922,6 +1922,14 @@ retry:
             read_ret = http_open_cnx(h, &options);
             av_dict_free(&options);
             if (read_ret == 0)
                 goto retry;
+            if (read_ret == AVERROR_HTTP_RANGE_NOT_SATISFIABLE &&
+                !s->reconnect_partial) {
+                /* The resource ends exactly at the previous range end; a 416
+                 * here means there is no more data: report EOF. */
+                s->filesize  = s->off;
+                is_premature = false;
+                read_ret     = AVERROR_EOF;
+            }
         }
 
         if (h->is_streamed && !s->reconnect_streamed)

With this change, when reconnect/reconnect_at_eof are off the loop reaches the break at http.c:1936 and http_read_stream() returns AVERROR_EOF; when -reconnect_at_eof is set, the existing retry-at-EOF logic is honored; and s->filesize = s->off makes any subsequent http_buf_read() return AVERROR_EOF directly via the s->off >= file_end check (http.c:1826-1827). The reconnect_partial=1 case is unaffected, since there the 416 is already handled as a soft error by http_should_reconnect().

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfork-regressionRegression introduced by a fork-specific commithttplibavformat/http.c

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions