stream-chat-react-native 9.8.1-beta.1 → 9.8.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,10 +5,48 @@ private enum StreamMultipartBodyElement {
5
5
  case file(URL)
6
6
  }
7
7
 
8
+ /// Carries a body-production failure out of band.
9
+ ///
10
+ /// A bound stream pair has no error channel: the only way the producer can signal failure is to
11
+ /// close the write end, which the server sees as a truncated body. Recording the real error here
12
+ /// lets the upload manager surface it instead of the generic transport error.
13
+ final class StreamMultipartBodyErrorBox: @unchecked Sendable {
14
+ private let lock = NSLock()
15
+ private var storedError: Error?
16
+
17
+ var error: Error? {
18
+ lock.lock()
19
+ defer { lock.unlock() }
20
+ return storedError
21
+ }
22
+
23
+ func record(_ error: Error) {
24
+ lock.lock()
25
+ defer { lock.unlock() }
26
+ if storedError == nil {
27
+ storedError = error
28
+ }
29
+ }
30
+ }
31
+
8
32
  final class StreamMultipartUploadBodyStreamFactory {
9
33
  let boundary: String
10
34
  let contentLength: Int64?
11
35
 
36
+ /// Set when the body of the **most recent** attempt could not be produced in full.
37
+ ///
38
+ /// `URLSession` can ask for a fresh body stream (redirect, auth retry) via
39
+ /// `needNewBodyStream`. Each attempt therefore gets its own box and `makeStream()` installs it
40
+ /// as the current one, so a failure recorded by an abandoned attempt — e.g. its reader going
41
+ /// away because URLSession decided to retry — can never fail a later attempt that succeeds.
42
+ var bodyError: Error? {
43
+ boxLock.lock()
44
+ defer { boxLock.unlock() }
45
+ return currentErrorBox.error
46
+ }
47
+
48
+ private let boxLock = NSLock()
49
+ private var currentErrorBox = StreamMultipartBodyErrorBox()
12
50
  private let elements: [StreamMultipartBodyElement]
13
51
 
14
52
  private init(
@@ -63,7 +101,37 @@ final class StreamMultipartUploadBodyStreamFactory {
63
101
  }
64
102
 
65
103
  func makeStream() -> InputStream {
66
- StreamMultipartSequentialInputStream(elements: elements)
104
+ var readStream: Unmanaged<CFReadStream>?
105
+ var writeStream: Unmanaged<CFWriteStream>?
106
+
107
+ CFStreamCreateBoundPair(
108
+ kCFAllocatorDefault,
109
+ &readStream,
110
+ &writeStream,
111
+ CFIndex(StreamMultipartBodyProducer.transferBufferSize)
112
+ )
113
+
114
+ // A fresh box per attempt; see `bodyError`.
115
+ let errorBox = StreamMultipartBodyErrorBox()
116
+ boxLock.lock()
117
+ currentErrorBox = errorBox
118
+ boxLock.unlock()
119
+
120
+ guard
121
+ let input = readStream?.takeRetainedValue() as InputStream?,
122
+ let output = writeStream?.takeRetainedValue()
123
+ else {
124
+ errorBox.record(StreamMultipartUploadError.invalidRequest("Could not create a request body stream"))
125
+ return InputStream(data: Data())
126
+ }
127
+
128
+ StreamMultipartBodyProducer(
129
+ elements: elements,
130
+ output: output,
131
+ errorBox: errorBox
132
+ ).start()
133
+
134
+ return input
67
135
  }
68
136
 
69
137
  private static func multipartTextData(boundary: String, part: StreamMultipartTextPart) -> Data {
@@ -102,153 +170,263 @@ final class StreamMultipartUploadBodyStreamFactory {
102
170
  }
103
171
  }
104
172
 
105
- private final class StreamMultipartSequentialInputStream: InputStream {
173
+ /// Feeds the write end of a `CFStreamCreateBoundPair` from the multipart element list.
174
+ ///
175
+ /// The body is handed to `URLSession` as the *read* end of a Core Foundation bound stream pair
176
+ /// rather than as a hand-rolled `InputStream` subclass. That matters: CFNetwork drives an HTTP/1.1
177
+ /// request body through the `CFReadStream` client-callback machinery, and a plain `InputStream`
178
+ /// subclass cannot participate in it — it can never report end-of-stream. CFNetwork stops reading
179
+ /// as soon as `Content-Length` is satisfied, so with a subclass it never observed the end of the
180
+ /// body, never considered the request finished, and the task sat idle until it timed out (the
181
+ /// server had already answered 201). A real bound pair reports every event, and closing the write
182
+ /// end is what tells CFNetwork the body is complete.
183
+ ///
184
+ /// The write end is driven by **GCD** (`CFWriteStreamSetDispatchQueue`) rather than a run loop, so
185
+ /// this owns no thread and cannot outlive its work.
186
+ private final class StreamMultipartBodyProducer {
187
+ static let transferBufferSize = 64 * 1024
188
+
189
+ private enum Refill {
190
+ case filled
191
+ case drained
192
+ case failed(Error)
193
+ }
194
+
106
195
  private let elements: [StreamMultipartBodyElement]
196
+ private let output: CFWriteStream
197
+ private let errorBox: StreamMultipartBodyErrorBox
198
+ private let queue: DispatchQueue
199
+ private let buffer = UnsafeMutablePointer<UInt8>.allocate(
200
+ capacity: StreamMultipartBodyProducer.transferBufferSize
201
+ )
202
+
107
203
  private var currentIndex = 0
108
204
  private var currentStream: InputStream?
109
- private weak var internalDelegate: StreamDelegate?
110
- private var internalStatus: Stream.Status = .notOpen
111
- private var internalError: Error?
112
- private var scheduledRunLoops: [(runLoop: RunLoop, mode: RunLoop.Mode)] = []
113
-
114
- init(elements: [StreamMultipartBodyElement]) {
205
+ private var bufferOffset = 0
206
+ private var bufferLength = 0
207
+ private var isFinished = false
208
+ /// Keeps the producer alive while the stream client holds an unretained pointer to it.
209
+ private var selfRetain: StreamMultipartBodyProducer?
210
+
211
+ init(
212
+ elements: [StreamMultipartBodyElement],
213
+ output: CFWriteStream,
214
+ errorBox: StreamMultipartBodyErrorBox
215
+ ) {
115
216
  self.elements = elements
116
- super.init(data: Data())
217
+ self.output = output
218
+ self.errorBox = errorBox
219
+ queue = DispatchQueue(
220
+ label: "io.getstream.chat.multipart-upload-body",
221
+ qos: .userInitiated
222
+ )
117
223
  }
118
224
 
119
- override var delegate: StreamDelegate? {
120
- get {
121
- internalDelegate
122
- }
123
- set {
124
- internalDelegate = newValue
125
- currentStream?.delegate = newValue
126
- }
225
+ deinit {
226
+ buffer.deallocate()
127
227
  }
128
228
 
129
- override var hasBytesAvailable: Bool {
130
- guard internalStatus != .closed, internalStatus != .error else {
131
- return false
132
- }
229
+ func start() {
230
+ selfRetain = self
133
231
 
134
- if let currentStream, currentStream.hasBytesAvailable {
135
- return true
136
- }
137
-
138
- return currentIndex < elements.count
139
- }
232
+ var context = CFStreamClientContext(
233
+ version: 0,
234
+ info: Unmanaged.passUnretained(self).toOpaque(),
235
+ retain: nil,
236
+ release: nil,
237
+ copyDescription: nil
238
+ )
140
239
 
141
- override var streamError: Error? {
142
- internalError
143
- }
240
+ let events: CFOptionFlags = CFStreamEventType.canAcceptBytes.rawValue
241
+ | CFStreamEventType.errorOccurred.rawValue
242
+ | CFStreamEventType.endEncountered.rawValue
144
243
 
145
- override var streamStatus: Stream.Status {
146
- internalStatus
147
- }
244
+ let didSetClient = CFWriteStreamSetClient(
245
+ output,
246
+ events,
247
+ { _, event, info in
248
+ guard let info else {
249
+ return
250
+ }
251
+ Unmanaged<StreamMultipartBodyProducer>.fromOpaque(info)
252
+ .takeUnretainedValue()
253
+ .handle(event)
254
+ },
255
+ &context
256
+ )
148
257
 
149
- override func open() {
150
- guard internalStatus == .notOpen else {
258
+ guard didSetClient else {
259
+ // No callbacks will ever arrive; finishing here is safe because the dispatch queue has not
260
+ // been attached yet, so nothing else can be running.
261
+ finish(error: writeStreamError() ?? StreamMultipartUploadError.invalidRequest(
262
+ "Could not observe the request body stream"
263
+ ))
151
264
  return
152
265
  }
153
266
 
154
- internalStatus = .opening
155
- advanceStreamIfNeeded()
156
- if internalStatus == .error {
267
+ // Deliver client callbacks on our serial queue instead of scheduling on a run loop, so the
268
+ // producer needs no thread of its own and GCD owns its lifetime.
269
+ CFWriteStreamSetDispatchQueue(output, queue)
270
+
271
+ guard CFWriteStreamOpen(output) else {
272
+ // The queue is attached now, so tear down on it to stay single-threaded.
273
+ queue.async { [self] in
274
+ finish(error: writeStreamError() ?? StreamMultipartUploadError.invalidRequest(
275
+ "Could not open the request body stream"
276
+ ))
277
+ }
157
278
  return
158
279
  }
159
- internalStatus = currentStream == nil ? .atEnd : .open
160
280
  }
161
281
 
162
- override func close() {
163
- currentStream?.close()
164
- currentStream = nil
165
- internalStatus = .closed
282
+ /// The write end's own error, when Core Foundation has one to give.
283
+ private func writeStreamError() -> Error? {
284
+ CFWriteStreamCopyError(output) as Error?
166
285
  }
167
286
 
168
- override func schedule(in aRunLoop: RunLoop, forMode mode: RunLoop.Mode) {
169
- scheduledRunLoops.append((runLoop: aRunLoop, mode: mode))
170
- currentStream?.schedule(in: aRunLoop, forMode: mode)
287
+ // MARK: - Callbacks (always on `queue`)
288
+
289
+ private func handle(_ event: CFStreamEventType) {
290
+ switch event {
291
+ case .canAcceptBytes:
292
+ pump()
293
+ case .errorOccurred:
294
+ // Usually the reader going away (a cancelled upload), but it can be a genuine write-side
295
+ // failure — record whatever CF gives us. Cancellation still wins in the manager, which
296
+ // checks `NSURLErrorCancelled` first.
297
+ finish(error: writeStreamError())
298
+ case .endEncountered:
299
+ finish(error: nil)
300
+ default:
301
+ break
302
+ }
171
303
  }
172
304
 
173
- override func remove(from aRunLoop: RunLoop, forMode mode: RunLoop.Mode) {
174
- scheduledRunLoops.removeAll { $0.runLoop == aRunLoop && $0.mode == mode }
175
- currentStream?.remove(from: aRunLoop, forMode: mode)
176
- }
305
+ private func pump() {
306
+ while !isFinished, CFWriteStreamCanAcceptBytes(output) {
307
+ if bufferOffset >= bufferLength {
308
+ switch refill() {
309
+ case .filled:
310
+ break
311
+ case .drained:
312
+ finish(error: nil)
313
+ return
314
+ case .failed(let error):
315
+ finish(error: error)
316
+ return
317
+ }
318
+ }
177
319
 
178
- override func read(_ buffer: UnsafeMutablePointer<UInt8>, maxLength len: Int) -> Int {
179
- guard internalStatus != .closed else {
180
- return 0
181
- }
320
+ let written = CFWriteStreamWrite(
321
+ output,
322
+ buffer + bufferOffset,
323
+ bufferLength - bufferOffset
324
+ )
325
+
326
+ if written > 0 {
327
+ bufferOffset += written
328
+ continue
329
+ }
330
+
331
+ if written == 0 {
332
+ // Backpressure, NOT end of body: `CFWriteStreamCanAcceptBytes` may answer true without
333
+ // knowing, and the pair reports 0 when it is full. The unwritten remainder stays in
334
+ // `buffer` at `bufferOffset`, so the next `.canAcceptBytes` resumes exactly here.
335
+ // Closing the stream here would silently truncate the body.
336
+ return
337
+ }
182
338
 
183
- if internalStatus == .notOpen {
184
- open()
339
+ // A negative write is itself the failure signal — do not depend on CF having an error
340
+ // object, or the failure degrades into the clean stream close this refactor exists to
341
+ // disambiguate.
342
+ finish(error: writeStreamError() ?? StreamMultipartUploadError.invalidRequest(
343
+ "Could not write the request body stream"
344
+ ))
345
+ return
185
346
  }
347
+ }
186
348
 
349
+ /// Fills `buffer` from the next available element.
350
+ private func refill() -> Refill {
187
351
  while true {
188
- guard let currentStream else {
189
- if internalStatus == .error {
190
- return -1
352
+ if currentStream == nil {
353
+ guard currentIndex < elements.count else {
354
+ return .drained
191
355
  }
192
356
 
193
- internalStatus = .atEnd
194
- return 0
195
- }
357
+ let element = elements[currentIndex]
358
+ currentIndex += 1
359
+
360
+ switch element {
361
+ case .data(let data):
362
+ currentStream = InputStream(data: data)
363
+ case .file(let url):
364
+ guard let stream = InputStream(url: url) else {
365
+ return .failed(StreamMultipartUploadError.unreadableFile(url.path))
366
+ }
367
+ currentStream = stream
368
+ }
369
+
370
+ guard let stream = currentStream else {
371
+ return .drained
372
+ }
196
373
 
197
- let bytesRead = currentStream.read(buffer, maxLength: len)
374
+ stream.open()
198
375
 
199
- if bytesRead > 0 {
200
- internalStatus = .open
201
- return bytesRead
376
+ if stream.streamStatus == .error {
377
+ return .failed(stream.streamError ?? StreamMultipartUploadError.unreadableFile(elementPath()))
378
+ }
202
379
  }
203
380
 
204
- if bytesRead < 0 {
205
- internalError = currentStream.streamError
206
- internalStatus = .error
207
- return -1
381
+ guard let stream = currentStream else {
382
+ return .drained
208
383
  }
209
384
 
210
- currentStream.close()
211
- self.currentStream = nil
212
- advanceStreamIfNeeded()
385
+ let read = stream.read(buffer, maxLength: StreamMultipartBodyProducer.transferBufferSize)
213
386
 
214
- if self.currentStream == nil {
215
- internalStatus = .atEnd
216
- return 0
387
+ if read > 0 {
388
+ bufferOffset = 0
389
+ bufferLength = read
390
+ return .filled
391
+ }
392
+
393
+ let readError = read < 0 ? (stream.streamError ?? StreamMultipartUploadError.unreadableFile(elementPath())) : nil
394
+ stream.close()
395
+ currentStream = nil
396
+
397
+ if let readError {
398
+ return .failed(readError)
217
399
  }
218
400
  }
219
401
  }
220
402
 
221
- private func advanceStreamIfNeeded() {
222
- guard currentStream == nil else {
403
+ private func elementPath() -> String {
404
+ guard currentIndex > 0, case .file(let url) = elements[currentIndex - 1] else {
405
+ return ""
406
+ }
407
+ return url.path
408
+ }
409
+
410
+ private func finish(error: Error?) {
411
+ guard !isFinished else {
223
412
  return
224
413
  }
225
414
 
226
- while currentIndex < elements.count {
227
- let nextElement = elements[currentIndex]
228
- currentIndex += 1
229
-
230
- let nextStream: InputStream?
231
- switch nextElement {
232
- case .data(let data):
233
- nextStream = InputStream(data: data)
234
- case .file(let url):
235
- nextStream = InputStream(url: url)
236
- if nextStream == nil {
237
- internalError = StreamMultipartUploadError.unreadableFile(url.path)
238
- internalStatus = .error
239
- return
240
- }
241
- }
415
+ isFinished = true
242
416
 
243
- if let nextStream {
244
- nextStream.delegate = internalDelegate
245
- for scheduled in scheduledRunLoops {
246
- nextStream.schedule(in: scheduled.runLoop, forMode: scheduled.mode)
247
- }
248
- nextStream.open()
249
- currentStream = nextStream
250
- return
251
- }
417
+ if let error {
418
+ errorBox.record(error)
252
419
  }
420
+
421
+ currentStream?.close()
422
+ currentStream = nil
423
+
424
+ // Unregister before dropping the retain so no callback can arrive against a dead pointer.
425
+ CFWriteStreamSetClient(output, 0, nil, nil)
426
+ CFWriteStreamSetDispatchQueue(output, nil)
427
+ // Closing the write end is what surfaces end-of-stream on the read end.
428
+ CFWriteStreamClose(output)
429
+
430
+ selfRetain = nil
253
431
  }
254
432
  }
@@ -390,6 +390,11 @@ extension StreamMultipartUploadManager: URLSessionDataDelegate, URLSessionTaskDe
390
390
 
391
391
  if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
392
392
  state.completion?(.failure(StreamMultipartUploadError.cancelled))
393
+ } else if let bodyError = state.bodyFactory.bodyError {
394
+ // The request body could not be produced in full. A bound stream pair has no error
395
+ // channel — the reader only sees a truncated body — so prefer the recorded cause over
396
+ // the transport error it surfaces as.
397
+ state.completion?(.failure(bodyError))
393
398
  } else {
394
399
  state.completion?(.failure(nsError))
395
400
  }
@@ -397,6 +402,16 @@ extension StreamMultipartUploadManager: URLSessionDataDelegate, URLSessionTaskDe
397
402
  return
398
403
  }
399
404
 
405
+ // The task completed without a transport error, but the body may still not have been produced
406
+ // in full. A bound stream pair has no error channel, so a producer failure closes the write end
407
+ // and the reader sees a clean EOF — with no `Content-Length` (chunked) that is a well-formed
408
+ // short body the server can happily accept. Never report a truncated upload as a success.
409
+ if let bodyError = state.bodyFactory.bodyError {
410
+ state.completion?(.failure(bodyError))
411
+ state.completion = nil
412
+ return
413
+ }
414
+
400
415
  guard let response = state.response else {
401
416
  state.completion?(.failure(StreamMultipartUploadError.missingHTTPResponse))
402
417
  state.completion = nil
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stream-chat-react-native",
3
3
  "description": "The official React Native SDK for Stream Chat, a service for building chat applications",
4
- "version": "9.8.1-beta.1",
4
+ "version": "9.8.1-beta.2",
5
5
  "homepage": "https://www.npmjs.com/package/stream-chat-react-native",
6
6
  "author": {
7
7
  "company": "Stream.io Inc",
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "es6-symbol": "^3.1.3",
32
32
  "mime": "^4.0.7",
33
- "stream-chat-react-native-core": "9.8.1-beta.1"
33
+ "stream-chat-react-native-core": "9.8.1-beta.2"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@react-native-camera-roll/camera-roll": ">=7.9.0",