@@ -139,168 +139,6 @@ class WebSocketClientTests: XCTestCase {
139139 await fulfillment ( of: [ reconnectExpectation] , timeout: timeout)
140140 }
141141
142- /// Regression test for https://github.com/aws-amplify/amplify-swift/issues/3976.
143- /// When iOS recycles the TCP route during a scenePhase transition,
144- /// NWPathMonitor reports .satisfied both before and after, producing
145- /// (.online, .online) through AmplifyNetworkMonitor's scan. Before the
146- /// fix, WebSocketClient.onNetworkStateChange hit `default: break` and
147- /// left the stale URLSessionWebSocketTask in place — a zombie.
148- ///
149- /// - Given:
150- /// - A WebSocketClient connected via a MockNetworkMonitor whose scan
151- /// seed is (.online, .online), so one updateState(.online) produces
152- /// the bug tuple deterministically.
153- /// - autoConnectOnNetworkStatusChange is true.
154- /// - When:
155- /// - The mock emits .online, producing an (.online, .online) tuple.
156- /// - Then:
157- /// - WebSocketClient sends a `.disconnected` event (stale task torn down).
158- /// - WebSocketClient emits a fresh `.connected` event (new connection).
159- func testWebSocketClient_whenNetworkPathChangesWhileOnline_shouldRecycleConnection( ) async throws {
160- var cancellables = Set < AnyCancellable > ( )
161- guard let endpoint = try localWebSocketServer? . start ( ) else {
162- XCTFail ( " Local WebSocket server failed to start " )
163- return
164- }
165-
166- let mockNetworkMonitor = MockNetworkMonitor ( )
167- let webSocketClient = WebSocketClient ( url: endpoint, networkMonitor: mockNetworkMonitor)
168- await verifyConnected ( webSocketClient, autoConnectOnNetworkStatusChange: true )
169-
170- let disconnectExpectation = expectation ( description: " Path change should force a disconnect " )
171- let reconnectExpectation = expectation ( description: " Path change should trigger a reconnect " )
172-
173- await webSocketClient. publisher. sink { event in
174- switch event {
175- case . disconnected:
176- disconnectExpectation. fulfill ( )
177- case . connected:
178- reconnectExpectation. fulfill ( )
179- default :
180- break
181- }
182- }
183- . store ( in: & cancellables)
184-
185- // Simulate NWPathMonitor firing .satisfied again after a path recycle.
186- // The scan seed in MockNetworkMonitor is (.online, .online), so sending
187- // .online produces exactly the (.online, .online) tuple from issue #3976.
188- await mockNetworkMonitor. updateState ( . online)
189-
190- await fulfillment (
191- of: [ disconnectExpectation, reconnectExpectation] ,
192- timeout: timeout,
193- enforceOrder: true
194- )
195- }
196-
197- /// Integration-level companion to the mock-based test above. Proves the
198- /// fix works with the real AmplifyNetworkMonitor's scan seed (.none, .none)
199- /// — i.e., the bug is not an artifact of MockNetworkMonitor's seeding.
200- /// Drives state through `updateState`, the same seam WebSocketClient
201- /// itself uses when reporting connectionLost. Tolerates spontaneous
202- /// NWPathMonitor firings on watchOS by counting `.connected` events
203- /// instead of using the strict verifyConnected helper.
204- ///
205- /// - Given:
206- /// - A WebSocketClient wired to a real AmplifyNetworkMonitor with its
207- /// natural scan seed (.none, .none).
208- /// - A publisher sink attached before connect() so no events are lost
209- /// while the client's internal sink is still attaching.
210- /// - autoConnectOnNetworkStatusChange is true.
211- /// - When:
212- /// - The monitor's updateState(.online) is called twice, producing
213- /// (.none, .online) then (.online, .online) through the scan.
214- /// - Then:
215- /// - A second `.connected` event is observed (initial connect + recycle
216- /// reconnect), confirming the WebSocket was torn down and rebuilt.
217- func testWebSocketClient_withRealNetworkMonitor_whenPathChangesWhileOnline_shouldRecycle( ) async throws {
218- var cancellables = Set < AnyCancellable > ( )
219- guard let endpoint = try localWebSocketServer? . start ( ) else {
220- XCTFail ( " Local WebSocket server failed to start " )
221- return
222- }
223-
224- let realNetworkMonitor = AmplifyNetworkMonitor ( )
225- let webSocketClient = WebSocketClient ( url: endpoint, networkMonitor: realNetworkMonitor)
226-
227- let initialConnect = expectation ( description: " Initial WebSocket connect " )
228- let reconnectAfterPathChange = expectation ( description: " Reconnect after (.online, .online) " )
229- let connectedCounter = AtomicInt ( )
230-
231- await webSocketClient. publisher. sink { event in
232- if case . connected = event {
233- let count = connectedCounter. increment ( )
234- if count == 1 {
235- initialConnect. fulfill ( )
236- } else if count == 2 {
237- reconnectAfterPathChange. fulfill ( )
238- }
239- }
240- // Tolerate .disconnected / .error / .string / .data events,
241- // which can arrive from NWPathMonitor-driven recycling or from
242- // LocalWebSocketServer teardown.
243- }
244- . store ( in: & cancellables)
245-
246- await webSocketClient. connect (
247- autoConnectOnNetworkStatusChange: true ,
248- autoRetryOnConnectionFailure: false
249- )
250- await fulfillment ( of: [ initialConnect] , timeout: timeout)
251-
252- // WebSocketClient.init spawns its sink via Task { startNetworkMonitor() };
253- // by the time initialConnect fulfils, the sink is attached.
254- // Prime the scan so (previous, next) reaches (.online, .online) on
255- // the second updateState — first reaches (.none, .online).
256- await realNetworkMonitor. updateState ( . online)
257-
258- // Second .online emission → scan produces (.online, .online) —
259- // the exact tuple from issue #3976. With the fix, this triggers a
260- // recycle that yields a second `.connected`.
261- await realNetworkMonitor. updateState ( . online)
262-
263- await fulfillment ( of: [ reconnectAfterPathChange] , timeout: timeout)
264- }
265-
266- /// Characterizes the input signal that drives issue #3976. Proves that
267- /// the real AmplifyNetworkMonitor.publisher emits the (.online, .online)
268- /// tuple when two .online states are sent consecutively — which is what
269- /// WebSocketClient.onNetworkStateChange receives during a scenePhase-
270- /// triggered NWPath recycle. Does not exercise the fix; passes both
271- /// before and after.
272- ///
273- /// - Given:
274- /// - A fresh AmplifyNetworkMonitor instance.
275- /// - A publisher sink that watches for (.online, .online) tuples.
276- /// - When:
277- /// - updateState(.online) is called twice consecutively.
278- /// - Then:
279- /// - The publisher emits an (.online, .online) tuple via its scan —
280- /// confirming this is the exact signal WebSocketClient must handle.
281- func testAmplifyNetworkMonitor_whenOnlineEmittedTwice_publishesOnlineOnlineTuple( ) async throws {
282- var cancellables = Set < AnyCancellable > ( )
283- let monitor = AmplifyNetworkMonitor ( )
284-
285- let expectOnlineOnline = expectation ( description: " publisher emits (.online, .online) " )
286- expectOnlineOnline. assertForOverFulfill = false
287-
288- monitor. publisher. sink { tuple in
289- if tuple. 0 == . online && tuple. 1 == . online {
290- expectOnlineOnline. fulfill ( )
291- }
292- }
293- . store ( in: & cancellables)
294-
295- // Two consecutive .online emissions must produce an (.online, .online)
296- // tuple through the scan — the exact input that triggers issue #3976
297- // in WebSocketClient.onNetworkStateChange.
298- await monitor. updateState ( . online)
299- await monitor. updateState ( . online)
300-
301- await fulfillment ( of: [ expectOnlineOnline] , timeout: timeout)
302- }
303-
304142 func testAutoRetry_whenReceiveTransientFailureFromServer( ) async throws {
305143 var cancellables = Set < AnyCancellable > ( )
306144 guard let endpoint = try localWebSocketServer? . start ( ) else {
@@ -357,17 +195,6 @@ class WebSocketClientTests: XCTestCase {
357195}
358196
359197
360- private final class AtomicInt : @unchecked Sendable {
361- private var value : Int = 0
362- private let lock = NSLock ( )
363- func increment( ) -> Int {
364- lock. lock ( )
365- defer { lock. unlock ( ) }
366- value += 1
367- return value
368- }
369- }
370-
371198private class MockNetworkMonitor : WebSocketNetworkMonitorProtocol {
372199 typealias State = AmplifyNetworkMonitor . State
373200 let subject = PassthroughSubject < State , Never > ( )
0 commit comments