Skip to content

fix: keep URLSession capture from changing how requests behave - #20

Merged
heoblitz merged 2 commits into
toss:mainfrom
wjdtjq6:fix/urlsession-capture-fidelity
Oct 15, 2026
Merged

heoblitz merged 2 commits into
toss:mainfrom
wjdtjq6:fix/urlsession-capture-fidelity

Conversation

@wjdtjq6

@wjdtjq6 wjdtjq6 commented Oct 13, 2026 •

Copy link
Copy Markdown

Summary

NectoURLSessionCapture re-issued every captured request through a new ephemeral URLSession with a completion-handler data task, and told the app's session about the response only once it was complete. Measured against a loopback HTTP server, that changes how the app's requests behave while the capture is installed:

  • Cookies stop working for URLSession.shared. Set-Cookie from a captured response is not stored and no cookie is sent on the next request, so cookie-based sessions break. (A session the app configured itself keeps this behaviour, by design: its cookie policy cannot be seen from a URLProtocol, so no cookie is sent or stored on its behalf — see the review discussion below.)
  • Data arrives before the response. The client was told didLoad before didReceive(response). URLSession.bytes(for:) fails with NSURLErrorDomain -1, and a URLSessionDataDelegate sees data, response, complete.
  • Nothing streams. A chunked or SSE response reaches the app only when it ends (the test holds the second chunk back for 3 s; the first line arrived after 3 s instead of immediately).
  • Every request opened a connection of its own, since the connection pool is per session.

This PR runs captured requests through one shared delegate-driven session built from the default configuration, hands the response and each piece of data to the client as they arrive, and keeps only the head of a large body for the record (Body.captureLimit).

Redirects are still followed inside the capture, as before. I tried handing them to the app's session with urlProtocol(_:wasRedirectedTo:redirectResponse:) (the Apple CustomHTTPProtocol pattern): told the load was cancelled for the redirect, the client sometimes reported that cancellation instead of following (2 failures in 16 runs of 200 redirects). A URLProtocol is also never told when a delegate refuses a redirect, so holding the response behind a timer was the only alternative, and that changes behaviour for slow delegates. The limitation is documented instead.

Two documentation corrections come with it: install() covers URLSession.shared only (the comment claimed sessions built from URLSessionConfiguration.default were covered; they are not), and docs/setup.md now says how to capture a session built from a configuration and what capturing one changes (cookies handled only for URLSession.shared, no cache, redirects and authentication challenges not passed to the delegate).

Changelog

Fix URLSessionNetworkPlugin breaking cookie sessions, URLSession.bytes(for:) and streamed responses in the app it captures.

Test Plan

New test target NectoURLSessionCaptureTests with a small loopback HTTP/1.1 server (LocalHTTPServer.swift). Every behavioural test above fails on main and passes here; the cookie and delegate-order tests run both with and without the capture, so the harness itself is checked.

  • swift test: 178 tests in 12 suites passed. The capture suite was then run 20 more times with --skip-build (10,000 redirects followed) without a failure.
  • xcodebuild -project Necto.xcodeproj -scheme ExampleApp -destination 'generic/platform=iOS Simulator' build: BUILD SUCCEEDED, so the module compiles for iOS.
  • Not verified: the capture running inside a real iOS app against Necto.app, and the USB path (no device). script/test e2e was not run; this machine has no iOS 26.2 runtime.

@heoblitz

Copy link
Copy Markdown
Collaborator

This introduces a cookie-policy regression: a .default session with httpShouldSetCookies = false now sends shared cookies when capture is enabled, whereas the previous implementation did not.

The shared loader uses its own cookie policy instead of the originating session’s. Could we preserve the caller’s policy and add a regression test? Preserving the caller-provided configuration, as Pulse’s URLSessionProxy does, is one possible approach.

소정섭 added 2 commits October 14, 2026 19:38
The capture re-issued every request through a new ephemeral session and
reported to the app only once the response was complete. With it
installed:

- cookies were neither sent nor stored, so cookie sessions stopped
  working;
- data reached the app before the response did, which made
  URLSession.bytes(for:) fail and told delegates things out of order;
- a streamed response arrived all at once when it ended;
- every request opened a connection of its own.

Requests now run in one delegate-driven session built from the default
configuration, and the response and its data are handed over as they
arrive. Only the head of a large response is kept for the record.

Redirects are still followed by the capture. Handing them to the app's
session was tried: told the load was cancelled for the redirect, the
session sometimes reported that cancellation instead of following.

install() covers URLSession.shared only, so the comment and the setup
guide now say how to capture a session built from a configuration, and
what capturing one changes.
The shared re-issuing session handled cookies through the shared store
for every captured request, so a session whose configuration turned
cookies off, or kept a store of its own, sent cookies it never agreed
to. What a configuration says about cookies cannot be seen from a
URLProtocol, so the protocol an app puts into its own configuration now
re-issues through a session that handles none, as before, and only the
protocol registered for URLSession.shared — whose policy is the shared
store's — keeps cookies working.

Tasks are now keyed by identity in the loader: a task identifier is
only unique within one session, and the loader serves two.
@wjdtjq6
wjdtjq6 force-pushed the fix/urlsession-capture-fidelity branch from 9bd5092 to 982ee2b Compare October 14, 2026 10:45
@wjdtjq6

wjdtjq6 commented Oct 14, 2026

Copy link
Copy Markdown
Author

Thanks, that's a real regression and I should have caught it. Fixed in a commit on top of a rebase onto main:

What changed. A URLProtocol cannot see the originating session's configuration — I probed what reaches the protocol's request and none of httpShouldSetCookies, requestCachePolicy or timeoutIntervalForRequest do (only httpAdditionalHeaders come through). So the capture now applies the one policy it can know: the class registered with the loading system only ever sees URLSession.shared, whose policy is the shared store, so it keeps handling cookies there. NectoURLSessionCapture.protocolClass, which an app puts into a configuration of its own, now returns a subclass (NectoConfiguredNetworkObserver) that re-issues through a session with httpShouldSetCookies = false and no cookie storage — no cookie is sent or stored on that session's behalf, which is what the previous implementation did. docs/setup.md says so.

Regression test. sendsNoSharedCookieForConfiguredSessions seeds the shared store with a cookie for the host, then makes requests through a .default configuration with httpShouldSetCookies = false and through an .ephemeral one, with and without the capture, and checks the server never sees a Cookie header. It fails on the previous commit of this branch (the .default + httpShouldSetCookies = false case sends session=necto) and passes now. keepsSharedSessionCookies covers the shared session both ways, and handlesNoCookieForCapturedDefaultConfiguration pins that a captured default configuration has its cookies left alone.

Also fixed while there: the loader keyed observers by taskIdentifier, which is only unique per session; it now keys by the task object.

On preserving the caller's configuration. I agree that is the right end state — a session built from a copy of the caller's configuration would carry cookies, cache, timeouts and proxy settings faithfully. Since httpAdditionalHeaders do reach the protocol, one way without swizzling is an opt-in like NectoURLSessionCapture.capture(configuration) that inserts the protocol class, keeps a copy of the configuration, and marks it with a header the protocol strips before re-issuing; the Pulse-style proxy (wrapping URLSession construction) is the other. Both add public API, so per CONTRIBUTING I'd rather propose that in an issue than grow this PR — happy to open one if you'd like.

swift test: 178 tests passed; ExampleApp builds for the iOS simulator.

@heoblitz heoblitz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution.

@heoblitz
heoblitz merged commit ead878f into toss:main Oct 15, 2026
5 checks passed
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.

2 participants