Skip to content

Support URLSession's new loader on iOS 26 and iOS 27 - #195

Merged
NghiaTranUIT merged 7 commits into
mainfrom
feat/urlsession-new-loader
Oct 14, 2026
Merged

NghiaTranUIT merged 7 commits into
mainfrom
feat/urlsession-new-loader

Conversation

@NghiaTranUIT

@NghiaTranUIT NghiaTranUIT commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

  • iOS 26 and iOS 27 add no new URLSession task APIs (diffed the Xcode 27 SDK). iOS 26 adds enablesEarlyData, iOS 26.1 adds allowsUltraConstrainedNetworkAccess.
  • The real change is URLSession's new loader built on Network framework. Apps opt in with usesClassicLoadingMode = false (iOS 18.4+), and enablesEarlyData requires it. Apple's header says the default "may be NO in a future OS update" (it's still the classic loader on iOS 27.0 and macOS 27.0.1).
  • On the new loader, tasks are NWURLSessionTask (not a subclass of NSURLSessionTask) and none of Atlantis's hooks fired: HTTP, upload, download, async/await and WebSocket traffic were all missed.

Changes

  • New loader (NetworkInjector+NWURLSession.swift):
    • -[NWURLSessionTask resume] and the upload methods of NWURLSession
    • Response, body and completion from -[NWURLSessionResponseConsumer* task:deliverResponse:completionHandler:] and task:deliverData:complete:error:completionHandler:
    • WebSocket send/receive/ping/close on NWURLSessionWebSocketTask; the handshake from loaderConnectedWithHTTPConnectionMetadata:CNAMEChain:unlistedTracker:, failures from completeTaskWithError:retryable:
  • Existing gaps, both loaders:
    • The request body of async upload(for:from:) and upload(for:fromFile:) was missing (they use the private _uploadTaskWithRequest:…delegate:completionHandler:)
    • WebSocket messages sent before the handshake were dropped, and queued messages were all sent as the last one
    • The resume hook wasn't installed on watchOS 10/11
  • Safety: each method is swizzled once, and the hooks reused on the private classes log instead of calling assertionFailure
  • README: "What's New" near the top, and a FAQ about the supported APIs
  • The new file is added to Atlantis.xcodeproj (Carthage)

Example app

  • Redesigned in SwiftUI with a tab for iOS 18 (classic loader), iOS 26 (new loader, Early Data, Ultra Constrained networks) and iOS 27 (AsyncImage(request:), .asyncImageURLSession(_:))
  • Each tab lists the APIs that Atlantis captures, grouped. Each row shows the title and the method that Atlantis swizzles
  • Tap an API or Run All: the badge shows the status code, a green ✓ means Atlantis captured the request (via AtlantisDelegate)
  • WebSocket section on the iOS 18 and iOS 26 tabs: Connect, Send Text, Send Data, Receive, Ping and Close on one URLSessionWebSocketTask (echo.websocket.org)
  • The deployment target stays iOS 16.6, rows that need a newer OS are disabled

Testing

  • URLSessionLoaderTests.swift: 23 scenarios run on both loaders (URLSessionCaptureTests, NetworkLoaderCaptureTests), plus URLSessionPrivateAPITests that check the private classes, selectors and type encodings. They use an in-process HTTP/SSE/WebSocket server (LocalTestServer.swift), so no internet or Node.js is needed.
  • macOS 27.0.1 (swift test): 60 passed, 1 skipped
  • iOS 27 simulator (iPhone 18 Pro): 56 passed, 1 skipped. The new suites ran 10 times with 0 failures.
  • iPadOS 27 simulator: the new suites passed except the classic refused-upgrade case, which is now skipped
  • build-for-testing succeeds for iOS, tvOS, watchOS (simulator and arm64_32 device), visionOS, macOS and Mac Catalyst
  • Example app on the iOS 27 simulator: Run All on each tab, every API shows a green ✓ (captured by Atlantis)
  • Fixed: the existing testDownloadRequestCaptured used httpbin.proxyman.app/bytes/32, which fails (502, or a reset HTTP/2 stream with an empty body). It downloads /image/png now (df36b7b).
  • Skipped: the classic loader's refused WebSocket upgrade. CFNetwork on iOS 27 sometimes never finishes that response (1 in 20 runs), so Atlantis can't see it.

Notes

  • Not run: an iOS 26 runtime (the iOS 26 API tests ran on iOS 27), and the watchOS, tvOS and visionOS runtimes.
  • CI runs on macOS 15, so the new loader is tested there too. Every hooked method exists on macOS 15 except -[NWURLSessionWebSocketTask completeTaskWithError:retryable:]. It's optional now: a failed handshake is also reported from the receive hook (6c3daae).
  • Atlantis.xcodeproj (Carthage) doesn't build with Xcode 27 because its deployment targets (iOS 13, macOS 10.15, tvOS 13) are below Xcode 27's minimum. That's not changed here.

🤖 Generated with Claude Code

NghiaTranUIT and others added 7 commits October 10, 2026 21:44
- Capture traffic from URLSession's Network.framework loader (NWURLSession),
  used when usesClassicLoadingMode is false (iOS 18.4+) and required by
  enablesEarlyData (HTTP/3 0-RTT) on iOS 26
- Capture the request body of async upload(for:from:) and upload(for:fromFile:)
- Keep WebSocket messages sent before the handshake, in order
- Install the resume hook on watchOS 10/11
- Add tests for both loaders with an in-process HTTP/WebSocket server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each tab lists the URLSession APIs that Atlantis captures, grouped, with the
  method that Atlantis swizzles for each one
- iOS 18: the classic loader. iOS 26: the new loader, Early Data and Ultra
  Constrained networks. iOS 27: AsyncImage(request:) and asyncImageURLSession(_:)
- Tap an API or Run All: each row shows the status code and whether Atlantis
  captured the request (AtlantisDelegate)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
macOS 15 / iOS 18 don't have -[NWURLSessionWebSocketTask
completeTaskWithError:retryable:], so a rejected upgrade on the new loader
wasn't captured (CI on macOS 15). The receive completion now reports it, and
the completeTaskWithError:retryable: hook is optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
httpbin.proxyman.app/bytes/N fails (502, or a reset HTTP/2 stream with an
empty body), so the test failed on CI. /image/png works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each row is the title and the method that Atlantis swizzles, with a small
  status badge. A green ✓ means Atlantis captured the request
