Skip to content

Backport #5448 (URLSession redirect crash under HTTP/2) to release/6.2 and release/6.3 #5524

Description

@esnunes

Request

Please consider backporting 48fc340a66b8cd5605ba764ecaae4b27a1af23b5 (PR #5448, "use default CURL_BUFFERSIZE", merged 2026-04-08, fixing #5445) to release/6.2 and release/6.3.

Verified with git branch -r --contains: the fix is present on main and release/6.4.x only. Both release/6.2 and release/6.3 still contain the two easyHandle.set(preferredReceiveBufferSize: Int.max) call sites in HTTPURLProtocol.swift and FTPURLProtocol.swift.

Why this is worth a backport

The bug makes swift package resolve unusable on Linux against any Swift package registry served over HTTP/2, which is the common deployment (any CDN-fronted registry). It is not an edge case and there is no client-side workaround for SwiftPM users.

Measured on a real package tree (84 dependencies), cold cache, resolving from a registry that returns 303 to presigned storage URLs over HTTP/2:

toolchain swift package resolve
swift:6.1.3-noble exit 0
swift:6.2.0-noble crash
swift:6.2 (6.2.4) crash
swift:6.2-jammy crash
swift:6.3.3-noble crash
swiftlang/swift:nightly-6.4.x-jammy exit 0
FoundationNetworking/EasyHandle.swift:293: Fatal error: 'try!' expression unexpectedly
  raised an error: Error Domain=libcurl.Easy Code=43 "(null)"

_HTTPURLProtocol.configureEasyHandle(for:body:)
_NativeProtocol.startNewTransfer(with:)
_HTTPURLProtocol.didCompleteRedirectCallback(_:)
closure #1 in _HTTPURLProtocol.redirectFor(request:)

It is not recoverable by retrying — a crashed attempt completes zero downloads, so a retry loop never converges (verified over 12 consecutive attempts). Every current stable Swift is affected; only the 6.4 development line is not.

Scope and risk

The change is a two-line deletion of two call sites. The set(preferredReceiveBufferSize:) method itself is untouched by the backport, and its remaining caller in WebSocketURLProtocol.swift runs before any transfer starts, so it is unaffected. Removing the call restores libcurl's default buffer size.

Reproduction

Minimal, no SwiftPM required — N concurrent URLSession.shared.downloadTask against a URL that redirects, where the first hop negotiates HTTP/2:

  • N <= 4 survives, N >= 8 crashes (matches httpMaximumConnectionsPerHost default of 6).
  • Redirect status code is irrelevant: 301, 302, 303, 307, 308 all crash.
  • The same test with the first hop forced to HTTP/1.1 (http2 off) survives at N=85. Plaintext HTTP/1.1 also survives. Only HTTP/2 + a redirect fails.

One note on the root cause

The rationale given in #5445/#5448 — that Int.max is out of libcurl's accepted range so the call "always crashes" — does not appear to be the actual mechanism. set(preferredReceiveBufferSize:) clamps with min(size, Int(CFURLSessionMaxWriteSize)), so the value passed is 16384, which is inside libcurl's accepted range; and a single non-redirecting request works fine on the affected toolchains.

The trigger appears to be libcurl's guard in lib/setopt.c:

  case CURLOPT_BUFFERSIZE:
    if(data->state.buffer)
      return CURLE_BAD_FUNCTION_ARGUMENT;

state.buffer is released at the end of multi_done(), which returns early when CONN_INUSE(conn) is true — i.e. when other easy handles are still attached to the same connection, which happens under HTTP/2 multiplexing. Since corelibs performs redirects itself (it never sets CURLOPT_FOLLOWLOCATION), the redirect reconfigures a handle whose buffer was never freed.

This does not change the correctness of the fix — removing the call avoids the guard entirely — but it explains why the crash requires both HTTP/2 and a redirect, and why value-based workarounds do not help.

Related: the regression was introduced between 6.1.3 and 6.2 by 4a9694d396b3 (#5159), which corrected header-include ordering so that LIBCURL_VERSION_MAJOR is defined. That enabled CURLMOPT_MAX_HOST_CONNECTIONS (previously inert), capping connections at httpMaximumConnectionsPerHost = 6 and causing excess transfers to be multiplexed onto an existing HTTP/2 connection. That commit is correct in itself; it activated a latent crash.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions