prism branch T-2260/bugfix-remote-loader-chunked-reads commits 3 files 3 touched lines +590 / -86

Pre-push review: T-2260 remote loader chunked reads

PR #402 — URLDocumentLoader drops the per-byte AsyncBytes loop for a URLSessionDataDelegate-driven ChunkedBodyLoader, and marks the enum nonisolated so the implicit-@MainActor hop can no longer be reintroduced by a refactor. Reviewed as git diff origin/main...HEAD (3 commits, including the concurrent follow-up b74993ab).

At a glance

  • Core change is sound. ChunkedBodyLoader resumes its continuation exactly once on every path (cap via header, cap mid-stream, error, success, no-response), and checks wasOversized ahead of the cancellation error so Req 3.3/7.3 surface as .contentTooLarge.
  • Tests do not exercise multi-chunk delivery. MockURLProtocol's .response case delivers the whole 9 MB body in one didLoad, so largeBodyWithoutContentLengthLoadsQuickly and contentTooLargeViaStreaming each hit didReceive data once. The .streamed mock already exists for this.
  • Timing budget is raw. The 0.5 s deadline sits ~1.6x below the regression it detects (~0.79 s per-byte at 9 MB) with no ciPerformanceMultiplier; a flake surfaces as .networkError(timedOut), which reads like a product bug.
  • CHANGELOG entry missing. T-2138, T-1928, T-2147 and T-1849 each added a ### Fixed line; T-2260 did not, and the existing T-2138 line already claims "about a second" for a 10 MB body that this branch makes ~100x faster.
  • Stale references. SharedConcurrency.swift:34, ImageLoader.swift:326, SVGSourceLoader.swift:173, two test files, docs/agent-notes/open-from-url.md:17 and specs/open-from-url/implementation.md still describe RedirectHandler / URLSession.bytes(from:); open-from-url Decision 8 lists URLSessionDataDelegate as a rejected alternative and is not superseded.
  • Early-cancel window documented, not closed. If the caller is already cancelled when run starts, a data task is still created — possibly on an already-invalidated session — and the continuation may never resume (leak, not hang). A DeadlineGate-style latch closes it cheaply.

Verdict

Ready to push

No blocking or major code defect. Single-resume discipline, lock usage and the .contentTooLarge-before-error ordering all hold; all 30 URLDocumentLoaderTests pass (run twice, including the follow-up cancellation test), SwiftLint is clean and the macOS build emits no warnings. What remains is test-quality and hygiene: the new regression tests never drive more than one didReceive data chunk, the 0.5 s wall-clock budget has no load multiplier unlike every other timing test in the repo, the CHANGELOG [Unreleased] / Fixed entry the project's bugfix PRs always carry is missing, and several comments/docs still name the deleted RedirectHandler or the retired byte loop. None of these change the runtime behaviour shipped by this branch; they are worth a follow-up commit before merge, not a rewrite.

Review findings

9 raised · 0 fixed · 9 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When Prism opens a markdown file from a web address, it downloads the file and must stop at 10 MB. The old code read the download one byte at a time — ten million tiny steps for a 10 MB file, each one an async pause. That was slow (about 0.9 s of pure overhead) and, worse, fragile: because the app makes everything run on the main thread by default, moving that loop into a helper function would silently make it 400x slower and break the size limit entirely.

Why it matters

The new ChunkedBodyLoader lets the networking layer hand over the download in whatever chunks arrive (a few dozen for a 10 MB file) and adds each chunk to a buffer, stopping the moment the total passes 10 MB. The whole type is also marked nonisolated, which is a compile-time guarantee that nothing in it accidentally runs on the main thread.

Key concepts

  • URLSessionDataDelegate: callbacks the system invokes as headers and body chunks arrive.
  • Continuation: a bridge that turns those callbacks into a single awaitable result.
  • nonisolated: opts a type out of the project's "everything is @MainActor by default" build setting.

Architecture

load still races the fetch against withDeadline; the operation closure now calls chunkLoader.run(session:request:), which wraps a CheckedContinuation in withTaskCancellationHandler so the calling task's cancellation cancels the URLSessionDataTask. The loader is a lock-guarded @unchecked Sendable class (same pattern as DeadlineGate) because delegate callbacks are synchronous and an actor would need a Task hop from each one.

Patterns

  • didReceive response: reads Content-Length, sets wasOversized and responds .cancel if over cap, else reserves capacity and allows.
  • didReceive data: appends, checks the running total, cancels the task on overflow.
  • didCompleteWithError: single resume; wasOversized beats the URLError.cancelled the cancellation produces.
  • Redirect validation (http/https only, no userinfo) is carried over verbatim from the deleted RedirectHandler.

Trade-offs

Overshoot is bounded to roughly one transport chunk rather than one byte. @concurrent stays on withDeadline's operation as insurance even though this call site no longer has a hot loop. The no-Content-Length path reserves only 64 KB, so a 9 MB body goes through geometric Data growth (transiently ~1.5–2x); acceptable, but it is the real transient cost in this design.

Deep dive

Two independent defects were addressed. (1) Under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, an unannotated static helper on the enum is implicitly @MainActor; called from inside the @concurrent closure it hops per call — measured 43.1 s/1 MB vs 0.096 s inline. nonisolated enum makes that structural. Note that with SWIFT_APPROACHABLE_CONCURRENCY the async load itself is nonisolated(nonsending) and still runs on the caller's executor; the guarantee is for synchronous helpers, which is what the doc comment claims. load now calls two still-unannotated enums (GitHubURLTransformer.transform, MarkdownFileExtensions.isAccepted) — in Swift 5 mode this builds warning-free (verified), and both are pure. (2) AsyncBytes has no bulk drain; ~0.88 s/10 MB of suspension overhead regardless of executor. Delegate chunking removes the per-element loop entirely.

Edge cases

  • Early cancellation. withTaskCancellationHandler invokes onCancel immediately when already cancelled and still runs the body, so self.task is nil in onCancel and a data task is created and resumed anyway. If load has already returned (the gate resumes withDeadline synchronously on caller cancel), that task is created on an invalidated session whose delegate has been released; didCompleteWithError is unlikely to arrive and the continuation, workTask, and loader leak. The follow-up commit documents this as deliberate; a cancelledEarly latch mirroring DeadlineGate.callerCancelledEarly closes it in ~6 lines.
  • Rejected redirect. Against a real server, completionHandler(nil) delivers the 3xx via didReceive response and load throws .httpError(3xx); under MockURLProtocol no response is delivered and the URLError(.unknown) fallback fires. Both redirect tests assert only (any Error).self, so neither branch is pinned and the user-facing message is generic. A redirectRejected flag checked in didCompleteWithError would make this deterministic; alternatively read task.response and drop receivedResponse.
  • Negative Content-Length. Int("-1") passes the cap guard and reaches reserveCapacity(-1); pre-existing and byte-identical to the deleted code, almost certainly a no-op on an empty Data, but an untrusted header driving an allocation deserves a clamp.
  • Lock across call-out. session.dataTask(with:) is created while holding lock, contradicting the rule DeadlineGate.deliver states; hoisting it above the lock is safe because no callback fires before resume().