- Remove the header, the section footers and the extra labels
- Load the Atlantis logo instead of the httpbin sample images

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Keep WebSocket messages sent before the handshake on the task, and send
  them after the handshake traffic. The package is built from the final
  request (headers, scheme), and the messages are released with the task
- Drop the waiting messages and the package if the handshake fails
- Queue messages sent before resume(), only drop them once the WS is closed
- Encode a WS message with its package without mutating the package, and
  stop touching shared state in startSendingWebsocketMessage (gRPC calls it
  from any thread)
- Report each new-loader data chunk before calling the original, so the
  next chunk can't overtake it
- Report a failed handshake from the send/ping completion handlers on
  iOS 18 / macOS 15, and make the per-message handshake check lock-free
  once it's reported
- Share a markOnce helper for the once-per-task reports
- Skip reading upload files larger than the maximum package size
- Check completeTaskWithError:retryable: whenever it exists in the tests
- Don't let the SSE demo server wait forever in .waiting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iOS 18 and iOS 26 tabs connect to echo.websocket.org with
URLSessionWebSocketTask, and each row calls one API on the same
connection: Connect, Send Text, Send Data, Receive, Ping and Close.

The connection keeps receiving like an app does. URLSession reads a pong
only after the messages before it, and the classic loader only while a
receive is pending, so Ping timed out without the receive loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NghiaTranUIT
NghiaTranUIT merged commit cc05901 into main Oct 14, 2026
1 check passed
@NghiaTranUIT
NghiaTranUIT deleted the feat/urlsession-new-loader branch October 14, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant