Second and final pre-push gate (PR #392, round 5 of 5). The runtime behaviour is correct, fast and well tested. The documentation the branch itself makes load-bearing is not: a production comment states a false fact about the build configuration, the “write the loop inline” rule it derives from that misidentifies the real constraint, and a follow-up ticket raised to high chases a mechanism that a six-shape measurement resolves in one line.
@concurrent 0.096 s; unannotated helper 43.101 s; helper marked nonisolated 0.104 s; helper marked @concurrent 0.102 s; inline without @concurrent on the parameter 42.119 s; session.data(for:) bulk 0.001 s. The two slow shapes report Thread.isMainThread == true at the end of the loop; the four fast ones do not.SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (app target only). One nonisolated on the helper removes it. T-2260's premise, and its high priority, do not survive that.prismTests does set SWIFT_APPROACHABLE_CONCURRENCY = YES (both configurations). The setting it lacks is the default-actor-isolation one.@concurrent fix itself is correct and necessary — shape E confirms it independently. Sendable-safety and cancellation propagation both hold.callerCancelledEarly branch is live and necessary in exactly one interleaving (cancel locks first, reads both task handles as nil, is preempted before its own deliver).session.data(for:) is ~100× faster than even the correct per-byte loop. That is the real T-2260.Needs fixes
The code is right. The explanation attached to it is not, and this branch deliberately made that explanation load-bearing.
Everything the first review raised is genuinely fixed: the symmetric latch closes all six orderings of {resume, cancel, start}, the ParkedContinuation cannot leak, resume-before-cancel is correct, caller cancellation propagates, and contentTooLargeViaStreaming now asserts .contentTooLarge in 1.37–1.47 s (measured, four hosts) instead of passing on a 30 s timer. The @concurrent fix is real and I reproduced its magnitude independently.
What must not ship as written is the reasoning. I ran the author's own experiment across six shapes in the app target. The result splits into the same two clusters they found — but the cause is fully established, and it is not the one in the comment. SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor is set on the app target and not on the test target, which makes every unannotated member of URLDocumentLoader implicitly @MainActor. A single nonisolated on the extracted helper makes it exactly as fast as the inline loop (0.104 s vs 0.096 s per MB). So the shipped rule — “the loop must be written HERE” — is not the constraint, and obeying it does not protect you: a maintainer who keeps the loop inline but calls any unannotated member of the enum from inside it walks straight back into the 400 s path.
Four must-fixes, none of them a behavioural code change. Because they are all comment/changelog/ticket text, this is close to the Ready line — but the branch's own argument for shipping ~200 lines of concurrency machinery rests on those comments being true, and one of them is checkably false.
f85567c6 Fix T-2138: bound remote document downloads by an explicit end-to-end deadline a43198ff Fix T-2138: make withDeadline authoritative and its regression test deterministic 748262ee Fix T-2138: propagate caller-task cancellation through withDeadline 7a06d1a8 test: give the slow-drip deadline test a 10 s budget so host load cannot starve it 0528cf0d test: make the slow-drip deadline test actually observable 7bfa0cbd Fix T-2138: latch an early resume, and make the 10 MB cap reachable review No fixes applied — editorial review only Prism can open a markdown file from a web address. Before this branch, a server could keep that download open forever: the only time limit was a “have I heard anything lately?” timer, and a server that sends one byte just before each interval elapses keeps resetting it. The branch adds a second, stricter limit — a stopwatch on the whole download, redirects and body together — that Prism enforces itself rather than asking the networking system to.
Adding that stopwatch exposed something bigger. Reading the downloaded bytes was happening on the main thread — the one thread that also draws the interface. Reading a 10 MB document that way takes over two minutes, during which the app is frozen. Moving the read off that thread brings it down to about a second.
URLDocumentLoader.load gains a deadline parameter (default 30 s) and wraps the whole streaming download in a new withDeadline(seconds:operation:). That helper races the operation, run as an unstructured Task, against a sleeping timeout task, arbitrated by a lock-protected DeadlineGate that honours only the first of three possible resumers: the operation finishing, the deadline firing, or the calling task being cancelled.
The obvious shape — withThrowingTaskGroup + cancelAll() — bounds when the winner is known, not when the function returns: a group implicitly awaits every child. If the losing branch is a URLSession.AsyncBytes iteration on a socket-level stall, the caller waits for it anyway and the guarantee evaporates. That is precisely the failure being bounded, so the branch races an unstructured task instead and abandons the loser. The abandoned task cannot leak the connection: defer { session.invalidateAndCancel() } fires when load returns, tearing the task down at the OS level regardless of whether AsyncBytes ever observed cooperative cancellation. RenderingUtilities.withTimeout keeps the task-group shape deliberately — its operation holds a WebViewPool slot, so abandoning the loser would leak the slot, a different fix tracked as T-2134.
onCancel is a synchronous closure and routing through an actor would reopen an ordering gap.DeadlineGate is internal rather than private so the ordering can be tested directly instead of by racing the scheduler.deadline exists on the production signature purely so tests can inject a short one — and it does not govern the three hardcoded 30s beside it.The app target sets both SWIFT_APPROACHABLE_CONCURRENCY = YES (hence NonisolatedNonsendingByDefault) and SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. URLDocumentLoader carries no nonisolated, so the enum — and therefore load, withDeadline, and any helper on it — is implicitly @MainActor. @concurrent on the operation parameter type is what opts the closure body out of that and onto the global concurrent executor.
Because URLSession.AsyncBytes yields one byte per suspension, a 10 MB body is ~107 suspension points. Each one that has to hop back to the main actor costs on the order of 40 µs. Hence the two clusters. Crucially, the hop is a property of the callee's isolation, not of where the loop is textually written: an unannotated helper called from inside the @concurrent closure hops to MainActor for its whole body (43.101 s/MB), and the same helper marked nonisolated does not (0.104 s/MB). The shipped rule — keep the loop inline — is a proxy that happens to correlate, and it leaves the actual trap open: a call from inside the inline loop to any unannotated member of the enum reintroduces the slow path while obeying the documented rule.
Single-resume holds across all six orderings of {resume, cancel, start}. finished is set by the first deliver; the only continuation.resume outside deliver is start's latched branch, which runs on a path where self.continuation was deliberately never stored, so the two can never both fire. The callerCancelledEarly branch is not dead: it is the sole task-cancelling path in the interleaving where cancelFromCaller acquires the lock before start does, reads both handles as nil, and is preempted before its own deliver. Its trailing deliver is redundant; if latched == nil && !finished's second conjunct is unreachable.
UInt64(max(0, seconds) * 1_000_000_000) traps on .infinity or anything past ~1.8e10 — unreachable from production today, reachable from a general-purpose primitive.LoadError.networkError(CancellationError()), which is why both coordinators' catch is CancellationError is now documented as dead rather than made live.String(data:encoding:.utf8) of up to 10 MB remains on the main actor. Negligible in time, but it narrows what “the read now runs off the main thread” can honestly claim.prism/Services/URLDocumentLoader.swift
Why it matters. This is the actual bug fix. It bounds when `load` RETURNS, not merely when the winner is known — the distinction a task group cannot make, because a group implicitly awaits every child even after cancelAll(). For a socket-level stall that is the whole guarantee.
What to look at. URLDocumentLoader.swift:341-395 (withDeadline), :439-540 (DeadlineGate)
prism/Services/URLDocumentLoader.swift
Why it matters. Correctness, not speed. At ~40 us per main-actor hop and one hop per byte, a 10 MB body cannot finish inside any 30-second bound, so the size cap becomes unreachable and an oversized document surfaces as a timeout instead of `.contentTooLarge`. I reproduced this independently: with the attribute 0.096 s/MB, without it 42.119 s/MB, and the slow shape reports Thread.isMainThread == true at the end of the loop.
What to look at. URLDocumentLoader.swift:343 (`operation: @escaping @Sendable @concurrent () async throws -> T`)
prism/Services/URLDocumentLoader.swift
Why it matters. A 35-line comment installs a load-bearing correctness rule the author explicitly cannot explain, and a high-priority ticket to go find out. Measurement shows the rule is a proxy, not the constraint: the constraint is the CALLEE's isolation. Extracting the loop into a `nonisolated` helper is completely safe (0.104 s/MB); keeping it inline while calling any unannotated member of the enum is not.
What to look at. URLDocumentLoader.swift:167-193 (call-site comment), :304-325 (control-experiment claim), T-2260
prism/Services/URLDocumentLoader.swift
Why it matters. The previous round's blocker. Latching only `cancelFromCaller` dropped an early `resume` from a fast-failing operation, and the resulting hang was permanent and indistinguishable from a slow network.
What to look at. URLDocumentLoader.swift:463-540 (start / deliver / cancelFromCaller); tests at :658, :679
prismTests/URLDocumentLoaderTests.swift
Why it matters. Moving the drip loop off the protocol thread is what makes the slow-drip test falsifiable at all: startLoading() and stopLoading() are delivered on the same thread, so a drip that sleeps inline blocks the very thread cancellation must arrive on.
What to look at. URLDocumentLoaderTests.swift:178-247 (dripQueue), :485-497 (assertions)
prismTests/URLDocumentLoaderTests.swift
Why it matters. The old assertion (`throws: LoadError.self`) was being satisfied by a 30-second timeout rather than by the size cap. It is the branch's only regression detector for the off-main placement.
What to look at. URLDocumentLoaderTests.swift:403-418
A group awaits every child before its scope returns, even after cancelAll(), so it bounds when the winner is known rather than when the function returns. For an AsyncBytes iteration on a stalled socket that degrades to whatever the operation eventually does. Verified sound here: defer { session.invalidateAndCancel() } is what releases the real resource, so abandoning the loser is safe in this specific case and not in general.
Its operation is a @MainActor WKWebView call holding a WebViewPool slot; abandoning the loser would leak the slot, not merely the task. Tracked as T-2134, whose suggested fix bounds the operation itself rather than abandoning it. This reasoning holds — the added note is good documentation of a real limitation, and the two utilities should stay separate.
onCancel is a synchronous, non-async closure that Swift may invoke before the continuation closure has even run. Routing through an actor would need a Task { await … } hop from that call site, reopening an ordering gap between “the calling task was cancelled” and “the gate learned about it.” Sound.
So the ordering that caused the round-5 hang can be tested directly instead of by racing the scheduler. Justified — and the resulting tests are the strongest part of the branch. It does, however, leave a general-purpose concurrency primitive namespaced under a URL loader, where the next person needing one will not find it.
Not supported by measurement. The constraint is the callee's isolation, not the loop's textual location. A nonisolated helper is as fast as inline (0.104 s vs 0.096 s per MB); an unannotated one is 43.101 s per MB. See finding 1.
deadline: TimeInterval = 30, timeoutIntervalForRequest = 30, timeoutIntervalForResource = 30, request.timeoutInterval = 30. Nothing links them, so load(from:deadline: 60) compiles, reads as “give me a minute”, and is cut off at 30 s by the resource timeout with a URLError.timedOut the caller will misattribute. No rationale is recorded for leaving them independent.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | URLDocumentLoader.swift:167-193 — the "inline" rule misidentifies the constraint | The call-site comment installs a load-bearing rule ("the accumulation loop is written HERE, inside the closure, rather than in the streamDownload helper it used to live in… measured, not stylistic") and concludes "The underlying mechanism is NOT established", filing T-2260 at high priority to find it. I reproduced the experiment in the app target across six shapes with a 1 MB body, recording Thread.isMainThread at the end of each loop. Inline @concurrent: 0.096 s, off main. Unannotated static-func helper: 43.101 s, ON MAIN. Same helper marked `nonisolated`: 0.104 s, off main. Same helper marked `@concurrent nonisolated`: 0.102 s, off main. The cause is SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, set on the app target (project.pbxproj:425, 479) and not on the test target — URLDocumentLoader carries no `nonisolated`, so every member of it is implicitly @MainActor, and a call from the @concurrent closure into any such member hops the entire callee back to the main actor, one hop per byte. The shipped rule is therefore a proxy that correlates rather than the constraint, and it leaves the real trap open in the opposite direction: a maintainer who obeys it — keeps the loop inline — but calls any unannotated member of the enum from inside the loop reintroduces the ~400 s path while following the documented rule to the letter. Conversely, extracting into a `nonisolated` helper, which the comment forbids, is completely safe. | Replace the 35-line comment with the mechanism and the actual rule: nothing inside the @concurrent closure may call an unannotated member of URLDocumentLoader; helpers must be `nonisolated`. Better still, mark the enum (or at minimum load/withDeadline and any helper) `nonisolated`, which removes the hazard by construction and lets the loop live wherever reads best. Then close or re-scope T-2260 and drop it from high — its stated premise ("find the real cause") is answered, and its ImageLoader/SVGSourceLoader flag is misdirected: both are actors, so their loops run on their own executor and never had this exposure. |
| major | URLDocumentLoader.swift:321-325 — the isolating evidence is checkably false | "Byte-identical code compiled into the TEST target (which does not enable the feature) ran at 0.88 s in every shape, which is what isolates the cause…" The test target DOES enable it: prismTests sets SWIFT_APPROACHABLE_CONCURRENCY = YES in both Debug and Release (project.pbxproj:240, 266), exactly as the app target does. The setting it lacks is SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. The experiment's result is right and the conclusion survives; the attribution does not. This matters beyond pedantry: it is the single load-bearing piece of evidence in a 90-line rationale, it is falsifiable in thirty seconds, and a reader who checks it has no reason to trust the rest. | Rewrite as: the test target does not set SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so the same code there has no enclosing actor for an isolation-inheriting parameter to inherit; the app target does set it, which is what makes URLDocumentLoader itself @MainActor. Found independently by two reviewers. |
| major | CHANGELOG.md:25 — in-branch churn billed as a shipped fix | Verified against `git show origin/main:prism/Services/URLDocumentLoader.swift`: before this branch there was no end-to-end bound of any kind, only the two inactivity timeouts. So "the 10 MB size limit could not be reached within any 30-second bound at all, and a large remote document would have been abandoned as a timeout rather than downloaded" describes a failure mode that existed only between two commits ON THIS BRANCH — it was introduced by the deadline this same entry announces, and removed three commits later. No user ever experienced it. The branch's own history confirms it: contentTooLargeViaStreaming on origin/main asserts LoadError.self and passes; 7bfa0cbd tightened it and it turned red at 30.001 s, i.e. against the new deadline. The genuinely shipped, user-visible bug — a 10 MB remote document freezing the UI for ~2 minutes — IS stated correctly and should stay. | Cut from "— which means the 10 MB size limit" through "rather than downloaded", and reduce the final clause to "…and a file that really is over 10 MB is refused for its size in about a second rather than after two minutes." Keep the 0.88 s / 133 s measurement and the main-thread framing. |
| major | CHANGELOG.md:25 — the last sentence is self-falsifying | "Both the code and this entry record why that placement is load-bearing, because it is undone by an innocuous-looking change to one attribute." The entry records no such thing: it never names @concurrent, never says where the accumulation loop is written, and never explains why either matters. It also dangles an unnamed "one attribute" in a user-facing document, and — per finding 1 — the attribute is not actually the fragile part; the callee's isolation is. | Delete the sentence. If the warning is wanted it belongs in docs/agent-notes/ or CLAUDE.md, not the changelog. |
| minor | specs/open-from-url/ — a design divergence with named rejected alternatives and no decision-log entry | design.md:131 still says the 30-second timeout comes from URLRequest.timeoutInterval. There are now three mechanisms with different semantics, and the one the spec names is the one that does not enforce Req 3.1. The branch adds a second, authoritative, in-process deadline and demotes the URLSession timeouts to defence-in-depth, with two genuine rejected alternatives already written out in the source (withThrowingTaskGroup — tried and rejected in a43198ff; timeoutIntervalForResource alone) and a real consequence (an honest-but-slow large download now fails at 30 s where it previously completed). By the project's own decision-log-format rule — "if you cannot name a genuine alternative or a meaningful consequence, it is a quick decision" — this is a full Enhanced Nygard entry, not a quick row. | Add a full decision entry to specs/open-from-url/decision_log.md, and correct design.md:131 to list all three mechanisms. |
| minor | CHANGELOG.md:25 — the trade-off is not stated | The entry says the download is now bounded by a 30-second deadline but never states the consequence: a download that is progressing but genuinely slow — a large file over a poor connection — now fails where it previously completed. Both neighbouring entries (T-2132, T-2152) name their trade-offs explicitly; this one should too. | Append one sentence naming the new failure. Note that caller-cancellation should NOT be added: verified against origin/main, the pre-branch loader propagated cancellation structurally, so net-versus-main that behaviour is neutral and adding it would repeat the same in-branch-churn error. |
| minor | Two new test helpers duplicate three existing ones | ParkedContinuation (URLDocumentLoaderTests.swift:77) is AsyncSignal from RemoteRefreshFlowTests.swift:544 restricted to one waiter — AsyncSignal.wait() already handles the already-fired case, which is exactly the "a late park cannot leak either" property it was written for. CancellationRecorder (:30) is the same AsyncSignal plus an Int payload; its doc comment re-derives from scratch a rationale that variant already carries. The tell is at :585, where CancellationRecorder is used as a plain boolean with a comment admitting it: `operationStarted.markCancelled() // reused as a plain "did start" flag`. There are now five NSLock-flag helpers across four test files, and the precedent for sharing exists in this very file (it imports ChunkCounter from ImageLoaderTests). | Hoist AsyncSignal into a shared prismTests/TestSynchronization.swift with an optional payload on fire(value:), then delete both new classes and the duplicate AsyncSignal in RemoteContentCoordinatorTests. |
| minor | withDeadline / DeadlineGate are general primitives namespaced under a URL loader | Neither touches a URL, request, session, or LoadError. DeadlineGate had to be widened to internal purely for testability, and tests now read URLDocumentLoader.DeadlineGate<Int>(). The cost is discoverability: the next person needing a bounded async operation will find RenderingUtilities.withTimeout, sitting in a shared utilities enum, and will not find withDeadline — because nothing about "download a markdown file" suggests it. The argument for keeping the two implementations SEPARATE is sound and should stand; "don't unify" is not the same as "hide it inside a URL loader". | Move both into RenderingUtilities or a new prism/Services/Deadline.swift, beside withTimeout, so the added T-2134 note becomes a comparison a reader can act on at the point of choosing. |
| minor | Comment-to-code ratio: 217 comment lines to 117 code lines, much of it review archaeology | Density alone is not the finding — this project's CLAUDE.md is denser. The finding is that a large share documents the review PROCESS and drafts that were never merged, which does not survive the merge as useful context. Worst: :169-180, a twelve-line lab notebook ("measured seven times, across private/internal, with/without @concurrent…") whose actionable content is two lines; :287-302, sixteen lines opening "Before T-2138 review round 2, that cancellation only ever reached…", describing a bug in an unmerged implementation; :420-433, fourteen lines on a prior draft of the same unmerged class. Plus "round N" markers at tests :187, :520, :572, :629, :665. withDeadline carries 84 doc lines for a 35-line body. | Strip the round-N and prior-draft narration and the measurement log; keep the invariants (single-resume across three resumers; early resumers are latched; the isolation rule as corrected by finding 1). Roughly 100 lines with no loss of contract. If the history is worth keeping, specs/bugfixes/ is where it goes. |
| minor | URLDocumentLoader.swift:125/:146/:153/:158 — deadline is a parameter, the three 30s beside it are not | load(from:deadline: 60) compiles, reads as "give me a minute", and is cut off at 30 s by the hardcoded timeoutIntervalForResource, surfacing a URLError.timedOut the caller will attribute to the deadline they set. Nothing in the signature or doc comment warns, and nothing enforces deadline <= 30. The parameter also exists solely for tests, which widens the production API to inject a value production must never change. | Derive the session timeouts from the parameter (timeoutIntervalForResource = max(deadline, 1)). MockURLProtocol ignores session timeouts, so the 0.2 s deadline test is unaffected. |
| minor | URLDocumentLoader.swift:358 — UInt64(Double) traps on a large or infinite deadline | `Task.sleep(nanoseconds: UInt64(max(0, seconds) * 1_000_000_000))` guards negatives but not the upper end. seconds = .infinity, or anything past ~1.8e10, crashes rather than throwing. Not reachable in production today, but withDeadline is internal and — per the finding above — a general-purpose primitive other call sites will acquire, and "pass .infinity for no timeout" is the obvious thing someone tries. | Clamp, and prefer Task.sleep(for: .seconds(_)). |
| minor | Two of the seven new tests carry no .timeLimit, in a .serialized suite | slowDripBodyBoundedByDeadline (:424) and withDeadlinePropagatesCallerCancellation (:570) lack the trait the other five carry, in a suite whose own comment cites T-2219/T-2096. Both ARE bounded by construction today (60-chunk drip, 200x0.05 s poll, 300x10 ms loop), but every bound is internal to the test body — an edit that raises maxDripChunks or drops the iterations guard reintroduces the hazard silently, and the suite is .serialized, so a wedge stalls everything after it. | Add .timeLimit(.minutes(1)) to both. Two lines, and it makes the file's convention uniform. |
| minor | Stale cross-references to the deleted streamDownload | ImageLoader.swift:326 and SVGSourceLoader.swift:173 both say "Mirrors URLDocumentLoader.streamDownload so the three remote loaders share a consistent enforcement strategy"; docs/agent-notes/image-support.md:67 says the same. The symbol no longer exists. The agent-note one is worse than stale — it is an active invitation to re-extract the helper, which is precisely what the branch's own comment forbids. CLAUDE.md's rule is that a note you made stale should be fixed or deleted. | Point all three at the accumulation loop inside load's withDeadline closure, and carry the corrected isolation rule from finding 1 rather than the 'inline' proxy. |
| minor | Caller cancellation is re-wrapped as networkError, so both coordinators' catch is dead by design | gate.cancelFromCaller() resumes with CancellationError, which falls into load's general catch and surfaces as LoadError.networkError(CancellationError()) — user-visible text "Network error: The operation couldn't be completed." The branch adds a comment to RemoteContentCoordinator explaining that the existing `catch is CancellationError` is now unreachable and kept for injected loaders. That works (the !Task.isCancelled guard suppresses it), but documenting a catch clause as dead costs more than making it live. | Add `} catch is CancellationError { throw CancellationError() }` before the general arm in load; both coordinators' existing clause becomes correct again and the explanatory comment can go. |
| minor | Testing gaps | The default 30 s deadline is pinned by nothing — every deadline test injects a value, so changing the default to 300 leaves the suite green while violating Req 3.1. config.timeoutIntervalForResource = 30 is untested and trivially testable, since load mutates the caller-supplied configuration in place. No test drives a redirect THROUGH the deadline, though both the CHANGELOG and the comment claim it covers 'redirects and streaming together' (structurally true, but the two existing redirect tests reject immediately and consume no deadline). And contentTooLargeViaStreaming, the only regression detector for the isolation rule, carries no .timeLimit and no elapsed-time assertion — so when it regresses it fails at ~30 s with the wrong error case, and nothing in the failure names the cause. | Hoist the default to a static let and assert it; assert the resource timeout on the injected config; add a redirect-then-drip test; add .timeLimit plus an elapsed assertion to contentTooLargeViaStreaming. |
| nit | URLDocumentLoader.swift:488 and :473 — redundant conjuncts in DeadlineGate.start | The callerCancelledEarly branch is live and necessary — it is the only path that cancels the two tasks in the interleaving where cancelFromCaller locks first, reads both handles as nil, and is preempted before its own deliver. But the `deliver(.failure(CancellationError()))` at the end of that branch is redundant (cancelFromCaller's own deliver follows immediately and the gate honours the first either way), and the `!finished` conjunct at :473 is unreachable: finished is only set in deliver, and a deliver before start always latches into pendingResult. | Reduce the branch to the two cancels with a comment naming the interleaving; drop !finished or mark it defensive. |
| nit | prismTests/URLDocumentLoaderTests.swift:236 — mock can deliver didLoad: after stopLoading() | Moving the drip off the protocol thread is the right call and the reasoning is sound, but there is now a one-iteration window between isCancelled() returning false and the didLoad: call in which stopLoading() can arrive, so the mock can message its client after being told to stop. Shows up as flakiness rather than a clean failure. | Re-check !isCancelled() immediately before didLoad:, or acknowledge the window in the comment. |
| nit | URLDocumentLoader.swift:13 — doc header names the wrong API | "Streams the response via URLSession.bytes(from:)" — it is bytes(for:). Pre-existing, but the surrounding block is now touched territory. | One-word fix. |
Click to expand.
diff --git a/prism/Services/URLDocumentLoader.swift b/prism/Services/URLDocumentLoader.swiftindex 8f7fa3e1..4974bd1d 100644--- a/prism/Services/URLDocumentLoader.swift+++ b/prism/Services/URLDocumentLoader.swift@@ -113,11 +113,16 @@ enum URLDocumentLoader { /// - url: The user-provided URL to load. /// - sessionConfiguration: Optional URLSession configuration for testing. /// Defaults to `.ephemeral` for production use.+ /// - deadline: End-to-end seconds allowed for the whole download,+ /// including redirects and streaming the body. Defaults to 30 (Req+ /// 3.1). Overridable so tests can exercise the deadline without+ /// waiting the full production interval. /// - Returns: A `LoadResult` with the content and URL metadata. /// - Throws: `LoadError` for validation failures or network errors. static func load( from url: URL,- sessionConfiguration: URLSessionConfiguration? = nil+ sessionConfiguration: URLSessionConfiguration? = nil,+ deadline: TimeInterval = 30 ) async throws -> LoadResult { // Validate scheme before any network activity (Req 2.1, 7.5) guard let scheme = url.scheme?.lowercased(),@@ -139,6 +144,13 @@ enum URLDocumentLoader { let config = sessionConfiguration ?? .ephemeral // Set timeout on both config (applies to redirect requests) and URLRequest (initial request). config.timeoutIntervalForRequest = 30 // Req 3.1+ // `timeoutIntervalForRequest`/`URLRequest.timeoutInterval` are inactivity+ // timeouts only: a server trickling one byte per interval never trips+ // them. `timeoutIntervalForResource` bounds the whole task (redirects ++ // body) regardless of activity, matching the pattern already used by+ // 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) defer { session.invalidateAndCancel() }@@ -146,11 +158,63 @@ enum URLDocumentLoader { var request = URLRequest(url: transformed.fetchURL) request.timeoutInterval = 30 - // Download with streaming to enforce size limit (Decision 8)+ // 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, inside the closure, rather+ // than in the `streamDownload` helper it used to live in. That is+ // measured, not stylistic: `URLSession.AsyncBytes` yields one byte at+ // a time, so a 10 MB body is ten million suspension points, and in+ // this target's build configuration the per-suspension cost depends+ // on where the loop is written. Iterating 10,485,761 bytes served by+ // `MockURLProtocol` takes 0.87-1.34 s with the loop inline (measured+ // three times, three different closure shapes) and 86-273 s with the+ // identical body in a separate `async` helper called from this+ // closure (measured seven times, across `private`/`internal`,+ // with/without `@concurrent` on the helper and on `load`, and with+ // `Data`, `[UInt8]` and `Int`-counter accumulators). No overlap+ // between the two groups.+ //+ // The consequence is a correctness one, not a performance one: at+ // ~13 microseconds per byte 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.+ //+ // The underlying mechanism is NOT established — the split is+ // reproducible but the rule ("loop inline") is empirical, and the+ // slow branch's magnitude varies with host load. T-2260 tracks+ // finding the real cause and replacing this with chunked+ // accumulation, which would make the whole question moot by cutting+ // ten million suspension points down to about a hundred and sixty. let data: Data let response: URLResponse do {- (data, response) = try await streamDownload(request: request, session: session)+ (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)+ } } catch let error as LoadError { throw error } catch {@@ -190,36 +254,287 @@ enum URLDocumentLoader { ) } - /// Streams a download, accumulating data up to the size limit.+ /// Races `operation` against a `seconds`-second wall-clock deadline.+ ///+ /// Unlike `timeoutIntervalForRequest`/`timeoutIntervalForResource`, which+ /// depend on the URLSession/OS networking stack to fire, this deadline is+ /// enforced entirely in-process. What it actually guarantees is narrower+ /// than "the operation stops within `seconds`": it guarantees that+ /// **this function returns (or throws) within `seconds`**, regardless of+ /// what `operation` is doing at that moment. `operation` runs as an+ /// unstructured `Task`, deliberately not as a `withThrowingTaskGroup`+ /// child — a task group implicitly awaits every child before the group+ /// scope returns, even after `cancelAll()`, so a group-based race still+ /// blocks the caller until the losing child actually unwinds (T-2138+ /// review). If `operation` is slow to notice cancellation — e.g. a+ /// `URLSession.AsyncBytes` iteration reading a connection stalled at the+ /// socket level, which is the exact failure this deadline exists to+ /// bound — a task group degrades back to whatever `operation` eventually+ /// does, silently losing the "authoritative" guarantee. Racing an+ /// unstructured task via a continuation avoids that: on timeout we+ /// resume the continuation immediately, request cancellation of the+ /// losing task, and then abandon it — this function does not wait for it+ /// to finish. The loser cannot affect the result: `DeadlineGate` only+ /// honours the first resume, so anything the abandoned task later+ /// produces (success, failure, or its own cancellation) is discarded.+ /// The loser also cannot leak the connection on its own: `load`'s+ /// `defer { session.invalidateAndCancel() }` runs as soon as this+ /// function returns/throws — not once the abandoned task finishes — so+ /// the underlying `URLSessionTask` is torn down at the OS level+ /// immediately, independent of whether `AsyncBytes` ever observed+ /// cooperative cancellation.+ ///+ /// A THIRD source can end this race: the task calling `withDeadline`+ /// being cancelled — e.g. `RemoteContentCoordinator.cancelDownload()` /+ /// `RemoteRefreshFlow.cancel()` flipping their UI state immediately.+ /// Before T-2138 review round 2, that cancellation only ever reached the+ /// unstructured `workTask` through `withCheckedThrowingContinuation`'s+ /// own (nonexistent) propagation — a bare continuation does not observe+ /// the calling task's cancellation at all, so the abandoned operation+ /// kept running for up to the full `seconds` deadline even though the+ /// caller had already moved on. Wrapping the continuation in+ /// `withTaskCancellationHandler` (the project's own pattern —+ /// `ImageLoader.AsyncSemaphore.acquire`, `ImageMemoryGuard.acquire`,+ /// `MermaidRenderer.renderInternal`) fixes that: `onCancel` cancels both+ /// `workTask` and `timeoutTask` and resumes the gate with+ /// `CancellationError`, so this function returns promptly on caller+ /// cancellation instead of waiting out the remaining deadline.+ ///+ /// `operation` is `@concurrent` for a MEASURED reason: without it, an+ /// operation that suspends often is ~150x slower. This target builds with+ /// `SWIFT_APPROACHABLE_CONCURRENCY = YES`, which enables+ /// `NonisolatedNonsendingByDefault`, under which an `async` function —+ /// including an `async` function-type PARAMETER like this one — is+ /// `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.+ ///+ /// 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+ /// 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 (which does not+ /// enable the feature) ran at 0.88 s in every shape, which is what+ /// isolates the cause to the isolation-inheriting parameter rather than+ /// to `AsyncBytes`, to `Data.append`, or to the shape of the race.+ ///+ /// The attribute is necessary but not sufficient: see the note at+ /// `load`'s `withDeadline` call for why the accumulation loop is written+ /// inside the closure. 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).+ ///+ /// `RenderingUtilities.withTimeout` is the same idea in the+ /// `withThrowingTaskGroup` shape this comment argues against, and it is+ /// deliberately NOT changed here. Its exposure is tracked separately as+ /// T-2134 (SVG snapshot timeout can hang past its deadline), and its+ /// operation is a `@MainActor` `WKWebView` call holding a `WebViewPool`+ /// slot, so abandoning the loser there leaks a pool slot rather than+ /// merely a task — a different fix from this one, not a mechanical+ /// port of it.+ static func withDeadline<T: Sendable>(+ seconds: TimeInterval,+ operation: @escaping @Sendable @concurrent () async throws -> T+ ) async throws -> T {+ let gate = DeadlineGate<T>()+ return try await withTaskCancellationHandler {+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<T, Error>) in+ let workTask = Task {+ do {+ let value = try await operation()+ gate.resume(returning: value)+ } catch {+ gate.resume(throwing: error)+ }+ }++ let timeoutTask = Task {+ try? await Task.sleep(nanoseconds: UInt64(max(0, seconds) * 1_000_000_000))+ guard !Task.isCancelled else { return }+ // Resume FIRST, cancel second. `Task.cancel()` returns+ // immediately without waiting, so a `workTask` unwinding+ // concurrently on another core could otherwise reach+ // `gate.resume(throwing: URLError(.cancelled))` before+ // this line and win the gate, flipping the surfaced error+ // away from `.timedOut`. The cancel is cleanup, not the+ // result, so it has no reason to go first.+ gate.resume(throwing: URLError(.timedOut))+ workTask.cancel()+ }++ // Registers both tasks with the gate so `onCancel` (below)+ // can reach them, and hands over the continuation. This does+ // NOT assume it wins a race against the two tasks above: a+ // freshly created `Task` body is scheduled rather than run+ // inline, but "not run inline" constrains only THIS thread —+ // another core can pick the body up and reach `gate.resume`+ // while this thread is still between statements. The gate+ // therefore latches a result that arrives before `start`+ // and `start` delivers it, exactly as it does for an early+ // `cancelFromCaller`.+ gate.start(continuation: continuation, work: workTask, timeout: timeoutTask)++ // Cleanup only: once the work task finishes on its own (before+ // the deadline), stop the now-pointless sleeping timeout task.+ // This does not gate `withDeadline`'s own return — the+ // continuation above already resumed via `gate`.+ Task {+ _ = await workTask.result+ timeoutTask.cancel()+ }+ }+ } onCancel: {+ gate.cancelFromCaller()+ }+ }++ /// Resumes a `CheckedContinuation` at most once, across all three+ /// possible resumers: `operation` finishing, `timeoutTask`'s deadline+ /// firing, or the calling task being cancelled.+ ///+ /// `withDeadline` races two unstructured tasks against the same+ /// continuation; whichever calls `resume` first wins, and the loser's+ /// call becomes a silent no-op instead of the double-resume trap+ /// (`CheckedContinuation` traps on a second resume). Caller cancellation+ /// is a third resumer with the same requirement, delivered via+ /// `withTaskCancellationHandler`'s `onCancel` — a plain, non-`async`+ /// closure that Swift can invoke immediately, before `withDeadline`'s+ /// `withCheckedThrowingContinuation` closure has even run, if the+ /// calling task was already cancelled at the point+ /// `withTaskCancellationHandler` was entered. That is why this is a+ /// two-phase, lock-protected class rather than an actor: `init()`+ /// creates it with nothing to cancel yet, `start` supplies the+ /// continuation and tasks once they exist, and `cancelFromCaller` can+ /// land before, during, or after `start` — a plain `NSLock` makes every+ /// method here synchronous and callable directly from `onCancel` and+ /// from the continuation closure, neither of which is `async`. Routing+ /// through an actor instead would need a `Task { await ... }` hop from+ /// each of those call sites, reopening an ordering gap between "the+ /// calling task was cancelled" and "the gate learned about it." ///- /// Uses `URLSession.bytes(from:)` to avoid loading oversized responses- /// into memory. Checks `Content-Length` header for early rejection.- private static func streamDownload(- request: URLRequest,- session: URLSession- ) async throws -> (Data, URLResponse) {- let (bytes, response) = try await session.bytes(for: request)-- // Early rejection via Content-Length header when available (Req 7.3)- let declaredLength = (response 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 data = Data()- data.reserveCapacity(min(declaredLength ?? 65_536, maxContentSize))-- for try await byte in bytes {- data.append(byte)- if data.count > maxContentSize {- throw LoadError.contentTooLarge+ /// Every one of the three resumers can arrive before `start` has supplied+ /// the continuation, and all three are latched the same way. That+ /// symmetry is the invariant, not a refinement: latching only+ /// `cancelFromCaller` (as this class originally did) silently DROPPED an+ /// early `resume`, and the resulting hang is permanent — `start` then+ /// records a continuation nobody will ever resume again, the cleanup task+ /// sees `workTask` already finished and cancels `timeoutTask`, and+ /// `timeoutTask`'s `guard !Task.isCancelled` returns without resuming.+ /// The window is widest exactly where `operation` fails fast (an+ /// immediate throw from `session.bytes(for:)`, a `Content-Length`+ /// rejection, a mock-backed instant response), and its symptom — a load+ /// that never returns — is indistinguishable from a slow network+ /// (T-2138 review round 5).+ ///+ /// Visibility is `internal` rather than `private` so the ordering that+ /// caused that bug can be tested directly: reaching it through+ /// `withDeadline` means racing the scheduler, which is exactly what a+ /// regression test must not do.+ final class DeadlineGate<T: Sendable>: @unchecked Sendable {+ private let lock = NSLock()+ private var continuation: CheckedContinuation<T, Error>?+ private var workTask: Task<Void, Never>?+ private var timeoutTask: Task<Void, Never>?+ private var callerCancelledEarly = false+ /// The winning result, when it was produced before `start` handed+ /// over the continuation. Delivered by `start`.+ private var pendingResult: Result<T, Error>?+ /// Set by the first resumer, whether it delivered or latched. Every+ /// later resumer is a no-op, which is what keeps the single-resume+ /// guarantee across `operation`, the deadline, and caller+ /// cancellation.+ private var finished = false++ /// Wires up the continuation and the two racing tasks together.+ ///+ /// If a result already arrived — either a racing task's `resume` or+ /// an early `cancelFromCaller()`, i.e. the calling task was cancelled+ /// before `operation`/`timeoutTask` existed — it is delivered here+ /// instead of being dropped, and both tasks are cancelled rather than+ /// left running unobserved. Cancelling a task that has already+ /// produced its result is a no-op, so the two cases need no+ /// distinction.+ func start(+ continuation: CheckedContinuation<T, Error>,+ work: Task<Void, Never>,+ timeout: Task<Void, Never>+ ) {+ lock.lock()+ workTask = work+ timeoutTask = timeout+ let latched = pendingResult+ pendingResult = nil+ if latched == nil && !finished {+ self.continuation = continuation }+ let cancelledEarly = callerCancelledEarly+ lock.unlock()++ if let latched {+ work.cancel()+ timeout.cancel()+ continuation.resume(with: latched)+ return+ }+ if cancelledEarly {+ work.cancel()+ timeout.cancel()+ deliver(.failure(CancellationError()))+ }+ }++ func resume(returning value: T) {+ deliver(.success(value)) } - return (data, response)+ func resume(throwing error: Error) {+ deliver(.failure(error))+ }++ /// Honours the first result only. Delivers it if the continuation is+ /// already here, latches it for `start` if it is not.+ private func deliver(_ result: Result<T, Error>) {+ lock.lock()+ guard !finished else {+ lock.unlock()+ return+ }+ finished = true+ let pending = continuation+ continuation = nil+ if pending == nil {+ pendingResult = result+ }+ lock.unlock()+ // Resumed outside the lock: the continuation's resumption can run+ // arbitrary caller code, and the lock is never held across a+ // call-out.+ pending?.resume(with: result)+ }++ /// Invoked from `onCancel` when the calling task — not `operation`+ /// — is cancelled. Cancels the abandoned operation and the+ /// now-pointless timeout, then resumes with `CancellationError` so+ /// `withDeadline` returns immediately instead of waiting out the+ /// remaining deadline. If `start` hasn't run yet, `deliver` latches+ /// the result and `callerCancelledEarly` records that the tasks still+ /// need cancelling once they exist.+ func cancelFromCaller() {+ lock.lock()+ callerCancelledEarly = true+ let work = workTask+ let timeout = timeoutTask+ lock.unlock()+ work?.cancel()+ timeout?.cancel()+ deliver(.failure(CancellationError()))+ } } /// Validates HTTP redirects only follow http/https schemes and have no
diff --git a/prismTests/URLDocumentLoaderTests.swift b/prismTests/URLDocumentLoaderTests.swiftindex 85893f32..8a52cd8b 100644--- a/prismTests/URLDocumentLoaderTests.swift+++ b/prismTests/URLDocumentLoaderTests.swift@@ -15,10 +15,102 @@ enum MockURLProtocolResult { case redirect(HTTPURLResponse, URLRequest) /// Streams the body lazily: the closure is invoked repeatedly and returns /// the next chunk until it returns `nil`. Lets tests exceed a cap without- /// allocating the full payload up front.- case streamed(HTTPURLResponse, () -> Data?)+ /// allocating the full payload up front. `onCancelled`, when provided, is+ /// invoked once `stopLoading()` is observed (i.e. the URL Loading System+ /// cancelled the underlying task) — tests that need to prove a load was+ /// actually torn down, rather than merely finishing on its own, use it.+ case streamed(HTTPURLResponse, () -> Data?, onCancelled: (() -> Void)? = nil) } +/// Thread-safe one-shot flag with a bounded async poll.+///+/// Used to prove a background callback (e.g. `MockURLProtocol`'s+/// `onCancelled`, invoked from `startLoading()`'s own thread) actually fired,+/// rather than inferring it indirectly from wall-clock timing.+final class CancellationRecorder: @unchecked Sendable {+ private let lock = NSLock()+ private var marked = false+ private var observedValue: Int?++ /// - Parameter value: optional payload captured at the moment the+ /// callback fired (e.g. how many chunks the mock had emitted), read+ /// back with `markedValue()`. The lock that publishes `marked` also+ /// publishes this, so a reader that has seen `isMarked() == true`+ /// sees the value that was stored with it.+ func markCancelled(value: Int? = nil) {+ lock.lock()+ marked = true+ observedValue = value+ lock.unlock()+ }++ func markedValue() -> Int? {+ lock.lock()+ defer { lock.unlock() }+ return observedValue+ }++ func isMarked() -> Bool {+ lock.lock()+ defer { lock.unlock() }+ return marked+ }++ /// Polls up to `attempts` times, `pollInterval` seconds apart, returning+ /// `true` as soon as `markCancelled()` has been observed.+ func waitUntilMarked(pollInterval: TimeInterval, attempts: Int) async -> Bool {+ for _ in 0..<attempts {+ if isMarked() { return true }+ try? await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000))+ }+ return isMarked()+ }+}++/// Holds a continuation an "uncooperative operation" test suspends on, so the+/// test can resume it exactly once during teardown.+///+/// Without this, such a test leaves a `CheckedContinuation` unresumed and the+/// runtime prints `SWIFT TASK CONTINUATION MISUSE: ... leaked its+/// continuation without resuming it` into every run's log — a line that looks+/// like a production defect to anyone scanning the output.+final class ParkedContinuation: @unchecked Sendable {+ private let lock = NSLock()+ private var continuation: CheckedContinuation<Void, Never>?+ private var released = false++ /// Parks `continuation`. If `release()` already ran, resumes immediately+ /// so a late park cannot leak either.+ func park(_ continuation: CheckedContinuation<Void, Never>) {+ lock.lock()+ if released {+ lock.unlock()+ continuation.resume()+ return+ }+ self.continuation = continuation+ lock.unlock()+ }++ /// Resumes the parked continuation, if any. Idempotent.+ func release() {+ lock.lock()+ released = true+ let pending = continuation+ continuation = nil+ lock.unlock()+ pending?.resume()+ }+}++/// Resumes a `DeadlineGate` probe that would otherwise never be resumed, so a+/// regression fails in seconds instead of hanging the shared test host.+private struct GateRescueError: Error {}++/// Thrown by an operation that fails the instant it starts — the ordering+/// where a dropped early `resume` used to hang forever.+private struct ImmediateOperationFailure: Error {}+ /// A per-suite handle onto `MockURLProtocol`'s handler registry. /// /// Assign `scope.handler` exactly as a test would once have assigned@@ -83,6 +175,44 @@ final class MockURLProtocol: URLProtocol { override static func canInit(with request: URLRequest) -> Bool { true } override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } + /// Serial queue that runs a `.streamed` response's drip loop.+ ///+ /// The drip MUST NOT run inline in `startLoading()`. The URL Loading+ /// System delivers `startLoading()` and `stopLoading()` to a protocol+ /// instance on the SAME thread, so a drip loop that sleeps between+ /// chunks inside `startLoading()` blocks the very thread `stopLoading()`+ /// has to arrive on: `wasCancelled` cannot flip until the loop has+ /// already run to completion, and `onCancelled` therefore never fires no+ /// matter how promptly the production code tears the session down. That+ /// is exactly what T-2138 round 4 measured — `invalidateAndCancel()` ran+ /// 2 ms after the injected deadline, while `stopLoading()` was not+ /// delivered until 3.44 s later, one millisecond AFTER the loop's exit+ /// check had already read `false`. Dripping from this queue leaves the+ /// protocol thread free, which is also the more faithful model of a real+ /// trickling server (a server does not hold the client's loading thread).+ private let dripQueue = DispatchQueue(label: "MockURLProtocol.drip")++ /// Set by `stopLoading()`, checked between chunks of a `.streamed`+ /// response's drip loop so the mock can react to real cancellation+ /// (delivered by the URL Loading System when the owning `URLSessionTask`+ /// is cancelled) instead of running the drip loop to completion+ /// regardless. Guarded because `stopLoading()` is called from a+ /// different thread than the one running the drip loop.+ private let cancelLock = NSLock()+ nonisolated(unsafe) private var wasCancelled = false++ private func markCancelled() {+ cancelLock.lock()+ wasCancelled = true+ cancelLock.unlock()+ }++ private func isCancelled() -> Bool {+ cancelLock.lock()+ defer { cancelLock.unlock() }+ return wasCancelled+ }+ override func startLoading() { let scope = request.value(forHTTPHeaderField: Self.scopeHeader) ?? "" guard let handler = Self.handler(forScope: scope) else {@@ -99,19 +229,30 @@ final class MockURLProtocol: URLProtocol { case .redirect(let response, let redirectedRequest): client?.urlProtocol(self, wasRedirectedTo: redirectedRequest, redirectResponse: response) client?.urlProtocolDidFinishLoading(self)- case .streamed(let response, let nextChunk):+ case .streamed(let response, let nextChunk, let onCancelled): client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)- while let chunk = nextChunk() {- client?.urlProtocol(self, didLoad: chunk)+ // Hand the body off to `dripQueue` and return, so the+ // protocol thread stays free to deliver `stopLoading()`+ // while the body is still trickling (see `dripQueue`).+ dripQueue.async {+ while !self.isCancelled(), let chunk = nextChunk() {+ self.client?.urlProtocol(self, didLoad: chunk)+ }+ if self.isCancelled() {+ onCancelled?()+ } else {+ self.client?.urlProtocolDidFinishLoading(self)+ } }- client?.urlProtocolDidFinishLoading(self) } } catch { client?.urlProtocol(self, didFailWithError: error) } } - override func stopLoading() {}+ override func stopLoading() {+ markCancelled()+ } } /// Tests for URLDocumentLoader, which downloads and validates markdown from URLs.@@ -259,12 +400,332 @@ struct URLDocumentLoaderTests { .response(self.mockResponse(url: url), oversizedData) } - await #expect(throws: URLDocumentLoader.LoadError.self) {+ // Assert the CASE, not the type. `LoadError.self` is satisfied just as+ // well by `.networkError(URLError.timedOut)`, so once T-2138 added an+ // end-to-end deadline to the same call this test could no longer tell+ // the reader which mechanism fired — and it was in fact taking exactly+ // 30.002 s, a timer value (T-2138 review round 5, T-2260).+ await #expect { _ = try await URLDocumentLoader.load( from: url, sessionConfiguration: mockSessionConfig() )+ } throws: { error in+ guard let loadError = error as? URLDocumentLoader.LoadError,+ case .contentTooLarge = loadError else {+ return false+ }+ return true+ }+ }++ // MARK: - End-to-End Deadline (T-2138)++ @Test("Slow-drip body bypassing inactivity timeouts is bounded by the explicit deadline")+ func slowDripBodyBoundedByDeadline() async throws {+ let url = URL(string: "https://example.com/slow.md")!+ let maxDripChunks = 60+ let counter = ChunkCounter()+ let cancellation = CancellationRecorder()+ // Each byte arrives well within any per-request inactivity timeout,+ // but the body never finishes on its own within the test's short+ // deadline — simulating a server trickling bytes to hold the+ // connection open indefinitely (the T-2138 bug shape). Bounded to+ // `maxDripChunks` (3s of real drip) so the mock protocol's drip loop+ // still terminates on its own if the deadline enforcement regresses,+ // rather than hanging the test process forever. The drip runs on+ // `MockURLProtocol`'s own queue, NOT the protocol thread — see+ // `MockURLProtocol.dripQueue`; sleeping on the protocol thread is+ // what made this test unfalsifiable in T-2138 rounds 2-4.+ mockScope.handler = { _ in+ .streamed(self.mockResponse(url: url), {+ guard counter.bytesSent < maxDripChunks else { return nil }+ counter.bytesSent += 1+ Thread.sleep(forTimeInterval: 0.05)+ return Data("a".utf8)+ }, onCancelled: {+ // Runs on the drip queue, the same queue that mutates+ // `counter`, so this read is ordered against the writes above.+ cancellation.markCancelled(value: counter.bytesSent)+ })+ }++ let start = Date()+ await #expect {+ _ = try await URLDocumentLoader.load(+ from: url,+ sessionConfiguration: mockSessionConfig(),+ deadline: 0.2+ )+ } throws: { error in+ // Assert the specific underlying error, not just the LoadError+ // case: `.networkError` also wraps ordinary connection failures,+ // so only `.timedOut` proves this was the deadline (T-2138 review).+ guard let loadError = error as? URLDocumentLoader.LoadError,+ case .networkError(let underlying) = loadError,+ let urlError = underlying as? URLError else {+ return false+ }+ return urlError.code == .timedOut+ }++ // Primary assertion: the mock's drip loop must actually be torn down+ // (its `stopLoading()` observed and recorded), not merely have run to+ // completion on its own schedule while `load` happened to have+ // already thrown by then. This is deterministic — it either happened+ // or it didn't — unlike a wall-clock reading, which can look "fast+ // enough" purely by coincidence on a loaded machine.+ let cancelledInTime = await cancellation.waitUntilMarked(pollInterval: 0.05, attempts: 200)+ #expect(cancelledInTime, "mock never observed stopLoading() — the drip loop ran to completion instead")++ // And the teardown must have CUT THE BODY SHORT, not merely landed+ // after it finished: the chunk count captured when `stopLoading()`+ // was observed has to be below the drip's own 60-chunk bound. Without+ // this, a mock that only ever noticed cancellation at the very end of+ // its 3s drip would still satisfy the flag above.+ let chunksAtCancellation = cancellation.markedValue()+ #expect(+ (chunksAtCancellation ?? maxDripChunks) < maxDripChunks,+ "teardown landed only after the body finished (\(chunksAtCancellation ?? -1) of \(maxDripChunks) chunks)"+ )++ // Secondary, generous guard: the production default deadline is 30s,+ // so a 10s ceiling still leaves a wide margin over the 0.2s injected+ // deadline without being a tight timing assertion on its own. The poll+ // above returns as soon as the flag is set, so this only ever fires+ // when nothing was torn down at all — it is a ceiling, not a+ // measurement of how loaded the host is.+ let elapsed = Date().timeIntervalSince(start)+ #expect(elapsed < 10.0)+ }++ @Test("withDeadline returns on time even when the operation ignores cooperative cancellation",+ .timeLimit(.minutes(1)))+ func withDeadlineReturnsWithoutWaitingForUncooperativeOperation() async throws {+ // The time limit is not decoration. The operation below never+ // completes on its own, so a regression in `withDeadline` does not+ // fail this test — it wedges the whole run. One process hosts the+ // entire unit-test target, and this project has lost whole runs to+ // exactly that (T-2219, T-2096); `WebViewPoolTests` carries the same+ // trait for the same reason.+ // An unresumed `CheckedContinuation` stands in for the review's+ // exact concern: a `URLSession.AsyncBytes` iteration reading a+ // connection stalled at the socket level, which likewise will not+ // notice Swift-level cancellation promptly. This replaces an+ // earlier version that used `Thread.sleep(forTimeInterval: 1.0)`+ // for the same purpose: `Thread.sleep` blocks a real OS thread out+ // of Swift's cooperative pool for the full second, and this suite+ // runs alongside other concurrently-executing test suites on the+ // same limited pool, so a one-second thread-block is a real cost,+ // not just a slow test (non-blocking review note, T-2138 round 2).+ // Suspending on a continuation that is deliberately never resumed+ // holds no thread and never completes on its own — a stronger proof+ // that `withDeadline` doesn't wait for the operation than a timed+ // sleep, since there's no wall-clock instant the operation could+ // ever finish by. Calling `URLDocumentLoader.withDeadline` directly+ // (rather than through the full `load`/`MockURLProtocol` path) is+ // what makes this deterministic: `MockURLProtocol` only controls+ // data delivery, not whether `URLSession.bytes(for:)`'s internal+ // cancellation plumbing is prompt, so no amount of mock cleverness+ // can otherwise prove the guarantee this test is checking.+ //+ // The continuation is parked in a holder rather than simply dropped+ // so teardown can resume it. Dropping it printed+ // "SWIFT TASK CONTINUATION MISUSE: ... leaked its continuation+ // without resuming it" into every run's log, which reads like a+ // production defect to anyone scanning the output. Releasing it after+ // the assertions changes nothing about what is proven: `withDeadline`+ // has already returned by then, and the operation's late+ // `gate.resume` is a no-op.+ let parked = ParkedContinuation()+ let start = Date()+ await #expect {+ _ = try await URLDocumentLoader.withDeadline(seconds: 0.1) {+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in+ // Not resumed until teardown — for the whole duration of+ // the deadline race the operation ignores cancellation+ // and never completes.+ parked.park(continuation)+ }+ return "unreachable"+ }+ } throws: { error in+ (error as? URLError)?.code == .timedOut+ }+ let elapsed = Date().timeIntervalSince(start)+ parked.release()++ // This is the assertion that actually distinguishes the fix from the+ // withThrowingTaskGroup-based implementation it replaced: a+ // group-based race implicitly awaits every child — including the+ // still-suspended one — before the group scope can return, so it+ // would hang forever here regardless of which branch "won" the race+ // internally. Any finite bound discriminates that, so the ceiling is+ // deliberately generous rather than a timing measurement: 2 s over a+ // 0.1 s deadline cannot be reached by a loaded host, only by a+ // regression.+ #expect(elapsed < 2.0)+ }++ @Test("withDeadline propagates cancellation of the calling task into the operation")+ func withDeadlinePropagatesCallerCancellation() async throws {+ // T-2138 review round 2: the unstructured `workTask` plus a bare+ // `withCheckedThrowingContinuation` did not observe cancellation of+ // the task CALLING `withDeadline` — only its own 30s deadline and+ // the operation finishing/throwing could resume it. That meant+ // `RemoteContentCoordinator.cancelDownload()` /+ // `RemoteRefreshFlow.cancel()` flipped the UI immediately while the+ // abandoned network operation kept running underneath for up to the+ // full deadline. This test calls `withDeadline` from a `Task` and+ // cancels that `Task` mid-flight — the operation is cooperative+ // (polls `Task.isCancelled`, unlike the uncooperative-operation+ // test above) specifically so this test can prove propagation, not+ // just that `withDeadline` forgets about an abandoned task.+ let operationStarted = CancellationRecorder()+ let operationObservedCancellation = CancellationRecorder()++ let task = Task {+ try await URLDocumentLoader.withDeadline(seconds: 5.0) {+ operationStarted.markCancelled() // reused as a plain "did start" flag+ // Bounded to ~3s (300 * 10ms) so a regression — cancellation+ // never reaching this operation — makes the loop exit on+ // its own bound rather than the test hanging until the 5s+ // deadline; the assertions below still correctly fail in+ // that case (elapsed and "observed" both prove the miss).+ var iterations = 0+ while !Task.isCancelled, iterations < 300 {+ try? await Task.sleep(nanoseconds: 10_000_000)+ iterations += 1+ }+ if Task.isCancelled {+ operationObservedCancellation.markCancelled()+ }+ throw CancellationError()+ }+ }++ let started = await operationStarted.waitUntilMarked(pollInterval: 0.01, attempts: 100)+ #expect(started, "operation never started")++ let start = Date()+ task.cancel()++ await #expect {+ _ = try await task.value+ } throws: { error in+ error is CancellationError+ }+ let elapsed = Date().timeIntervalSince(start)++ // Must return promptly — nowhere near the 5s deadline or the+ // operation's ~3s bound — proving cancellation reached `withDeadline`+ // directly rather than falling back to either.+ #expect(elapsed < 1.0)++ let observed = await operationObservedCancellation.waitUntilMarked(pollInterval: 0.01, attempts: 100)+ #expect(observed, "operation never observed cancellation — withDeadline did not cancel workTask")+ }++ // MARK: - DeadlineGate ordering (T-2138 review round 5)++ /// Runs `body` — which must arrange for `gate` to be started — and returns+ /// its outcome, resuming the gate with `GateRescueError` after two+ /// seconds if nothing else has.+ ///+ /// The rescue is what makes these tests FAIL rather than HANG on the+ /// pre-fix gate, where `start` unconditionally stored the continuation+ /// (so a later `resume` still reaches it) but an earlier one was dropped.+ /// On the fixed gate the rescue is a no-op: the result has already been+ /// delivered and the gate honours only the first resume.+ private func gateOutcome<T: Sendable>(+ _ gate: URLDocumentLoader.DeadlineGate<T>,+ _ body: @escaping @Sendable (CheckedContinuation<T, Error>) -> Void+ ) async -> Result<T, Error> {+ let probe = Task<T, Error> {+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<T, Error>) in+ body(continuation)+ }+ }+ let rescue = Task {+ try? await Task.sleep(nanoseconds: 2_000_000_000)+ gate.resume(throwing: GateRescueError())+ }+ let outcome = await probe.result+ rescue.cancel()+ return outcome+ }++ @Test("DeadlineGate delivers a value that arrived before start",+ .timeLimit(.minutes(1)))+ func deadlineGateDeliversValueLatchedBeforeStart() async {+ // The ordering `withDeadline` cannot rule out and used to lose:+ // `workTask` reaches `gate.resume` before the creating thread reaches+ // `gate.start`. Forced here rather than raced, because racing the+ // scheduler is exactly what a regression test must not do — that+ // window is why the defect survived four review rounds.+ let gate = URLDocumentLoader.DeadlineGate<Int>()+ gate.resume(returning: 42)++ let outcome = await gateOutcome(gate) { continuation in+ gate.start(continuation: continuation, work: Task {}, timeout: Task {})+ }++ #expect(+ (try? outcome.get()) == 42,+ "gate dropped a value that arrived before start — the continuation would never be resumed"+ )+ }++ @Test("DeadlineGate delivers a failure that arrived before start",+ .timeLimit(.minutes(1)))+ func deadlineGateDeliversFailureLatchedBeforeStart() async {+ // Same ordering, failure side: the window is WIDEST when `operation`+ // fails fast (an immediate throw from `session.bytes(for:)`, a+ // Content-Length rejection, a mock-backed instant response), because+ // there is nothing to slow the work task down before it resumes.+ let gate = URLDocumentLoader.DeadlineGate<Int>()+ gate.resume(throwing: ImmediateOperationFailure())++ let outcome = await gateOutcome(gate) { continuation in+ gate.start(continuation: continuation, work: Task {}, timeout: Task {})+ }++ guard case .failure(let error) = outcome else {+ Issue.record("gate delivered a value where the operation had thrown")+ return+ }+ #expect(+ error is ImmediateOperationFailure,+ "gate dropped a failure that arrived before start (got \(error))"+ )+ }++ @Test("withDeadline returns promptly for an operation that completes immediately",+ .timeLimit(.minutes(1)))+ func withDeadlineReturnsImmediateSuccess() async throws {+ // End-to-end shape of the ordering above, at the production deadline:+ // if the early resume were dropped, nothing would ever resume the+ // continuation (the cleanup task cancels the timeout task as soon as+ // the work task finishes), so this would hang rather than time out+ // after 30 s.+ let start = Date()+ let value = try await URLDocumentLoader.withDeadline(seconds: 30) { "done" }+ #expect(value == "done")+ #expect(Date().timeIntervalSince(start) < 2.0)+ }++ @Test("withDeadline surfaces an operation that throws immediately",+ .timeLimit(.minutes(1)))+ func withDeadlineSurfacesImmediateThrow() async {+ let start = Date()+ await #expect {+ _ = try await URLDocumentLoader.withDeadline(seconds: 30) { () async throws -> String in+ throw ImmediateOperationFailure()+ }+ } throws: { error in+ error is ImmediateOperationFailure }+ #expect(Date().timeIntervalSince(start) < 2.0) } // MARK: - Encoding Validation
diff --git a/prism/Services/RenderingUtilities.swift b/prism/Services/RenderingUtilities.swiftindex 87bde6f1..309a3bc3 100644--- a/prism/Services/RenderingUtilities.swift+++ b/prism/Services/RenderingUtilities.swift@@ -30,6 +30,23 @@ enum RenderingUtilities { /// Races `operation` against a `seconds`-second timeout. If the timeout /// fires first, the supplied `timeoutError` is thrown. The losing branch /// is cancelled.+ ///+ /// Known limitation, deliberately not fixed here: `withThrowingTaskGroup`+ /// implicitly awaits every child before the group scope returns, even+ /// after `cancelAll()`. This therefore bounds when the WINNER is known,+ /// not when this function RETURNS — if `operation` is slow to notice+ /// cooperative cancellation, the caller waits for it anyway and the+ /// timeout degrades to whatever the operation eventually does.+ /// `URLDocumentLoader.withDeadline` (T-2138) exists because that+ /// degradation is unacceptable for a network read, and races an+ /// unstructured task through a single-resume continuation instead.+ /// That fix is NOT mechanically portable here: `operation` is a+ /// `@MainActor` `WKWebView` call holding a `WebViewPool` slot, so+ /// abandoning the loser would leak the slot rather than just the task.+ /// The exposure is tracked as T-2134 (SVG snapshot timeout can hang past+ /// its deadline), whose suggested fix bounds the operation itself — a+ /// token-validated timeout bridge inside `WebViewPool.captureSnapshot` —+ /// rather than abandoning it. static func withTimeout<T: Sendable>( seconds: UInt64, timeoutError: @autoclosure @Sendable @escaping () -> any Error & Sendable,
diff --git a/prism/ViewModels/RemoteContentCoordinator.swift b/prism/ViewModels/RemoteContentCoordinator.swiftindex 8fede38a..9a9d49e5 100644--- a/prism/ViewModels/RemoteContentCoordinator.swift+++ b/prism/ViewModels/RemoteContentCoordinator.swift@@ -95,7 +95,13 @@ final class RemoteContentCoordinator { fragment: fragment ) } catch is CancellationError {- // Silently ignore cancellation+ // Unreachable with the production loader: `URLDocumentLoader.load`+ // wraps every non-`LoadError` — including `URLError.cancelled` and+ // `CancellationError` — as `LoadError.networkError`. Kept for+ // injected loaders. Real cancellation is suppressed by the+ // `!Task.isCancelled` guard in the catch below, which is a+ // property of this caller rather than of the loader+ // (matches `RemoteRefreshFlow`). } catch { guard !Task.isCancelled else { return } flowCoordinator.loadError = error.localizedDescription
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bb517458..31f674e7 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A remote document opened from a URL is no longer left downloading indefinitely against a server that trickles the body slowly enough to dodge the 30-second timeout (T-2138). The timeout applied only to network inactivity, so a byte sent just before each interval elapsed kept the load open with no end-to-end bound; the whole download is now also bounded by an explicit 30-second deadline covering redirects and streaming together. Adding that deadline also surfaced, and fixed, a far larger problem it would otherwise have masked: reading the downloaded body was running on the main thread, where it is roughly 150 times slower. Measured on this project's own build, reading a 10 MB body takes 0.88 seconds off the main thread and 133 seconds on it — which means the 10 MB size limit could not be reached within any 30-second bound at all, and a large remote document would have been abandoned as a timeout rather than downloaded. The read now genuinely runs off the main thread, so a large remote document opens in about a second instead of holding the interface still for minutes, and a file that really is over 10 MB is refused for its size, with the message that says so, rather than as a network timeout. Both the code and this entry record why that placement is load-bearing, because it is undone by an innocuous-looking change to one attribute. - An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 400 MB the moment anything tries to display it, and four times that if it is in colour. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app.
App target, 1 MB body served by MockURLProtocol, one run per shape on this machine, Thread.isMainThread sampled at the end of each loop. Temporary code was added to URLDocumentLoader.swift and URLDocumentLoaderTests.swift, measured, and fully reverted (git status verified empty afterwards).
| shape | time / MB | on main at end |
|---|---|---|
A — loop inline in the @concurrent closure (ships) | 0.096 s | no |
B — loop in an unannotated static func helper | 43.101 s | YES |
C — same helper marked nonisolated | 0.104 s | no |
D — same helper marked @concurrent nonisolated | 0.102 s | no |
E — loop inline, withDeadline without @concurrent | 42.119 s | YES |
F — session.data(for:) bulk read | 0.001 s | no |
Scaled ×10: 0.96 / 431 / 1.04 / 1.02 / 421 / 0.01 seconds per 10 MB. A and E reproduce the author's 0.88 s and 133 s clusters. C and D are the shapes the branch's comment says do not exist — it reports that @concurrent on the helper was still slow, which does not reproduce. The most likely explanation is that @concurrent was applied without nonisolated to a declaration that was implicitly @MainActor.
session.data(for:) read the same body in 0.001 s — about 100× faster than even the correct per-byte loop, and ~43,000× faster than the broken one. The per-byte AsyncBytes iteration costs ~0.9 s per 10 MB no matter which executor it runs on, and the same loop is in ImageLoader (50 MB cap) and SVGSourceLoader (2 MB cap).
The cheap intermediate the branch does not consider: Content-Length is already fetched and validated before the loop starts. When it is present and within cap — overwhelmingly the common case — the byte stream buys nothing; use a bulk read and keep the per-byte stream only for absent or unparseable headers. That is ~6 lines and does not depend on resolving anything about isolation. Re-scope T-2260 to this and the ticket earns its priority; leave it as “find the real cause” and it is chasing something already answered.
@concurrent semantics. Compiles on the function-type parameter and behaves as the isolation opt-out. Captured session/request are Sendable, T: Sendable is required and (Data, URLResponse) satisfies it, no shared mutable state crosses the boundary.workTask.cancel() on the deadline path, gate.cancelFromCaller() → work?.cancel() on the caller path. Proven by withDeadlinePropagatesCallerCancellation and by the mock observing stopLoading() mid-body. The socket is released by defer { session.invalidateAndCancel() } regardless of whether AsyncBytes notices.{resume, cancel, start}. Single-resume holds in every one. finished is set by the first deliver; start's latched branch deliberately never stores the continuation it resumes directly, so the two resume sites cannot both fire.ParkedContinuation cannot leak across tests. park/release are symmetric under the lock — a release that lands first resumes a later park inline. The only escape is the .timeLimit trait firing before release(), i.e. only on regression.load is @MainActor, so only the @concurrent closure body leaves the main actor and the continuation resumes load back on it. RemoteContentCoordinator and RemoteRefreshFlow receive LoadResult on main exactly as before.Task { _ = await workTask.result; timeoutTask.cancel() }. On the timeout path invalidateAndCancel() has already fired, which makes the iteration throw and the work task finish.reserveCapacity(min(declaredLength ?? 65_536, maxContentSize)). Clamped, user-initiated, one at a time.make lint 0 violations across 555 files; make verify-test-isolation OK (43 guard tests); URLDocumentLoaderTests 23/23 green on macOS; contentTooLargeViaStreaming 1.37–1.47 s across four hosts, consistent with the claimed 1.33 s.-only-testing:prismTests/URLDocumentLoaderTests/reviewBenchShapes matched zero tests and reported ** TEST SUCCEEDED ** in 6.7 seconds. Swift Testing needs the parentheses: reviewBenchShapes(). This is exactly the failure mode Tools/check-test-results.sh exists to catch (T-1983), and it catches it only for the make targets — a hand-rolled xcodebuild -only-testing: during a review has no such guard. Worth remembering the next time a targeted run comes back green suspiciously fast.