Skip to content

Commit d542d29

Browse files
authored
Revert "fix(api): recycle WebSocket and resubscribe on same-online network path change (#4202)" (#4228)
This reverts commit be0a214.
1 parent 7428f64 commit d542d29

6 files changed

Lines changed: 2 additions & 314 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,7 @@ swift test --filter AWSCognitoAuthPluginUnitTests # Specific target
9898
- **Unit tests**: XCTest, defined in Package.swift (19 test targets)
9999
- **Integration tests**: Xcode host app projects under `AmplifyPlugins/<Category>/Tests/<Category>HostApp/`
100100
- **Conventions**: Mock via behavior protocols, use `AmplifyTestCommon` for shared utilities, `AmplifyAsyncTesting` for async helpers
101-
- **Test documentation (MANDATORY)**: Every new or modified test method
102-
**must** have a Given/When/Then doc comment. No exceptions — this applies
103-
to unit tests, integration tests, and regression tests alike. Reviewers
104-
should reject PRs that add tests without this structure.
101+
- **Test documentation**: Use Given/When/Then doc comments on all test methods:
105102
```swift
106103
/// Test description
107104
///

AmplifyPlugins/API/Sources/AWSAPIPlugin/AppSyncRealTimeClient/AppSyncRealTimeClient.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,8 @@ actor AppSyncRealTimeClient: AppSyncRealTimeClientProtocol {
251251

252252
private func resumeExistingSubscriptions() {
253253
log.debug("[AppSyncRealTimeClient] Resuming existing subscriptions")
254-
for (id, subscription) in subscriptions {
254+
for (id, _) in subscriptions {
255255
Task { [weak self] in
256-
// Reset local state so subscribe() re-sends .start to the
257-
// server. After a reconnect, the server has no memory of
258-
// prior subscriptions, so stale local .subscribed state must
259-
// not short-circuit the resubscription.
260-
await subscription.prepareForResubscribe()
261256
do {
262257
if let cancellable = try await self?.startSubscription(id) {
263258
await self?.storeInConnectionCancellables(cancellable)

AmplifyPlugins/API/Sources/AWSAPIPlugin/AppSyncRealTimeClient/AppSyncRealTimeSubscription.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,6 @@ actor AppSyncRealTimeSubscription {
8484
state.send(.subscribed)
8585
}
8686

87-
/// Reset local subscription state so `subscribe()` will actually resend
88-
/// the `.start` request after a WebSocket reconnect. The server drops
89-
/// subscription state when the connection dies, so local `.subscribed`
90-
/// state is stale and must not short-circuit the resubscription path.
91-
/// Fix for https://github.com/aws-amplify/amplify-swift/issues/3976
92-
func prepareForResubscribe() {
93-
state.send(.none)
94-
}
95-
9687
func unsubscribe() async throws {
9788
guard state.value == .subscribed else {
9889
log.debug("[AppSyncRealTimeSubscription-\(id)] Subscription should be subscribed to be unsubscribed")

AmplifyPlugins/API/Tests/APIHostApp/AWSAPIPluginFunctionalTests/AppSyncRealTimeClientTests.swift

Lines changed: 0 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -169,100 +169,6 @@ 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-
266172
private func makeOneSubscription(
267173
id: String = UUID().uuidString,
268174
onSubscriptionEvents: ((AppSyncSubscriptionEvent) -> Void)?
@@ -295,14 +201,3 @@ class AppSyncRealTimeClientTests: XCTestCase {
295201
}
296202

297203
}
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-
}

AmplifyPlugins/Core/AWSPluginsCore/WebSocket/WebSocketClient.swift

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -292,23 +292,6 @@ extension WebSocketClient {
292292
case (.offline, .online):
293293
log.debug("[WebSocketClient] NetworkMonitor - Device back online")
294294
await createConnectionAndRead()
295-
case (.online, .online):
296-
// NWPathMonitor's pathUpdateHandler only fires on real path
297-
// changes, so a second .satisfied emission while we were already
298-
// online means the underlying path was swapped (e.g., iOS recycled
299-
// the TCP route during a scenePhase transition). The existing
300-
// URLSessionWebSocketTask is now bound to a stale route and any
301-
// further reads/writes will silently fail, leaving the client in
302-
// a zombie state that cached consumers can't recover from.
303-
// Fix for https://github.com/aws-amplify/amplify-swift/issues/3976
304-
guard connection?.state == .running else {
305-
log.debug("[WebSocketClient] NetworkMonitor - Path changed but connection is not running, skipping recycle")
306-
break
307-
}
308-
log.debug("[WebSocketClient] NetworkMonitor - Network path changed while online, recycling connection")
309-
connection?.cancel(with: .invalid, reason: nil)
310-
subject.send(.disconnected(.invalid, nil))
311-
await createConnectionAndRead()
312295
default:
313296
break
314297
}

AmplifyPlugins/Core/AWSPluginsCoreTests/WebSocket/WebSocketClientTests.swift

Lines changed: 0 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -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-
371198
private class MockNetworkMonitor: WebSocketNetworkMonitorProtocol {
372199
typealias State = AmplifyNetworkMonitor.State
373200
let subject = PassthroughSubject<State, Never>()

0 commit comments

Comments
 (0)