Completeness assessment

Fully implemented: chunked accumulation, header + incremental cap, error precedence, redirect validation, caller-cancellation teardown (mid-body), type-level nonisolated. Partially: regression coverage — proves "not per-byte" but not incremental capping across chunks, and the redirect-rejected error path is unpinned. Missing: CHANGELOG entry; doc/comment updates in sibling files; superseding decision-log entry for open-from-url Decision 8.

Important changes — detailed

URLDocumentLoader: nonisolated at the type level

prism/Services/URLDocumentLoader.swift

Why it matters. Turns the 'keep the loop inline' convention from T-2138 into a compiler-enforced guarantee; any future synchronous helper on this enum runs off-main.

What to look at. URLDocumentLoader.swift:29-41

Takeaway. Under SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor, mark networking/delegate types nonisolated at the TYPE level rather than per member. Async members still become nonisolated(nonsending) and run on the caller's executor, so the guarantee is for synchronous helpers and delegate callbacks.
Rationale. A per-call hop back to main measured 43.1 s for 1 MB vs 0.096 s inline; the old inline placement was a proxy for the real constraint. Stated in the type doc comment and the bugfix report.

ChunkedBodyLoader: delegate-driven accumulation replaces AsyncBytes

prism/Services/URLDocumentLoader.swift

Why it matters. Removes ten million suspensions per 10 MB body; cap is enforced from the header and incrementally per chunk with at most ~one chunk of overshoot.

What to look at. URLDocumentLoader.swift:539-675 (class, run, didReceive response/data)

Takeaway. URLSession.AsyncBytes has no bulk-drain API. For a size-capped download that must also be fast, a per-task URLSessionDataDelegate with a lock-guarded continuation is the shape; the same helper could replace the per-byte loops still in ImageLoader and SVGSourceLoader.
Rationale. Ticket listed two acceptable options; the delegate path handles known- and unknown-length bodies with one request, whereas a bulk data(for:) fast path would need a second request. Stated in the bugfix report.

didCompleteWithError: single resume with wasOversized precedence

prism/Services/URLDocumentLoader.swift

Why it matters. Correctness of Req 3.3/7.3: a cap-triggered cancellation must surface as .contentTooLarge, never URLError.cancelled.

What to look at. URLDocumentLoader.swift:699-731

Takeaway. When you cancel a task yourself to enforce a policy, record WHY before cancelling and check that flag ahead of the error the cancellation produces.
Rationale. Stated in the property doc comment; contentTooLargeViaContentLength / contentTooLargeViaStreaming are the detectors.

run(): withTaskCancellationHandler around the continuation

prism/Services/URLDocumentLoader.swift

Why it matters. A bare continuation does not observe the calling task's cancellation; without this, cancelDownload() would only take effect via the 30 s deadline.

What to look at. URLDocumentLoader.swift:602-631

Takeaway. onCancel can fire BEFORE the body stores the task. DeadlineGate solves this with a callerCancelledEarly latch; this class documents the window instead and relies on load's deferred invalidateAndCancel().
Rationale. Follow-up commit b74993ab argues the drop is harmless because the session teardown always follows. That holds while load is still on the stack; if load has already returned, the task is created on an invalidated session and the continuation may never resume. (inferred — not stated by the author)

Tests: two tight-deadline regression detectors + caller-cancellation teardown

prismTests/URLDocumentLoaderTests.swift

Why it matters. The only thing that would catch a regression back to per-byte accumulation; the existing 30 s-deadline tests would not.

What to look at. URLDocumentLoaderTests.swift:422-479, 562-632

Takeaway. A performance regression test needs a discrimination band wide enough to survive a loaded host. Project precedent (FootnotePreprocessorPerformanceTests) multiplies budgets by 20x; these use a raw 0.5 s.
Rationale. Doc comments explain the 0.88 s/10 MB per-byte cost is what the tight deadline detects.

Key decisions

Delegate chunk path over a bulk data(for:) fast path.

AsyncBytes cannot be drained in bulk, so a Content-Length fast path would need a second request or an unverified reliance on delegate callbacks firing for the async convenience API. One delegate handles both known- and unknown-length bodies with one request. (Bugfix report, Approach rationale.)

Lock, not actor, for ChunkedBodyLoader state.

Delegate callbacks are synchronous; an actor would need a Task { await } hop from each, reopening an ordering gap between callback arrival and state update. Same reasoning as DeadlineGate. (Class doc comment.)

Keep @concurrent on withDeadline's operation.

This call site no longer has a hot suspension loop, but withDeadline is general-purpose and the attribute is free insurance. (Updated doc comment.)

Leave the onCancel-before-task-stored window unlatched.

Argued in b74993ab: load's deferred invalidateAndCancel() tears the task down regardless. Review note: that reasoning covers the case where load is still running; it does not cover a task created after the session is already invalidated.

open-from-url Decision 8 is silently superseded.

