mount public data from the atmosphere to a virtual filesystem (macos only) pdfs.at
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(atproto): isolate URLProtocolStub handlers per session to unblock parallel tests

Replace the shared global handler with a UUID-keyed registry. Each `install()`
call gets its own slot (identified via `httpAdditionalHeaders["X-URLProtocolStub-ID"]`),
and `reset(session:)` clears only the calling test's handler — so concurrently
running suites can never clobber each other's state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+88 -22
+78 -13
Packages/ATProto/Tests/ATProtoTests/Support/URLProtocolStub.swift
··· 1 1 import Foundation 2 2 3 - /// In-process HTTP stub for URLSession tests. Not thread-safe across concurrent 4 - /// tests; invoke each test serially or guard with `@Suite(.serialized)`. 3 + /// In-process HTTP stub for URLSession tests. 4 + /// 5 + /// Each `install(handler:)` creates a unique UUID, stores the handler under that 6 + /// key, and configures a `URLSession` whose `httpAdditionalHeaders` carry the UUID 7 + /// in `X-URLProtocolStub-ID`. `startLoading()` reads that header to look up the 8 + /// correct per-session handler, so parallel test suites never stomp each other. 9 + /// 10 + /// Call `reset(session:)` in a `defer` block using the session returned by 11 + /// `install(handler:)` to clean up the handler after each test. 5 12 final class URLProtocolStub: URLProtocol, @unchecked Sendable { 6 13 struct Response { 7 14 let statusCode: Int ··· 19 26 } 20 27 } 21 28 22 - nonisolated(unsafe) static var handler: ((URLRequest) -> Response)? 23 - nonisolated(unsafe) static var recordedRequests: [URLRequest] = [] 29 + // MARK: - Registry 30 + 31 + private static let registryLock = NSLock() 32 + nonisolated(unsafe) private static var handlers: [String: (URLRequest) -> Response] = [:] 33 + nonisolated(unsafe) private static var requestsByID: [String: [URLRequest]] = [:] 34 + /// Maps URLSession object identity → session stub ID so `reset(session:)` can 35 + /// clean up without relying on a shared cursor that parallel tests would clobber. 36 + nonisolated(unsafe) private static var sessionIDs: [ObjectIdentifier: String] = [:] 37 + 38 + // MARK: - Public API 24 39 40 + /// Installs a request handler and returns a configured `URLSession`. 41 + /// Pass the returned session to `reset(session:)` in a `defer` block. 42 + @discardableResult 25 43 static func install(handler: @escaping (URLRequest) -> Response) -> URLSession { 26 - Self.handler = handler 27 - Self.recordedRequests = [] 44 + let id = UUID().uuidString 28 45 let config = URLSessionConfiguration.ephemeral 29 46 config.protocolClasses = [URLProtocolStub.self] 30 - return URLSession(configuration: config) 47 + config.httpAdditionalHeaders = ["X-URLProtocolStub-ID": id] 48 + let session = URLSession(configuration: config) 49 + 50 + registryLock.lock() 51 + handlers[id] = handler 52 + requestsByID[id] = [] 53 + sessionIDs[ObjectIdentifier(session)] = id 54 + registryLock.unlock() 55 + 56 + return session 57 + } 58 + 59 + /// Clears the handler associated with the given session. 60 + /// Safe to call from any test even when other sessions are active in parallel. 61 + static func reset(session: URLSession? = nil) { 62 + registryLock.lock() 63 + defer { registryLock.unlock() } 64 + let id: String? 65 + if let session { 66 + let key = ObjectIdentifier(session) 67 + id = sessionIDs.removeValue(forKey: key) 68 + } else { 69 + // Legacy no-arg form: remove all handlers (single-suite usage only). 70 + handlers.removeAll() 71 + requestsByID.removeAll() 72 + sessionIDs.removeAll() 73 + return 74 + } 75 + guard let id else { return } 76 + handlers.removeValue(forKey: id) 77 + requestsByID.removeValue(forKey: id) 78 + } 79 + 80 + /// Requests recorded for the given session. 81 + static func recordedRequests(for session: URLSession) -> [URLRequest] { 82 + registryLock.lock() 83 + defer { registryLock.unlock() } 84 + guard let id = sessionIDs[ObjectIdentifier(session)] else { return [] } 85 + return requestsByID[id] ?? [] 31 86 } 32 87 33 - static func reset() { 34 - handler = nil 35 - recordedRequests = [] 88 + // MARK: - URLProtocol overrides 89 + 90 + override class func canInit(with request: URLRequest) -> Bool { 91 + // Only intercept requests that carry our session marker. 92 + request.value(forHTTPHeaderField: "X-URLProtocolStub-ID") != nil 36 93 } 37 94 38 - override class func canInit(with request: URLRequest) -> Bool { true } 39 95 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } 40 96 41 97 override func startLoading() { 42 - Self.recordedRequests.append(request) 43 - guard let handler = Self.handler else { 98 + guard let id = request.value(forHTTPHeaderField: "X-URLProtocolStub-ID") else { 99 + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) 100 + return 101 + } 102 + 103 + Self.registryLock.lock() 104 + let handler = Self.handlers[id] 105 + if handler != nil { Self.requestsByID[id, default: []].append(request) } 106 + Self.registryLock.unlock() 107 + 108 + guard let handler else { 44 109 client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) 45 110 return 46 111 }
+5 -5
Packages/ATProto/Tests/ATProtoTests/XRPCClientTests.swift
··· 15 15 #expect(request.httpMethod == "GET") 16 16 return .json(#"{"value":"hi"}"#) 17 17 } 18 - defer { URLProtocolStub.reset() } 18 + defer { URLProtocolStub.reset(session: session) } 19 19 20 20 let client = XRPCClient( 21 21 service: URL(string: "https://bsky.social")!, ··· 31 31 let session = URLProtocolStub.install { _ in 32 32 .json(#"{"error":"InvalidRequest","message":"bad"}"#, status: 400) 33 33 } 34 - defer { URLProtocolStub.reset() } 34 + defer { URLProtocolStub.reset(session: session) } 35 35 36 36 let client = XRPCClient( 37 37 service: URL(string: "https://bsky.social")!, ··· 57 57 let session = URLProtocolStub.install { _ in 58 58 .text("upstream down", status: 502) 59 59 } 60 - defer { URLProtocolStub.reset() } 60 + defer { URLProtocolStub.reset(session: session) } 61 61 62 62 let client = XRPCClient( 63 63 service: URL(string: "https://bsky.social")!, ··· 82 82 #expect(request.url?.absoluteString == "https://self.hosted.pds/api/xrpc/com.example.echo") 83 83 return .json(#"{"value":"ok"}"#) 84 84 } 85 - defer { URLProtocolStub.reset() } 85 + defer { URLProtocolStub.reset(session: session) } 86 86 87 87 let client = XRPCClient( 88 88 service: URL(string: "https://self.hosted.pds/api/")!, ··· 104 104 #expect(request.url?.path == "/xrpc/com.example.create") 105 105 return .json(#"{"ok":true}"#) 106 106 } 107 - defer { URLProtocolStub.reset() } 107 + defer { URLProtocolStub.reset(session: session) } 108 108 109 109 let client = XRPCClient( 110 110 service: URL(string: "https://bsky.social")!,
+5 -4
Packages/ATProto/Tests/ATProtoTests/XRPCEndpointsTests.swift
··· 4 4 5 5 @Suite("XRPC typed endpoints", .serialized) 6 6 struct XRPCEndpointsTests { 7 - func makeClient(handler: @escaping (URLRequest) -> URLProtocolStub.Response) -> XRPCClient { 7 + func makeClient(handler: @escaping (URLRequest) -> URLProtocolStub.Response) -> (XRPCClient, URLSession) { 8 8 let session = URLProtocolStub.install(handler: handler) 9 - return XRPCClient( 9 + let client = XRPCClient( 10 10 service: URL(string: "https://bsky.social")!, 11 11 session: session, 12 12 auth: AnonymousAuthTokenProvider() 13 13 ) 14 + return (client, session) 14 15 } 15 16 16 17 @Test("describeRepo returns collections + handle") 17 18 func describeRepo() async throws { 18 - let client = makeClient { request in 19 + let (client, session) = makeClient { request in 19 20 #expect(request.url?.path == "/xrpc/com.atproto.repo.describeRepo") 20 21 #expect(request.url?.query?.contains("repo=did:plc:abc") == true) 21 22 return .json(#"{"handle":"nate.natemoo.re","did":"did:plc:abc","didDoc":{"id":"did:plc:abc"},"collections":["app.bsky.feed.post","app.bsky.actor.profile"],"handleIsCorrect":true}"#) 22 23 } 23 - defer { URLProtocolStub.reset() } 24 + defer { URLProtocolStub.reset(session: session) } 24 25 25 26 let result = try await client.describeRepo(repo: "did:plc:abc") 26 27 #expect(result.handle == "nate.natemoo.re")