Skip to content

Commit c285e64

Browse files
authored
chore: kickoff release
2 parents 4a75795 + 595c3c9 commit c285e64

34 files changed

Lines changed: 835 additions & 45 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ 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**: Use Given/When/Then doc comments on all test methods:
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.
102105
```swift
103106
/// Test description
104107
///

Amplify/Categories/Storage/Operation/Request/StorageUploadDataRequest.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,50 +92,59 @@ public extension StorageUploadDataRequest {
9292
/// - Tag: StorageUploadDataRequestOptions.pluginOptions
9393
public let pluginOptions: Any?
9494

95+
/// Override plugin default progress stall timeout for this upload only. `nil` uses the plugin default.
96+
public let progressStallTimeout: ProgressStallTimeout?
97+
9598
/// - Tag: StorageUploadDataRequestOptions.init
9699
@available(*, deprecated, message: "Use init(metadata:contentType:options)")
97100
public init(
98101
accessLevel: StorageAccessLevel = .guest,
99102
targetIdentityId: String? = nil,
100103
metadata: [String: String]? = nil,
101104
contentType: String? = nil,
102-
pluginOptions: Any? = nil
105+
pluginOptions: Any? = nil,
106+
progressStallTimeout: ProgressStallTimeout? = nil
103107
) {
104108
self.accessLevel = accessLevel
105109
self.targetIdentityId = targetIdentityId
106110
self.metadata = metadata
107111
self.bucket = nil
108112
self.contentType = contentType
109113
self.pluginOptions = pluginOptions
114+
self.progressStallTimeout = progressStallTimeout
110115
}
111116

112117
/// - Tag: StorageUploadDataRequestOptions.init
113118
public init(
114119
metadata: [String: String]? = nil,
115120
contentType: String? = nil,
116-
pluginOptions: Any? = nil
121+
pluginOptions: Any? = nil,
122+
progressStallTimeout: ProgressStallTimeout? = nil
117123
) {
118124
self.accessLevel = .guest
119125
self.targetIdentityId = nil
120126
self.metadata = metadata
121127
self.bucket = nil
122128
self.contentType = contentType
123129
self.pluginOptions = pluginOptions
130+
self.progressStallTimeout = progressStallTimeout
124131
}
125132

126133
/// - Tag: StorageUploadDataRequestOptions.init
127134
public init(
128135
metadata: [String: String]? = nil,
129136
bucket: some StorageBucket,
130137
contentType: String? = nil,
131-
pluginOptions: Any? = nil
138+
pluginOptions: Any? = nil,
139+
progressStallTimeout: ProgressStallTimeout? = nil
132140
) {
133141
self.accessLevel = .guest
134142
self.targetIdentityId = nil
135143
self.metadata = metadata
136144
self.bucket = bucket
137145
self.contentType = contentType
138146
self.pluginOptions = pluginOptions
147+
self.progressStallTimeout = progressStallTimeout
139148
}
140149
}
141150
}

Amplify/Categories/Storage/Operation/Request/StorageUploadFileRequest.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,50 +89,59 @@ public extension StorageUploadFileRequest {
8989
/// - Tag: StorageUploadFileRequestOptions.pluginOptions
9090
public let pluginOptions: Any?
9191

92+
/// Override the storage plugin's default progress stall timeout for this upload only. `nil` uses the plugin default.
93+
public let progressStallTimeout: ProgressStallTimeout?
94+
9295
/// - Tag: StorageUploadFileRequestOptions.init
9396
@available(*, deprecated, message: "Use init(metadata:contentType:pluginOptions)")
9497
public init(
9598
accessLevel: StorageAccessLevel = .guest,
9699
targetIdentityId: String? = nil,
97100
metadata: [String: String]? = nil,
98101
contentType: String? = nil,
99-
pluginOptions: Any? = nil
102+
pluginOptions: Any? = nil,
103+
progressStallTimeout: ProgressStallTimeout? = nil
100104
) {
101105
self.accessLevel = accessLevel
102106
self.targetIdentityId = targetIdentityId
103107
self.metadata = metadata
104108
self.bucket = nil
105109
self.contentType = contentType
106110
self.pluginOptions = pluginOptions
111+
self.progressStallTimeout = progressStallTimeout
107112
}
108113

109114
/// - Tag: StorageUploadFileRequestOptions.init
110115
public init(
111116
metadata: [String: String]? = nil,
112117
contentType: String? = nil,
113-
pluginOptions: Any? = nil
118+
pluginOptions: Any? = nil,
119+
progressStallTimeout: ProgressStallTimeout? = nil
114120
) {
115121
self.accessLevel = .guest
116122
self.targetIdentityId = nil
117123
self.metadata = metadata
118124
self.bucket = nil
119125
self.contentType = contentType
120126
self.pluginOptions = pluginOptions
127+
self.progressStallTimeout = progressStallTimeout
121128
}
122129

123130
/// - Tag: StorageUploadFileRequestOptions.init
124131
public init(
125132
metadata: [String: String]? = nil,
126133
bucket: some StorageBucket,
127134
contentType: String? = nil,
128-
pluginOptions: Any? = nil
135+
pluginOptions: Any? = nil,
136+
progressStallTimeout: ProgressStallTimeout? = nil
129137
) {
130138
self.accessLevel = .guest
131139
self.targetIdentityId = nil
132140
self.metadata = metadata
133141
self.bucket = bucket
134142
self.contentType = contentType
135143
self.pluginOptions = pluginOptions
144+
self.progressStallTimeout = progressStallTimeout
136145
}
137146
}
138147
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import Foundation
9+
10+
/// Strategy for cancelling uploads when progress stops advancing.
11+
///
12+
/// Aligns with the pattern used by other Amplify clients (for example flush intervals in the Kinesis client).
13+
/// Configure a default on the S3 storage plugin and optionally override per upload using
14+
/// ``StorageUploadFileRequest/Options`` or ``StorageUploadDataRequest/Options``.
15+
///
16+
/// - Tag: ProgressStallTimeout
17+
public enum ProgressStallTimeout: Sendable, Equatable {
18+
/// Do not cancel uploads when progress stalls.
19+
/// Named `disabled` (not `none`) so it does not collide with `Optional.none` when used as `ProgressStallTimeout?`.
20+
case disabled
21+
/// Cancel the upload if progress does not advance within this interval (seconds).
22+
case interval(TimeInterval)
23+
}
24+
25+
public extension ProgressStallTimeout {
26+
/// Duration in seconds used by the stall timer, or `0` when disabled.
27+
var secondsForStallTimer: TimeInterval {
28+
switch self {
29+
case .disabled:
30+
return 0
31+
case .interval(let seconds):
32+
return max(0, seconds)
33+
}
34+
}
35+
}

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

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

252252
private func resumeExistingSubscriptions() {
253253
log.debug("[AppSyncRealTimeClient] Resuming existing subscriptions")
254-
for (id, _) in subscriptions {
254+
for (id, subscription) 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()
256261
do {
257262
if let cancellable = try await self?.startSubscription(id) {
258263
await self?.storeInConnectionCancellables(cancellable)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ 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+
8796
func unsubscribe() async throws {
8897
guard state.value == .subscribed else {
8998
log.debug("[AppSyncRealTimeSubscription-\(id)] Subscription should be subscribed to be unsubscribed")

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/RefreshAuthorizationSession/UserPool/RefreshUserPoolTokens.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ struct RefreshUserPoolTokens: Action {
3333
let existingTokens = existingSignedIndata.cognitoUserPoolTokens
3434

3535
let deviceMetadata = await DeviceMetadataHelper.getDeviceMetadata(
36-
for: existingSignedIndata.username,
36+
for: existingSignedIndata.inputUsername ?? existingSignedIndata.username,
3737
with: environment
3838
)
3939

@@ -82,7 +82,8 @@ struct RefreshUserPoolTokens: Action {
8282
let signedInData = SignedInData(
8383
signedInDate: existingSignedIndata.signedInDate,
8484
signInMethod: existingSignedIndata.signInMethod,
85-
cognitoUserPoolTokens: userPoolTokens
85+
cognitoUserPoolTokens: userPoolTokens,
86+
inputUsername: existingSignedIndata.inputUsername
8687
)
8788
let event: RefreshSessionEvent
8889

AmplifyPlugins/Core/AWSPluginsCore/WebSocket/WebSocketClient.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,23 @@ 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()
295312
default:
296313
break
297314
}

0 commit comments

Comments
 (0)