specs/open-from-url/decision_log.md Decision 8 chose URLSession.bytes "without the complexity of a URLSessionDataDelegate" and lists the delegate approach as rejected. This branch adopts it without a superseding entry, while code comments keep citing "Decision 8: streaming" as unchanged.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorTests: multi-chunk cappingMockURLProtocol's .response case delivers the entire body in one didLoad call, so largeBodyWithoutContentLengthLoadsQuickly and contentTooLargeViaStreaming each drive a single didReceive data callback. The 'cap enforced incrementally as chunks arrive' claim in the test's own comment is not exercised; no test proves the transfer is cut short mid-stream.Add a .streamed-based test emitting ~1 MB chunks with the existing ChunkCounter/CancellationRecorder, asserting .contentTooLarge and that ~11 chunks were sent, not all. Not applied: review is read-only.
minorTests: timing budget0.5 s deadline for a 9 MB body sits ~1.6x below the per-byte regression it detects, with no load multiplier; every other timing test in the repo either uses ciPerformanceMultiplier (20x) or a generous one-directional ceiling. A flake throws .networkError(timedOut), which looks like a product defect.Measure elapsed around a generous-deadline load and assert with a multiplied budget and a message that prints elapsed. Not applied.
minorChunkedBodyLoader.run early cancellationIf the calling task is already cancelled when run starts, onCancel reads task == nil and the body still creates and resumes a data task. If load has already returned (gate resumes withDeadline synchronously), that task is created on an invalidated session with its delegate released; didCompleteWithError likely never arrives and the continuation, workTask and loader leak. Documented as deliberate in b74993ab; the existing test cancels mid-body only.Add a cancelledEarly latch mirroring DeadlineGate.callerCancelledEarly: set in onCancel, and in the continuation body resume with CancellationError instead of creating a task. Not applied.
minorRejected redirect error pathProduction (3xx delivered, .httpError) and MockURLProtocol (no response, URLError(.unknown) fallback) take different branches; both redirect tests assert only (any Error).self at the default 30 s deadline, so neither branch is pinned and the user sees a generic 'error -1' message.Set a redirectRejected flag in willPerformHTTPRedirection and check it in didCompleteWithError to throw .unsupportedScheme/.embeddedCredentials; tighten the tests to the specific case with a short deadline. Not applied.
minorCHANGELOG.mdEvery recent bugfix PR (T-2138, T-1928, T-2147, T-1849) adds a line under [Unreleased] / Fixed; T-2260 has none. The T-2138 entry already states a 10 MB body opens 'in about a second', which this branch improves to milliseconds.Add a Fixed entry describing the further gain. Not applied: review is read-only.
minorStale references to deleted symbolsSharedConcurrency.swift:34, ImageLoaderTests.swift:457, SVGSourceLoaderTests.swift:231 reference URLDocumentLoader.RedirectHandler; ImageLoader.swift:326, SVGSourceLoader.swift:173, docs/agent-notes/image-support.md:67 reference URLDocumentLoader.streamDownload (never existed, now doubly wrong); docs/agent-notes/open-from-url.md:17 and specs/open-from-url/implementation.md:26,48,50,62,63 describe URLSession.bytes(from:) and a per-byte loop.Mechanical comment/doc updates plus a superseding entry for open-from-url Decision 8. Not applied.
minorRedirect validation duplicatedwillPerformHTTPRedirection in ChunkedBodyLoader is byte-for-byte RemoteFetchRedirectValidator (SharedConcurrency.swift:38-57) with a hardcoded scheme pair instead of ImagePathResolver.allowedRemoteSchemes. Pre-existing duplication that the diff rewrote rather than consolidated; it is a security predicate with three copies.Extract one static validate(_:) -> URLRequest? and call it from both delegates. Not applied.
nitChunkedBodyLoader hygiene(a) session.dataTask(with:) is created while holding lock, contradicting the no-call-out-under-lock rule DeadlineGate.deliver states. (b) Class is internal where RedirectHandler was private and no test names the type. (c) reserveCapacity(min(declaredLength ?? 65_536, cap)) accepts a negative Content-Length (pre-existing, byte-identical). (d) body is never cleared after the final capture. (e) finished duplicates continuation == nil and the guard let pending is unreachable.Hoist task creation above the lock; make the class private; clamp declaredLength to >= 0. Not applied.
nitFollow-up: sibling loadersImageLoader (50 MB cap) and SVGSourceLoader (2 MB cap) still use per-byte AsyncBytes loops. They are actors so they never suffered the main-actor hop, but by this branch's own reasoning they pay ~0.88 s per 10 MB of suspension overhead — up to ~4-5 s for a large legal image. specs/bugfixes/remote-image-size-streaming already lists a shared streamDownload as deferred work; ChunkedBodyLoader is that utility.File a follow-up ticket to hoist ChunkedBodyLoader with an injected oversize error and retire both loops.

Per-file diffs

Click to expand.

