@@ -169,6 +169,100 @@ class AppSyncRealTimeClientTests: XCTestCase {
169169 withExtendedLifetime ( cancellables) { }
170170 }
171171
172+ /// End-to-end regression test for https://github.com/aws-amplify/amplify-swift/issues/3976
173+ /// against a real AppSync backend. Simulates the scenePhase-triggered
174+ /// NWPath recycle by driving two .online states through an injected
175+ /// AmplifyNetworkMonitor and asserts that the subscription is actually
176+ /// re-established (server issues a second start_ack).
177+ ///
178+ /// - Given:
179+ /// - An AppSyncRealTimeClient wired to a real AmplifyNetworkMonitor
180+ /// and a real AppSync endpoint from the bundled config.
181+ /// - A live subscription that has already received .subscribed from
182+ /// the server (first start_ack confirmed).
183+ /// - When:
184+ /// - After the WebSocketClient's internal sink has attached (200ms),
185+ /// updateState(.online) is called twice on the network monitor,
186+ /// producing the (.online, .online) tuple from issue #3976.
187+ /// - Then:
188+ /// - The WebSocket is recycled, AppSyncRealTimeClient reconnects,
189+ /// resumeExistingSubscriptions() re-sends `start`, and the server
190+ /// returns a second start_ack — causing a second .subscribed event.
191+ func testSubscribe_afterOnlineToOnlinePathChange_shouldRecycleAndResubscribe( ) async throws {
192+ var cancellables = Set < AnyCancellable > ( )
193+
194+ let data = try TestConfigHelper . retrieve (
195+ forResource: GraphQLModelBasedTests . amplifyConfiguration
196+ )
197+ let amplifyConfig = try JSONDecoder ( ) . decode ( JSONValue . self, from: data)
198+ let ( endpoint, apiKey) = ( amplifyConfig. api? . plugins? . awsAPIPlugin? . asObject? . values
199+ . map { ( $0. endpoint? . stringValue, $0. apiKey? . stringValue) }
200+ . first { $0. 0 != nil && $0. 1 != nil }
201+ . map { ( $0. 0 !, $0. 1 !) } ) !
202+
203+ // Inject a real AmplifyNetworkMonitor we can drive directly.
204+ let networkMonitor = AmplifyNetworkMonitor ( )
205+
206+ let webSocketClient = WebSocketClient (
207+ url: AppSyncRealTimeClientFactory . appSyncRealTimeEndpoint ( URL ( string: endpoint) !) ,
208+ handshakeHttpHeaders: [
209+ URLRequestConstants . Header. webSocketSubprotocols: " graphql-ws " ,
210+ URLRequestConstants . Header. userAgent: AmplifyAWSServiceConfiguration . userAgentLib + " (intg-test-3976) "
211+ ] ,
212+ interceptor: APIKeyAuthInterceptor ( apiKey: apiKey) ,
213+ networkMonitor: networkMonitor
214+ )
215+ let client = AppSyncRealTimeClient (
216+ endpoint: URL ( string: endpoint) !,
217+ requestInterceptor: APIKeyAuthInterceptor ( apiKey: apiKey) ,
218+ webSocketClient: webSocketClient
219+ )
220+ defer { Task { await client. reset ( ) } }
221+
222+ // Wait for WebSocketClient's internal sink to attach to the monitor's
223+ // publisher (it's kicked off via a Task in init). Prime with .online
224+ // AFTER the subscriber is attached so the PassthroughSubject actually
225+ // delivers the event. This gets the scan to (.none, .online) —
226+ // WebSocketClient will ignore it because autoConnect is still false.
227+ try await Task . sleep ( nanoseconds: 200_000_000 )
228+ await networkMonitor. updateState ( . online)
229+
230+ let firstSubscribed = expectation ( description: " Initial subscription established " )
231+ let resubscribedAfterPathChange = expectation ( description: " Subscription re-established after (.online, .online) " )
232+ resubscribedAfterPathChange. assertForOverFulfill = false
233+
234+ let id = UUID ( ) . uuidString
235+ let subscribedCount = AtomicInt ( )
236+ let subscription = try await client. subscribe (
237+ id: id,
238+ query: Self . appSyncQuery ( with: subscriptionRequest)
239+ ) . sink { event in
240+ if case . subscribed = event {
241+ let count = subscribedCount. increment ( )
242+ if count == 1 {
243+ firstSubscribed. fulfill ( )
244+ } else {
245+ resubscribedAfterPathChange. fulfill ( )
246+ }
247+ }
248+ }
249+ cancellables. insert ( subscription)
250+
251+ try await client. connect ( )
252+ await fulfillment ( of: [ firstSubscribed] , timeout: 10 )
253+
254+ // Simulate the path-recycle: second .online emission produces
255+ // (.online, .online) through the scan — the exact bug tuple.
256+ // In the buggy code, nothing happens; WebSocketClient keeps the
257+ // zombie connection. With the fix, it should tear down and reconnect,
258+ // and AppSyncRealTimeClient.resumeExistingSubscriptions() should
259+ // re-subscribe.
260+ await networkMonitor. updateState ( . online)
261+
262+ await fulfillment ( of: [ resubscribedAfterPathChange] , timeout: 15 )
263+ withExtendedLifetime ( cancellables) { }
264+ }
265+
172266 private func makeOneSubscription(
173267 id: String = UUID ( ) . uuidString,
174268 onSubscriptionEvents: ( ( AppSyncSubscriptionEvent ) -> Void ) ?
@@ -201,3 +295,14 @@ class AppSyncRealTimeClientTests: XCTestCase {
201295 }
202296
203297}
298+
299+ private final class AtomicInt : @unchecked Sendable {
300+ private var value : Int = 0
301+ private let lock = NSLock ( )
302+ func increment( ) -> Int {
303+ lock. lock ( )
304+ defer { lock. unlock ( ) }
305+ value += 1
306+ return value
307+ }
308+ }
0 commit comments