Skip to content

Commit dc078d0

Browse files
committed
fix: Emit an error when the stream closes before any response
On Linux, swift-corelibs-foundation (libcurl) terminates a 3xx redirect with an empty or missing Location header by completing the task with no error and no response -- no didReceive, no willPerformHTTPRedirection callback. EventSource treated that as a normal close and reconnected forever, never emitting an error, so the SSE contract tests "client handles empty/missing Location header with 301/307 status" timed out. (CFNetwork surfaces it as an error, so macOS passed.) Treat a no-error completion that never opened the stream as an unrecoverable error and stop, giving deterministic behavior across platforms. Adds a regression test that finishes a request with no response and asserts an unrecoverable error with no reconnect.
1 parent 0dc0c44 commit dc078d0

2 files changed

Lines changed: 33 additions & 0 deletions

File tree

Source/LDSwiftEventSource.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,17 @@ final class EventSourceDelegate: NSObject, URLSessionDataDelegate, @unchecked Se
333333
return
334334
}
335335
}
336+
} else if readyState != .open {
337+
// The task completed with no error, but the stream never opened -- no usable HTTP response
338+
// was delivered. On Linux, libcurl terminates a 3xx redirect with an empty or missing
339+
// Location exactly this way (no error, no response, no willPerformHTTPRedirection call),
340+
// whereas CFNetwork surfaces it as an error. Retrying the same URL just repeats it, so
341+
// report an unrecoverable error and stop instead of reconnecting forever.
342+
logger.info("Connection closed before any response was received; reporting as unrecoverable")
343+
handler.onError(EventSourceError(statusCode: nil, headers: [:], recoverable: false, underlyingError: nil))
344+
readyState = .shutdown
345+
continuation.finish()
346+
return
336347
} else {
337348
logger.info("Connection unexpectedly closed.")
338349
}

Tests/LDSwiftEventSourceTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,28 @@ final class LDSwiftEventSourceTests {
281281
collector.cancel()
282282
}
283283

284+
@Test func closeWithoutResponseEmitsUnrecoverableError() async throws {
285+
var config = EventSource.Config(url: URL(string: "http://example.com")!)
286+
config.urlSessionConfiguration = sessionWithMockProtocol()
287+
config.reconnectTime = 0.1
288+
let es = EventSource(config: config)
289+
let collector = EventCollector(es.events)
290+
es.start()
291+
let handler = try #require(await MockingProtocol.requested.expectEvent())
292+
// Finish the request without ever sending a response. This is how libcurl (Linux) terminates a
293+
// 3xx redirect whose Location is empty or missing: the task completes with no error and no
294+
// response. The client must surface an unrecoverable error and stop, not reconnect forever.
295+
handler.finish()
296+
let error = await collector.expectError()
297+
#expect(error?.statusCode == nil)
298+
#expect(error?.recoverable == false)
299+
// The unrecoverable error ends the stream; no reconnect attempt should follow.
300+
await MockingProtocol.requested.expectNoEvent()
301+
es.stop()
302+
await expectFullyConsumed(collector)
303+
collector.cancel()
304+
}
305+
284306
@Test func lastEventIdUpdatedByEvents() async throws {
285307
var config = EventSource.Config(url: URL(string: "http://example.com")!)
286308
config.urlSessionConfiguration = sessionWithMockProtocol()

0 commit comments

Comments
 (0)