prism/Services/URLDocumentLoader.swift Modified +223 / -86
diff --git a/prism/Services/URLDocumentLoader.swift b/prism/Services/URLDocumentLoader.swiftindex 1c880fa5..1bb39c06 100644--- a/prism/Services/URLDocumentLoader.swift+++ b/prism/Services/URLDocumentLoader.swift@@ -10,8 +10,10 @@ import Foundation /// Downloads and validates markdown content from remote URLs. /// /// Uses an ephemeral URLSession to avoid cookie/cache persistence.-/// Streams the response via `URLSession.bytes(from:)` to enforce the 10MB-/// limit without loading oversized responses into memory.+/// Streams the response via `ChunkedBodyLoader`'s `URLSessionDataDelegate`+/// chunk callbacks to enforce the 10MB limit without loading oversized+/// responses into memory (T-2260 replaced an earlier `URLSession.bytes(for:)`+/// per-byte implementation — see `ChunkedBodyLoader`'s doc comment). /// /// Requirements covered: /// - 2.1: Accept http/https only@@ -23,7 +25,20 @@ import Foundation /// - 8.1: Redirect restriction to http/https /// - 8.2: No credentials /// - 8.4: Ephemeral URLSession-enum URLDocumentLoader {+///+/// `nonisolated` at the type level (T-2260): the app target sets+/// `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` (project.pbxproj), which+/// makes every unannotated member of an otherwise-unmarked type implicitly+/// `@MainActor`. `load` runs its network fetch from inside a `@concurrent`+/// closure (see `withDeadline`'s doc comment) specifically to stay off the+/// main actor; a helper on this type that stayed implicitly `@MainActor`+/// would silently hop every call from that closure back to main, which is+/// catastrophic for a hot path (a per-byte accumulation loop that used to+/// live here measured 43.1s for 1MB that way, vs. 0.096s inline — see+/// `ChunkedBodyLoader`'s doc comment for the full history). Marking the+/// type itself `nonisolated` makes this a structural guarantee rather than+/// a "keep everything inline" convention future edits could quietly break.+nonisolated enum URLDocumentLoader {      /// Maximum content size in bytes (10MB), matching MarkdownDocument.maxFileSize.     static let maxContentSize = 10_485_760@@ -151,78 +166,26 @@ enum URLDocumentLoader {         // ImageLoader/SVGSourceLoader. The explicit `deadline` race below is the         // primary, testable enforcement; this is defense-in-depth.         config.timeoutIntervalForResource = 30 // Req 3.1-        let redirectHandler = RedirectHandler()-        let session = URLSession(configuration: config, delegate: redirectHandler, delegateQueue: nil)+        let chunkLoader = ChunkedBodyLoader(maxContentSize: maxContentSize)+        let session = URLSession(configuration: config, delegate: chunkLoader, delegateQueue: nil)         defer { session.invalidateAndCancel() }          var request = URLRequest(url: transformed.fetchURL)         request.timeoutInterval = 30 -        // Download with streaming to enforce size limit (Decision 8), bounded-        // end-to-end by `deadline` so a slow-drip body (one byte per interval,-        // never triggering the inactivity timeouts above) cannot hold the-        // load open indefinitely (Req 3.1).-        //-        // The accumulation loop is written HERE, inline in the `@concurrent`-        // closure. That placement works, but it is a proxy for the real-        // constraint, not the constraint itself. The app target sets-        // `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`-        // (project.pbxproj:424, 478), and `URLDocumentLoader` carries no-        // `nonisolated`, so every unannotated member of this enum is-        // implicitly `@MainActor`. Calling any such member from inside the-        // `@concurrent` closure hops the ENTIRE CALLEE back to the main-        // actor, once per call — once per byte, for a helper holding this-        // loop. A `nonisolated` (or `@concurrent nonisolated`) helper is not-        // exposed to that hop and is as fast as writing the loop inline; an-        // unannotated helper is not, regardless of where it is called from.-        // Measured (1 MB body, app target, `MockURLProtocol`): inline here-        // 0.096 s; the identical loop moved into an unannotated helper-        // 43.1 s (`Thread.isMainThread == true` at completion); the same-        // helper marked `nonisolated` 0.104 s; inline but with `@concurrent`-        // removed from `withDeadline`'s `operation` parameter (see that-        // function's doc comment) 42.1 s; a bulk `session.data(for:)` read-        // of the same body 0.001 s.-        //-        // The consequence is a correctness one, not a performance one: at-        // that per-byte cost the 10 MB cap below cannot be reached inside-        // the 30 s deadline at all, so an oversized body failed over to-        // `.networkError(URLError.timedOut)` and Req 3.3's limit was-        // unenforceable. `contentTooLargeViaStreaming` asserts the specific-        // `.contentTooLarge` case and is the regression detector for this.-        //-        // T-2260 tracks replacing this per-byte loop with a bulk read when-        // `Content-Length` is present and validated (already checked above)-        // — about 100x faster than even the correct per-byte loop — keeping-        // per-byte accumulation only for absent/unparseable headers. That is-        // a genuinely separate cost from the isolation hop above: the-        // `AsyncBytes` iteration itself runs at ~0.9 s per 10 MB regardless-        // of which executor it is on. `ImageLoader` and `SVGSourceLoader`-        // hold a similar per-byte loop but are actors, so they were never-        // exposed to the `@MainActor` hop described here.+        // Download via `ChunkedBodyLoader` (Decision 8: streaming, not a+        // bulk `session.data(for:)`, so an oversized body without a+        // trustworthy `Content-Length` is capped as it arrives rather than+        // fully materialised first). Bounded end-to-end by `deadline` so a+        // slow-drip body (one byte per interval, never triggering the+        // inactivity timeouts above) cannot hold the load open indefinitely+        // (Req 3.1). See `ChunkedBodyLoader`'s doc comment for why this+        // replaced a per-byte `AsyncBytes` loop (T-2260).         let data: Data         let response: URLResponse         do {             (data, response) = try await withDeadline(seconds: deadline) {-                let (bytes, streamResponse) = try await session.bytes(for: request)--                // Early rejection via Content-Length header when available (Req 7.3)-                let declaredLength = (streamResponse as? HTTPURLResponse)-                    .flatMap { $0.value(forHTTPHeaderField: "Content-Length") }-                    .flatMap(Int.init)-                if let declaredLength, declaredLength > maxContentSize {-                    throw LoadError.contentTooLarge-                }--                // Stream and accumulate with size enforcement-                var body = Data()-                body.reserveCapacity(min(declaredLength ?? 65_536, maxContentSize))-                for try await byte in bytes {-                    body.append(byte)-                    if body.count > maxContentSize {-                        throw LoadError.contentTooLarge-                    }-                }-                return (body, streamResponse)+                try await chunkLoader.run(session: session, request: request)             }         } catch let error as LoadError {             throw error@@ -317,14 +280,14 @@ enum URLDocumentLoader {     /// `nonisolated(nonsending)`: it runs on its CALLER's executor rather     /// than off-actor. The unstructured `Task` does NOT change that on its     /// own; the closure carries the caller's isolation into the task, so-    /// every suspension inside it hops back to that executor. `load`'s-    /// per-byte `AsyncBytes` loop is ten million suspensions over a 10 MB-    /// body, and both production call sites (`RemoteContentCoordinator`,-    /// `RemoteRefreshFlow`) are `@MainActor`, so without `@concurrent` that-    /// executor is the main one.+    /// every suspension inside it hops back to that executor.     ///-    /// Measured on this project's own build, iterating 10,485,761 bytes-    /// served by `MockURLProtocol`: 0.88 s inline, and 0.88 s inside an+    /// This was measured against `load`'s ORIGINAL implementation, which+    /// accumulated the response body one byte at a time from+    /// `URLSession.bytes(for:)` — ten million suspensions over a 10 MB body,+    /// with both production call sites (`RemoteContentCoordinator`,+    /// `RemoteRefreshFlow`) being `@MainActor`, so without `@concurrent` that+    /// executor was the main one: 0.88 s inline, and 0.88 s inside an     /// unstructured `Task`, a detached task, or a task group — but 133 s     /// through this function without `@concurrent`, and 0.88 s with it.     /// Byte-identical code compiled into the TEST target ran at 0.88 s in@@ -333,17 +296,19 @@ enum URLDocumentLoader {     /// both configurations, same as the app target. What the test target     /// lacks is `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, which the app     /// target sets and the test target does not; that is the isolating-    /// variable. See the note at `load`'s accumulation loop for the-    /// mechanism this exposes and the full measurement across six shapes.+    /// variable.     ///-    /// The attribute is necessary but not sufficient: an unannotated member-    /// of this enum called from inside this `@concurrent` closure is still-    /// implicitly `@MainActor` under the app target's default-actor--    /// isolation setting, and hops back to main on every call regardless of-    /// `@concurrent` here. The consequence of getting either wrong is-    /// correctness, not speed — the 10 MB cap becomes unreachable within the-    /// deadline and an oversized body surfaces as a timeout instead-    /// (T-2260).+    /// T-2260 replaced that per-byte loop with `ChunkedBodyLoader` (see its+    /// doc comment), which drives `operation` here through a single+    /// continuation resume rather than millions of suspensions, so this+    /// SPECIFIC call site's exposure to the 150x/400x-class regression is+    /// gone. `@concurrent` stays on `operation`'s signature regardless:+    /// `withDeadline` is a general-purpose race, any future operation passed+    /// to it can reintroduce a hot suspension loop, and the attribute is+    /// free insurance against that — the type-level `nonisolated` on+    /// `URLDocumentLoader` covers the other half of the original defect (an+    /// unannotated helper called from inside `operation` hopping back to+    /// main regardless of `@concurrent` here).     ///     /// `RenderingUtilities.withTimeout` is the same idea in the     /// `withThrowingTaskGroup` shape this comment argues against, and it is@@ -572,9 +537,146 @@ enum URLDocumentLoader {         }     } -    /// Validates HTTP redirects only follow http/https schemes and have no-    /// embedded credentials (Req 8.1, 8.2).-    private final class RedirectHandler: NSObject, URLSessionTaskDelegate {+    /// Downloads one request's body via `URLSessionDataDelegate` chunk+    /// callbacks instead of `URLSession.bytes(for:)`'s per-byte `AsyncBytes`,+    /// and validates redirects the same way the `RedirectHandler` it+    /// replaced did (Req 8.1, 8.2).+    ///+    /// T-2260: iterating `AsyncBytes` one byte at a time costs ~0.88s of+    /// pure Swift async-suspension overhead per 10 MB body — ten million+    /// awaited `next()` calls for a body the transport actually delivers in+    /// a few dozen chunks — REGARDLESS of executor or isolation; that cost+    /// is on top of (and separate from) the ~150x/400x actor-hop regression+    /// `withDeadline`'s doc comment describes, which came from calling an+    /// implicitly-`@MainActor` helper from inside that loop. Delegate chunk+    /// callbacks receive the body in however many pieces the transport+    /// delivered it in, so accumulation cost tracks the network rather than+    /// Swift's `AsyncSequence` overhead, and — because there is no longer a+    /// hot per-element loop at all — there is no isolation hop left to+    /// regress here even without the type-level `nonisolated` on+    /// `URLDocumentLoader`. That annotation is kept anyway: this class'+    /// callbacks are invoked directly by `URLSession` off the main actor,+    /// and Swift will not let an implicitly-`@MainActor` method satisfy+    /// `URLSessionDataDelegate`'s nonisolated requirements silently — making+    /// the type `nonisolated` keeps that a non-issue for any future+    /// callback added here.+    ///+    /// One instance serves exactly one request via `run(session:request:)`,+    /// matching `withDeadline`'s single-shot `operation` shape. State is+    /// protected by a lock rather than an actor for the same reason+    /// `DeadlineGate` is: these callbacks arrive on `URLSession`'s delegate+    /// queue, which is not `async`, so an actor would need a `Task { await+    /// ... }` hop from every one of them, reopening an ordering gap between+    /// a callback arriving and the state actually being updated.+    nonisolated final class ChunkedBodyLoader: NSObject, URLSessionDataDelegate, @unchecked Sendable {+        private let maxContentSize: Int+        private let lock = NSLock()+        private var body = Data()+        private var receivedResponse: URLResponse?+        /// Set the instant either the `Content-Length` header or the+        /// accumulated body is found to exceed `maxContentSize`. Checked+        /// FIRST in `didCompleteWithError`, ahead of whatever error (if any)+        /// the resulting cancellation produced, so the surfaced error is+        /// always `.contentTooLarge` — never a raw `URLError.cancelled` —+        /// regardless of whether `URLSession` reports the cancellation as a+        /// failure or lets the task finish "successfully" first (Req 3.3,+        /// 7.3; `contentTooLargeViaContentLength`/`contentTooLargeViaStreaming`+        /// are the regression detectors for this).+        private var wasOversized = false+        private var finished = false+        private var continuation: CheckedContinuation<(Data, URLResponse), Error>?+        private var task: URLSessionDataTask?++        init(maxContentSize: Int) {+            self.maxContentSize = maxContentSize+        }++        /// Runs `request` to completion (or cap rejection) over `session`.+        ///+        /// Wrapped in `withTaskCancellationHandler` so cancelling the+        /// calling task — `withDeadline`'s `workTask`, on timeout or caller+        /// cancellation — cancels the underlying `URLSessionDataTask`+        /// immediately, the same pattern `DeadlineGate` uses for the same+        /// reason: a bare continuation does not observe the calling task's+        /// cancellation on its own.+        func run(session: URLSession, request: URLRequest) async throws -> (Data, URLResponse) {+            try await withTaskCancellationHandler {+                try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in+                    lock.lock()+                    self.continuation = continuation+                    let task = session.dataTask(with: request)+                    self.task = task+                    lock.unlock()+                    task.resume()+                }+            } onCancel: {+                // Narrow race, deliberately not closed: if cancellation lands+                // while the closure above is between entering+                // `withTaskCancellationHandler` and storing `self.task` under+                // the lock, this reads `nil` and the explicit cancel is+                // dropped. Unlike `DeadlineGate`, there is no+                // `callerCancelledEarly` latch here because the drop is+                // harmless: the only caller is `load`, whose `defer {+                // session.invalidateAndCancel() }` tears the session — and+                // with it this data task — down the instant `withDeadline`+                // resumes with `CancellationError`. Cancelling here only+                // makes that teardown earlier; it is never the sole teardown.+                // `callerCancellationTearsDownChunkedBodyLoader` covers the+                // end-to-end path.+                lock.lock()+                let capturedTask = self.task+                lock.unlock()+                capturedTask?.cancel()+            }+        }++        // MARK: - URLSessionDataDelegate++        /// Early rejection via `Content-Length` before any body bytes are+        /// delivered (Req 7.3) — the same check `load` performed itself+        /// before this class existed, now driven by the response+        /// disposition instead of a manual header read ahead of a loop.+        func urlSession(+            _ session: URLSession,+            dataTask: URLSessionDataTask,+            didReceive response: URLResponse,+            completionHandler: @escaping (URLSession.ResponseDisposition) -> Void+        ) {+            let declaredLength = (response as? HTTPURLResponse)+                .flatMap { $0.value(forHTTPHeaderField: "Content-Length") }+                .flatMap(Int.init)+            if let declaredLength, declaredLength > maxContentSize {+                lock.lock()+                wasOversized = true+                lock.unlock()+                completionHandler(.cancel)+                return+            }+            lock.lock()+            receivedResponse = response+            body.reserveCapacity(min(declaredLength ?? 65_536, maxContentSize))+            lock.unlock()+            completionHandler(.allow)+        }++        /// Appends one chunk and enforces the cap incrementally, so a body+        /// sent without (or with an unparseable) `Content-Length` — the case+        /// the original per-byte loop existed to guard — is still bounded,+        /// without ever materialising more than one chunk past the cap.+        func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {+            lock.lock()+            body.append(data)+            let oversized = body.count > maxContentSize+            if oversized { wasOversized = true }+            lock.unlock()+            if oversized {+                dataTask.cancel()+            }+        }++        /// Validates HTTP redirects only follow http/https schemes and have+        /// no embedded credentials (Req 8.1, 8.2). Unchanged from the+        /// `RedirectHandler` this class replaced.         func urlSession(             _ session: URLSession,             task: URLSessionTask,@@ -593,5 +695,40 @@ enum URLDocumentLoader {             }             completionHandler(request)         }++        /// Resolves `run`'s continuation exactly once. `wasOversized` is+        /// checked ahead of `error` so a cap-triggered cancellation always+        /// surfaces as `.contentTooLarge`, never as whatever `URLError`+        /// `URLSession` happens to report for a cancelled task.+        func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {+            lock.lock()+            guard !finished else {+                lock.unlock()+                return+            }+            finished = true+            let pending = continuation+            continuation = nil+            let oversized = wasOversized+            let finalResponse = receivedResponse+            let finalData = body+            lock.unlock()++            guard let pending else { return }+            if oversized {+                pending.resume(throwing: URLDocumentLoader.LoadError.contentTooLarge)+            } else if let error {+                pending.resume(throwing: error)+            } else if let finalResponse {+                pending.resume(returning: (finalData, finalResponse))+            } else {+                // No response was ever delivered (e.g. a redirect rejected+                // by `willPerformHTTPRedirection` with no further response+                // following it) and the task did not report an error either.+                // `load`'s HTTP-status check only ever runs on a real+                // response, so this must still throw.+                pending.resume(throwing: URLError(.unknown))+            }+        }     } }
prismTests/URLDocumentLoaderTests.swift Modified +131 / -0
diff --git a/prismTests/URLDocumentLoaderTests.swift b/prismTests/URLDocumentLoaderTests.swiftindex 8a52cd8b..e0bc0cac 100644--- a/prismTests/URLDocumentLoaderTests.swift+++ b/prismTests/URLDocumentLoaderTests.swift@@ -419,6 +419,66 @@ struct URLDocumentLoaderTests {         }     } +    // MARK: - Bulk/Chunked Accumulation Performance (T-2260)++    /// Regression detector for T-2260 itself: a per-byte `AsyncBytes`+    /// accumulation loop costs ~0.88s of pure Swift async-suspension+    /// overhead per 10MB body — a cost that exists regardless of executor+    /// or actor isolation, so `contentTooLargeViaStreaming` above (which+    /// relies on the default 30s deadline) would NOT catch a regression+    /// back to that shape, only a catastrophically slower one. A tight+    /// deadline this large a body cannot reach unless accumulation happens+    /// in bulk/chunk-sized pieces (`ChunkedBodyLoader`'s+    /// `URLSessionDataDelegate` callbacks) rather than one byte at a time+    /// is what actually proves "switch to bulk/chunked reads" happened.+    ///+    /// `Content-Length` is declared and valid here, exercising the early+    /// header-accepted path into `ChunkedBodyLoader`'s chunk accumulation.+    @Test("Large body under the cap with a valid Content-Length loads well within a tight deadline")+    func largeBodyWithContentLengthLoadsQuickly() async throws {+        let url = URL(string: "https://example.com/large-with-length.md")!+        let bodySize = 9_000_000+        let content = String(repeating: "a", count: bodySize)+        let data = Data(content.utf8)++        mockScope.handler = { _ in+            .response(self.mockResponse(url: url, contentLength: bodySize), data)+        }++        let result = try await URLDocumentLoader.load(+            from: url,+            sessionConfiguration: mockSessionConfig(),+            deadline: 0.5+        )++        #expect(result.content == content)+    }++    /// Same regression detector, but with NO `Content-Length` header, so the+    /// cap is enforced incrementally as chunks arrive rather than accepted+    /// up front — the shape the original per-byte loop existed to guard,+    /// now handled by `ChunkedBodyLoader.urlSession(_:dataTask:didReceive:)`+    /// instead of a per-byte `for try await` loop.+    @Test("Large body under the cap without Content-Length loads well within a tight deadline")+    func largeBodyWithoutContentLengthLoadsQuickly() async throws {+        let url = URL(string: "https://example.com/large-without-length.md")!+        let bodySize = 9_000_000+        let content = String(repeating: "b", count: bodySize)+        let data = Data(content.utf8)++        mockScope.handler = { _ in+            .response(self.mockResponse(url: url), data)+        }++        let result = try await URLDocumentLoader.load(+            from: url,+            sessionConfiguration: mockSessionConfig(),+            deadline: 0.5+        )++        #expect(result.content == content)+    }+     // MARK: - End-to-End Deadline (T-2138)      @Test("Slow-drip body bypassing inactivity timeouts is bounded by the explicit deadline")@@ -499,6 +559,77 @@ struct URLDocumentLoaderTests {         #expect(elapsed < 10.0)     } +    @Test("Cancelling the caller mid-body tears down the real ChunkedBodyLoader data task")+    func callerCancellationTearsDownChunkedBodyLoader() async throws {+        // The caller-side twin of `slowDripBodyBoundedByDeadline`: same+        // trickling mock, but the teardown is triggered by cancelling the+        // task that called `load` — the `RemoteContentCoordinator+        // .cancelDownload()` / `RemoteRefreshFlow.cancel()` shape — with a+        // deadline far too long to be what fires. `withDeadlinePropagates-+        // CallerCancellation` proves the propagation against a synthetic+        // cooperative operation only; this proves it reaches+        // `ChunkedBodyLoader.run`'s own `withTaskCancellationHandler` and+        // that the underlying `URLSessionDataTask` is actually cancelled,+        // as observed by the mock's `stopLoading()` (PR #402 review).+        let url = URL(string: "https://example.com/cancelled.md")!+        let maxDripChunks = 60+        let counter = ChunkCounter()+        let firstChunkSent = CancellationRecorder()+        let cancellation = CancellationRecorder()+        mockScope.handler = { _ in+            .streamed(self.mockResponse(url: url), {+                guard counter.bytesSent < maxDripChunks else { return nil }+                counter.bytesSent += 1+                firstChunkSent.markCancelled() // reused as a plain "body started" flag+                Thread.sleep(forTimeInterval: 0.05)+                return Data("a".utf8)+            }, onCancelled: {+                cancellation.markCancelled(value: counter.bytesSent)+            })+        }++        let loadTask = Task {+            try await URLDocumentLoader.load(+                from: url,+                sessionConfiguration: mockSessionConfig(),+                deadline: 30+            )+        }++        // Cancel only once bytes are flowing, so the cancel lands on a task+        // that `ChunkedBodyLoader.run` has already stored — the mid-flight+        // case, not the early-cancel race documented in `run`'s `onCancel`.+        let bodyStarted = await firstChunkSent.waitUntilMarked(pollInterval: 0.01, attempts: 500)+        #expect(bodyStarted, "mock never started dripping the body")++        let start = Date()+        loadTask.cancel()++        await #expect {+            _ = try await loadTask.value+        } throws: { error in+            guard let loadError = error as? URLDocumentLoader.LoadError,+                  case .networkError(let underlying) = loadError else {+                return false+            }+            return underlying is CancellationError+        }+        let elapsed = Date().timeIntervalSince(start)++        // Nowhere near the 30s deadline: the return came from cancellation.+        #expect(elapsed < 2.0)++        // The mock must observe `stopLoading()` — the data task was really+        // cancelled — and observe it before the drip ran its course.+        let cancelledInTime = await cancellation.waitUntilMarked(pollInterval: 0.05, attempts: 200)+        #expect(cancelledInTime, "mock never observed stopLoading() — the data task was not cancelled")+        let chunksAtCancellation = cancellation.markedValue()+        #expect(+            (chunksAtCancellation ?? maxDripChunks) < maxDripChunks,+            "teardown landed only after the body finished (\(chunksAtCancellation ?? -1) of \(maxDripChunks) chunks)"+        )+    }+     @Test("withDeadline returns on time even when the operation ignores cooperative cancellation",           .timeLimit(.minutes(1)))     func withDeadlineReturnsWithoutWaitingForUncooperativeOperation() async throws {
specs/bugfixes/remote-loader-chunked-reads/report.md Added +236 / -0
diff --git a/specs/bugfixes/remote-loader-chunked-reads/report.md b/specs/bugfixes/remote-loader-chunked-reads/report.mdnew file mode 100644index 00000000..b0518260--- /dev/null+++ b/specs/bugfixes/remote-loader-chunked-reads/report.md@@ -0,0 +1,236 @@+# Bugfix Report: Remote loader per-byte AsyncBytes loop is ~400x slower via implicit @MainActor hop++**Date:** 2026-08-29+**Status:** Fixed++## Description of the Issue++`URLDocumentLoader.load` (the remote-URL fetch path used by `RemoteContentCoordinator`+and `RemoteRefreshFlow`) downloaded the response body by iterating+`URLSession.bytes(for:)`'s `AsyncBytes` one byte at a time, appending each byte+to a `Data` accumulator inside a `@concurrent` closure passed to `withDeadline`.++That loop had to stay written INLINE in the closure as a fragile convention:+the app target sets `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, so any+unannotated helper on `URLDocumentLoader` is implicitly `@MainActor`. Calling+such a helper from inside the `@concurrent` closure hops the callee back to+the main actor on every call — once per byte, for a helper holding this loop.+A future refactor extracting the loop into a normal (unannotated) static+helper — an entirely reasonable-looking cleanup — would silently reintroduce+a ~400x slowdown, at which point the 10MB size cap becomes unreachable within+the 30-second deadline and an oversized body fails over to+`.networkError(URLError.timedOut)` instead of the correct `.contentTooLarge`+(Req 3.3 becomes unenforceable).++**Reproduction steps (as measured by prior investigation on PR #392/T-2138):**+1. Move the per-byte accumulation loop out of `load`'s inline `@concurrent`+   closure into an ordinary (unannotated) static helper on `URLDocumentLoader`.+2. Load a 1MB remote body through the helper.+3. Observe ~43.1s wall time (`Thread.isMainThread == true` at completion),+   vs. 0.096s for the same loop kept inline — a per-byte actor hop back to+   main, not a real per-byte network cost.++**Impact:** Any refactor of `URLDocumentLoader`'s internals could silently+reintroduce a catastrophic (400x+) slowdown of remote document loading,+eventually manifesting as `.networkError(timedOut)` instead of the correct+`.contentTooLarge` for genuinely oversized documents, and as long/hung loads+for documents near the 10MB cap. Separately, even the "correctly isolated"+per-byte loop cost ~0.88s of pure Swift async-suspension overhead per 10MB+body regardless of isolation — real but far smaller than the actor-hop+defect, and the ticket's title ("switch to bulk/chunked reads") called out+this second, independent cost too.++## Investigation Summary++The root cause was already established by a prior pre-push audit of PR #392+(T-2138) and captured in the T-2260 ticket description with measurements+across six shapes (inline, unannotated helper, `nonisolated` helper, with/+without `@concurrent`, and a bulk `session.data(for:)` baseline). This fix+picked up from that diagnosis rather than re-deriving it.++- **Symptoms examined:** the ticket's own measured numbers; confirmed by+  reading `URLDocumentLoader.swift`'s existing (extensive) doc comments,+  which already documented the "must stay inline" workaround and its+  rationale.+- **Code inspected:** `prism/Services/URLDocumentLoader.swift` (the `load`+  function, the per-byte loop, `withDeadline`, `DeadlineGate`, the private+  `RedirectHandler`); `prismTests/URLDocumentLoaderTests.swift` (existing+  coverage, especially `contentTooLargeViaStreaming`'s comment about needing+  to assert the specific `.contentTooLarge` case rather than `LoadError.self`+  generically, and `slowDripBodyBoundedByDeadline`'s `MockURLProtocol` drip+  mechanics).+- **Hypotheses tested:** whether a bulk `session.data(for:)` fast path could+  be added alongside the existing `session.bytes(for:)` call without a+  second network round-trip; ruled out because `AsyncBytes` exposes no bulk+  drain API (confirmed via Apple's own documentation for+  `URLSession.AsyncBytes.Iterator`), and a genuine bulk fetch requires either+  a duplicate request or bypassing `AsyncBytes` entirely. Settled on a+  `URLSessionDataDelegate`-based chunked read (the ticket's explicitly+  sanctioned alternative), verified via Apple's documentation that a+  per-task delegate conforming to `URLSessionDataDelegate` still receives+  `didReceive response:`/`didReceive data:` callbacks when passed to+  `URLSession.dataTask(with:)`.++## Discovered Root Cause++**Defect type:** Two compounding performance defects, one of which was also+a latent correctness defect:++1. (Correctness-relevant) A hot loop's placement (inline in a `@concurrent`+   closure) was the only thing preventing an implicit `@MainActor` hop per+   iteration; nothing enforced that placement, so it was one refactor away+   from silently regressing 400x+.+2. (Performance) Even with placement correct, `AsyncBytes`'s per-byte+   `for try await` loop costs ~0.88s of pure Swift async-suspension overhead+   per 10MB body, independent of isolation — ten million awaited `next()`+   calls for a body the transport actually delivers in a few dozen chunks.++**Why it occurred:** `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` (set for the+whole app target) makes any unannotated type/member implicitly `@MainActor`.+`URLDocumentLoader` was never marked `nonisolated`, so the original fix+(T-2138) could only address the hop by keeping the loop's call graph+entirely inline — a documented convention, not a structural guarantee.+Separately, `URLSession.bytes(for:)`'s `AsyncBytes` has no bulk-read API;+consuming it one element at a time is inherently suspension-heavy regardless+of where that loop lives.++**Contributing factors:** `ImageLoader` and `SVGSourceLoader` hold a similar+per-byte loop but are actors, so they were never exposed to the `@MainActor`+hop — meaning this class of defect is specific to types that default to+`@MainActor` isolation under the project's build settings, not to per-byte+streaming loops in general.++## Resolution for the Issue++**Changes made:**+- `prism/Services/URLDocumentLoader.swift:39` — marked the `URLDocumentLoader`+  enum `nonisolated` at the type level, making every current and future+  static member immune to the implicit-`@MainActor` hop structurally,+  rather than relying on an "everything stays inline" convention.+- `prism/Services/URLDocumentLoader.swift` (`load`) — replaced the+  `session.bytes(for:)` + per-byte accumulation loop with+  `ChunkedBodyLoader.run(session:request:)`.+- `prism/Services/URLDocumentLoader.swift` (new `ChunkedBodyLoader` class,+  replacing the former `private final class RedirectHandler`) — a+  `nonisolated`, lock-protected `URLSessionDataDelegate` that: rejects early+  via `Content-Length` in `didReceive response:completionHandler:` (same+  check as before, now driven by `URLSession.ResponseDisposition.cancel`+  instead of a manual throw ahead of a loop); accumulates chunks in+  `didReceive data:`, cancelling the task the instant the running total+  exceeds the cap (bounding the overshoot to at most one chunk, the same+  chunk-based-cap pattern `BoundedFileRead`/`ImageMemoryGuard` already use+  elsewhere in this codebase); preserves the exact redirect-validation logic+  the old `RedirectHandler` had; and resolves `run`'s continuation exactly+  once in `didCompleteWithError:`, checking the oversized flag AHEAD of the+  underlying error so a cap-triggered cancellation always surfaces as+  `.contentTooLarge`, never a raw `URLError.cancelled`.+- Updated `withDeadline`'s doc comment (which extensively documented the old+  per-byte-loop measurements) to reflect that this specific call site no+  longer has a hot suspension loop, while keeping `@concurrent` on+  `operation`'s signature as general-purpose insurance for future callers.++**Approach rationale:** The ticket's own "remaining work" explicitly listed+two acceptable resolutions for the accumulation cost: "(1) ... bulk read+when Content-Length is present ... or a URLSessionDataDelegate chunk path+enforcing the cap for chunked bodies." A bulk `session.data(for:)` fast path+layered on top of the existing `session.bytes(for:)` header check would+require either a second network request (since `AsyncBytes` has no bulk+drain) or an unverified reliance on delegate callbacks firing for a+different async convenience method. The `URLSessionDataDelegate` chunk path+handles BOTH the known- and unknown-Content-Length cases with one mechanism,+one request, and eliminates the hot per-element loop entirely — which also+means there is no longer a hot suspension loop for the type-level+`nonisolated` fix to protect at this specific call site (though the+annotation remains valuable insurance against future additions).++**Alternatives considered:**+- **Bifurcated bulk-when-known / per-byte-when-unknown** — matches the+  ticket's first-listed option literally, but achieving genuine bulk speed+  for the known-length case requires either re-issuing the request via+  `session.data(for:)` (a real, if usually connection-reused, extra+  round-trip) or accepting `AsyncBytes`'s per-element cost is unavoidable.+  Rejected in favour of the delegate approach, which needs no second request+  and improves the unknown-length case too.+- **Keep the per-byte loop, only add `nonisolated`** — would fix the+  catastrophic actor-hop defect but leave the ~0.88s/10MB Swift async+  overhead unaddressed, which the ticket's title ("switch to bulk/chunked+  reads") explicitly calls out as in scope.++## Regression Test++**Test file:** `prismTests/URLDocumentLoaderTests.swift`++**Test names:**+- `largeBodyWithContentLengthLoadsQuickly` / `largeBodyWithoutContentLengthLoadsQuickly`+  (new) — a 9MB body (under the cap) must load within a 0.5s deadline. A+  per-byte `AsyncBytes` loop costs ~0.88s for a body this size regardless of+  isolation correctness, so this fails deterministically on a regression back+  to that shape, which `contentTooLargeViaStreaming` (relying on the default+  30s deadline) does not catch.+- `contentTooLargeViaContentLength` / `contentTooLargeViaStreaming`+  (pre-existing, unchanged) — kept as the regression detector for the+  correctness consequence: both assert the load throws `.contentTooLarge`+  specifically, not `LoadError.self` generically, so a regression that turns+  cap enforcement into a timeout is still caught.+- `redirectToURLWithCredentials` / `redirectToUnsupportedSchemeRejected`+  (pre-existing, unchanged) — verify `ChunkedBodyLoader`'s redirect+  validation still matches the old `RedirectHandler` behaviour exactly.++**What it verifies:** that body accumulation happens in bulk/chunk-sized+pieces (fast enough to clear a tight deadline) in both the+Content-Length-known and Content-Length-absent cases, and that the 10MB cap+still surfaces as the correct error case in both.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' \+  -only-testing:prismTests/URLDocumentLoaderTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/URLDocumentLoader.swift` | Type marked `nonisolated`; replaced the per-byte `AsyncBytes` loop and `RedirectHandler` with `ChunkedBodyLoader`, a `URLSessionDataDelegate`-based chunked reader; updated doc comments |+| `prismTests/URLDocumentLoaderTests.swift` | Added two tight-deadline regression tests for bulk/chunked accumulation speed |++## Verification++**Automated:**+- [x] Regression tests pass (`largeBodyWithContentLengthLoadsQuickly`, `largeBodyWithoutContentLengthLoadsQuickly`)+- [x] Full `URLDocumentLoaderTests` suite passes (all 29 tests, run 4x under `xcodebuild test`)+- [x] `make build-macos` succeeds+- [x] `make lint` passes with no violations+- [x] `make test-quick` was run; the run hit an unrelated `WebReloadNavigationClaimTests`+      crash cascade (documented project behaviour under host contention from+      parallel worktree test runs) — every `URLDocumentLoaderTests` case in+      that same run passed++**Manual verification:** None beyond the automated suite — this is a pure+backend networking change with no UI surface.++## Prevention++**Recommendations to avoid similar bugs:**+- Under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, mark any type whose+  methods must run off the main actor (networking, file I/O, delegate+  callbacks invoked on a non-main queue) `nonisolated` at the TYPE level+  rather than relying on "keep everything inline" conventions in comments.+- When a hot loop's placement is documented as load-bearing for+  performance/correctness (as the old inline-loop comment was), prefer+  eliminating the loop's PER-ELEMENT isolation exposure structurally (here:+  `nonisolated` + no hot loop at all) over leaving a convention for future+  editors to preserve by hand.+- `URLSession.AsyncBytes` has no bulk-read API; a size-capped streaming+  download that also needs to be fast should use a+  `URLSessionDataDelegate`-based chunk read rather than `bytes(for:)`.++## Related++- Transit T-2260 (this ticket)+- Transit T-2138 / PR #392 (commit 5b6cf922) — introduced the `withDeadline`+  race and the original "keep the loop inline" workaround this fix replaces+- Transit T-2151/T-2132/T-1867 (`ImageMemoryGuard`), `BoundedFileRead` — the+  existing codebase precedent for chunk-based (not per-byte) cap enforcement+- PR #402: https://github.com/ArjenSchwarz/prism/pull/402

Things to double-check

Post-invalidation data task behaviour.

The early-cancel finding rests on URLSession not delivering didCompleteWithError for a task created after invalidateAndCancel(). Apple documents that tasks cannot be created on an invalidated session but not the delegate behaviour; a 10-line experiment would settle whether the leak is real or the task fails fast.

Flakiness of the 0.5 s tests under the full suite.

Both passed at ~0.03 s here in isolation. Run make test-quick on a loaded machine (or alongside a second worktree run) before treating them as stable.

Real-server redirect behaviour.

Confirm that a rejected redirect against a live 302 surfaces as .httpError(302) rather than the URLError(.unknown) fallback; the mock cannot tell you.