Skip to content

Commit 8a5b010

Browse files
authored
fix: FoundationStreamBridge fixes from main branch (#708)
1 parent 64e66f3 commit 8a5b010

File tree

3 files changed

+81
-83
lines changed

3 files changed

+81
-83
lines changed

Sources/ClientRuntime/Networking/Http/URLSession/FoundationStreamBridge.swift

Lines changed: 79 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77

88
#if os(iOS) || os(macOS) || os(watchOS) || os(tvOS) || os(visionOS)
99

10+
import func Foundation.CFWriteStreamSetDispatchQueue
1011
import class Foundation.DispatchQueue
11-
import func Foundation.autoreleasepool
1212
import class Foundation.NSObject
1313
import class Foundation.Stream
1414
import class Foundation.InputStream
1515
import class Foundation.OutputStream
1616
import class Foundation.Thread
1717
import class Foundation.RunLoop
18+
import class Foundation.Timer
19+
import struct Foundation.TimeInterval
1820
import protocol Foundation.StreamDelegate
1921

2022
/// Reads data from a smithy-swift native `ReadableStream` and streams the data to a Foundation `InputStream`.
@@ -39,35 +41,41 @@ class FoundationStreamBridge: NSObject, StreamDelegate {
3941
/// A Foundation `OutputStream` that will read from the `ReadableStream`
4042
private let outputStream: OutputStream
4143

42-
/// Actor used to isolate the stream status from multiple concurrent accesses.
43-
actor ReadableStreamStatus {
44+
/// A Logger for logging events.
45+
private let logger: LogAgent
46+
47+
/// Actor used to ensure writes are performed in series.
48+
actor WriteCoordinator {
49+
var task: Task<Void, Error>?
4450

4551
/// `true` if the readable stream has been found to be empty, `false` otherwise. Will flip to `true` if the readable stream is read,
4652
/// and `nil` is returned.
47-
var isEmpty = false
53+
var readableStreamIsEmpty = false
4854

4955
/// Sets stream status to indicate the stream is empty.
50-
func setIsEmpty() async {
51-
isEmpty = true
56+
func setReadableStreamIsEmpty() async {
57+
readableStreamIsEmpty = true
5258
}
53-
}
5459

55-
/// Actor used to isolate the stream status from multiple concurrent accesses.
56-
private var readableStreamStatus = ReadableStreamStatus()
60+
/// Creates a new concurrent Task that executes the passed block, ensuring that the previous Task
61+
/// finishes before this task starts.
62+
///
63+
/// Acts as a sort of "serial queue" of Swift concurrency tasks.
64+
/// - Parameter block: The code to be performed in this task.
65+
func perform(_ block: @escaping @Sendable (WriteCoordinator) async throws -> Void) {
66+
self.task = Task { [task] in
67+
_ = await task?.result
68+
try await block(self)
69+
}
70+
}
71+
}
5772

58-
/// A shared serial DispatchQueue to run the `perform`-on-thread operations.
59-
/// Performing thread operations on an async queue allows Swift concurrency tasks to not block.
60-
private static let queue = DispatchQueue(label: "AWSFoundationStreamBridge")
73+
/// Actor used to enforce the order of multiple concurrent stream writes.
74+
private let writeCoordinator = WriteCoordinator()
6175

62-
/// Foundation Streams require a run loop on which to post callbacks for their delegates.
63-
/// All stream operations should be performed on the same thread as the delegate callbacks.
64-
/// A single shared `Thread` is started and is used to host the RunLoop for all Foundation Stream callbacks.
65-
private static let thread: Thread = {
66-
let thread = Thread { autoreleasepool { RunLoop.current.run() } }
67-
thread.name = "AWSFoundationStreamBridge"
68-
thread.start()
69-
return thread
70-
}()
76+
/// A shared serial DispatchQueue to run the stream operations.
77+
/// Performing operations on an async queue allows Swift concurrency tasks to not block.
78+
private let queue = DispatchQueue(label: "AWSFoundationStreamBridge")
7179

7280
// MARK: - init & deinit
7381

@@ -78,8 +86,8 @@ class FoundationStreamBridge: NSObject, StreamDelegate {
7886
/// - Parameters:
7987
/// - readableStream: The `ReadableStream` that serves as the input to the bridge.
8088
/// - bufferSize: The number of bytes in the in-memory buffer. The buffer is allocated for this size no matter if in use or not.
81-
/// Defaults to 4096 bytes.
82-
init(readableStream: ReadableStream, bufferSize: Int = 4096) {
89+
/// Defaults to 65536 bytes.
90+
init(readableStream: ReadableStream, bufferSize: Int = 65_536, logger: LogAgent) {
8391
var inputStream: InputStream?
8492
var outputStream: OutputStream?
8593

@@ -98,77 +106,73 @@ class FoundationStreamBridge: NSObject, StreamDelegate {
98106
self.readableStream = readableStream
99107
self.inputStream = inputStream
100108
self.outputStream = outputStream
109+
self.logger = logger
110+
111+
// The stream is configured to deliver its callbacks on the dispatch queue.
112+
// This precludes the need for a Thread with RunLoop.
113+
CFWriteStreamSetDispatchQueue(outputStream, queue)
101114
}
102115

103116
// MARK: - Opening & closing
104117

105-
/// Schedule the output stream on the special thread reserved for stream callbacks.
118+
/// Schedule the output stream on the queue for stream callbacks.
106119
/// Do not wait to complete opening before returning.
107120
func open() async {
108121
await withCheckedContinuation { continuation in
109-
Self.queue.async {
110-
self.perform(#selector(self.openOnThread), on: Self.thread, with: nil, waitUntilDone: false)
122+
queue.async {
123+
self.outputStream.delegate = self
124+
self.outputStream.open()
125+
continuation.resume()
111126
}
112-
continuation.resume()
113127
}
114128
}
115129

116-
/// Configure the output stream to make StreamDelegate callback to this bridge using the special thread / run loop, and open the output stream.
117-
/// The input stream is not included here. It will be configured by `URLSession` when the HTTP request is initiated.
118-
@objc private func openOnThread() {
119-
outputStream.delegate = self
120-
outputStream.schedule(in: RunLoop.current, forMode: .default)
121-
outputStream.open()
122-
}
123-
124130
/// Unschedule the output stream on the special stream callback thread.
125131
/// Do not wait to complete closing before returning.
126132
func close() async {
127133
await withCheckedContinuation { continuation in
128-
Self.queue.async {
129-
self.perform(#selector(self.closeOnThread), on: Self.thread, with: nil, waitUntilDone: false)
134+
queue.async {
135+
self.outputStream.close()
136+
self.outputStream.delegate = nil
137+
continuation.resume()
130138
}
131-
continuation.resume()
132139
}
133140
}
134141

135-
/// Close the output stream and remove it from the thread / run loop.
136-
@objc private func closeOnThread() {
137-
outputStream.close()
138-
outputStream.remove(from: RunLoop.current, forMode: .default)
139-
outputStream.delegate = nil
140-
}
141-
142142
// MARK: - Writing to bridge
143143

144144
/// Tries to read from the readable stream if possible, then transfer the data to the output stream.
145145
private func writeToOutput() async throws {
146-
var data = Data()
147-
if await !readableStreamStatus.isEmpty {
148-
if let newData = try await readableStream.readAsync(upToCount: bufferSize) {
149-
data = newData
150-
} else {
151-
await readableStreamStatus.setIsEmpty()
152-
await close()
146+
await writeCoordinator.perform { [self] writeCoordinator in
147+
var data = Data()
148+
if await !writeCoordinator.readableStreamIsEmpty {
149+
if let newData = try await readableStream.readAsync(upToCount: bufferSize) {
150+
data = newData
151+
} else {
152+
await writeCoordinator.setReadableStreamIsEmpty()
153+
await close()
154+
}
153155
}
156+
try await writeToOutputStream(data: data)
154157
}
155-
try await writeToOutputStream(data: data)
156-
}
157-
158-
private class WriteToOutputStreamResult: NSObject {
159-
var data = Data()
160-
var error: Error?
161158
}
162159

163160
/// Write the passed data to the output stream, using the reserved thread.
164161
private func writeToOutputStream(data: Data) async throws {
165162
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
166-
Self.queue.async {
167-
let result = WriteToOutputStreamResult()
168-
result.data = data
169-
let selector = #selector(self.writeToOutputStreamOnThread)
170-
self.perform(selector, on: Self.thread, with: result, waitUntilDone: true)
171-
if let error = result.error {
163+
queue.async { [self] in
164+
guard !buffer.isEmpty || !data.isEmpty else { continuation.resume(); return }
165+
buffer.append(data)
166+
var writeCount = 0
167+
buffer.withUnsafeBytes { bufferPtr in
168+
guard let bytePtr = bufferPtr.bindMemory(to: UInt8.self).baseAddress else { return }
169+
writeCount = outputStream.write(bytePtr, maxLength: buffer.count)
170+
}
171+
if writeCount > 0 {
172+
logger.info("FoundationStreamBridge: wrote \(writeCount) bytes to request body")
173+
buffer.removeFirst(writeCount)
174+
}
175+
if let error = outputStream.streamError {
172176
continuation.resume(throwing: error)
173177
} else {
174178
continuation.resume()
@@ -177,34 +181,28 @@ class FoundationStreamBridge: NSObject, StreamDelegate {
177181
}
178182
}
179183

180-
/// Append the new data to the buffer, then write to the output stream. Return any error to the caller using the param object.
181-
@objc private func writeToOutputStreamOnThread(_ result: WriteToOutputStreamResult) {
182-
guard !buffer.isEmpty || !result.data.isEmpty else { return }
183-
buffer.append(result.data)
184-
var writeCount = 0
185-
buffer.withUnsafeBytes { bufferPtr in
186-
let bytePtr = bufferPtr.bindMemory(to: UInt8.self).baseAddress!
187-
writeCount = outputStream.write(bytePtr, maxLength: buffer.count)
188-
}
189-
if writeCount > 0 {
190-
buffer.removeFirst(writeCount)
191-
}
192-
result.error = outputStream.streamError
193-
}
194-
195184
// MARK: - StreamDelegate protocol
196185

197-
/// The stream places this callback when appropriate. Call will be delivered on the special thread / run loop for stream callbacks.
186+
/// The stream places this callback when appropriate. Call will be delivered on the GCD queue for stream callbacks.
198187
/// `.hasSpaceAvailable` prompts this type to query the readable stream for more data.
199188
@objc func stream(_ aStream: Foundation.Stream, handle eventCode: Foundation.Stream.Event) {
200189
switch eventCode {
190+
case .openCompleted:
191+
break
192+
case .hasBytesAvailable:
193+
break
201194
case .hasSpaceAvailable:
202195
// Since space is available, try and read from the ReadableStream and
203196
// transfer the data to the Foundation stream pair.
204197
// Use a `Task` to perform the operation within Swift concurrency.
205198
Task { try await writeToOutput() }
206-
default:
199+
case .errorOccurred:
200+
logger.info("FoundationStreamBridge: .errorOccurred event")
201+
logger.info("FoundationStreamBridge: Stream error: \(String(describing: aStream.streamError))")
202+
case .endEncountered:
207203
break
204+
default:
205+
logger.info("FoundationStreamBridge: Other stream event occurred: \(eventCode)")
208206
}
209207
}
210208
}

Sources/ClientRuntime/Networking/Http/URLSession/URLSessionHTTPClient.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ public final class URLSessionHTTPClient: HTTPClient {
252252

253253
// If needed, create a stream bridge that streams data from a SDK stream to a Foundation InputStream
254254
// that URLSession can stream its request body from.
255-
let streamBridge = requestStream.map { FoundationStreamBridge(readableStream: $0, bufferSize: 4096) }
255+
let streamBridge = requestStream.map { FoundationStreamBridge(readableStream: $0, bufferSize: 4096, logger: logger) }
256256

257257
// Create the request (with a streaming body when needed.)
258258
let urlRequest = self.makeURLRequest(from: request, httpBodyStream: streamBridge?.inputStream)

Tests/ClientRuntimeTests/NetworkingTests/URLSession/FoundationStreamBridgeTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class FoundationStreamBridgeTests: XCTestCase {
6060

6161
// Create a stream bridge with our original data & open it
6262
let bufferedStream = BufferedStream(data: originalData, isClosed: true)
63-
let subject = FoundationStreamBridge(readableStream: bufferedStream, bufferSize: bufferSize)
63+
let subject = FoundationStreamBridge(readableStream: bufferedStream, bufferSize: bufferSize, logger: TestLogger())
6464
await subject.open()
6565

6666
// This will hold the data that is bridged from the ReadableStream to the Foundation InputStream

0 commit comments

Comments
 (0)