Skip to content

Commit a99cd95

Browse files
tmmampagent
andauthored
test(zero-swift): wait out a connect attempt a stalled system service holds up (#128)
TransportProbeTests.clientReconnectsAfterTheServerGoesAwayMidFrame and AppLifecycleTests.hiddenDisconnectsAndShownReconnects flaked on the iOS Simulator: a URLSession connect attempt never reached the in-process server, and the test gave up as the client's connect timeout (10 s) ended it, before the client's own retry, 250–500 ms later. Freezing the simulator's configd_sim reproduces both failures line for line, with the process running: the server sees no connection, the client logs "Connecting to …" and then the timeout. The lifecycle test waited 10 s for a connection. The probe waited 15 s, but URLSession only sees a frame cut by the server's FIN when the client next writes, its ping 5 s in, so the reconnect had under 10 s. Waits for a connection now cover one timed-out attempt and the first retry: the connect timeout, the retry's longest wait, and 2 s, plus the ping interval in the probe. Under the same freezes both tests failed before and pass now, and the client connects on its own retry. The probe's failure output now says how many TCP connections the server accepted, which tells a reconnect that never reached the server from one whose request didn't arrive. Amp-Thread-ID: https://ampcode.com/threads/T-0d2ed206-a5dd-4f42-9b68-c56e6b23e8f6 Co-authored-by: Amp <amp@ampcode.com>
1 parent 33712ed commit a99cd95

2 files changed

Lines changed: 28 additions & 16 deletions

File tree

‎swift/Tests/AppleTests/AppLifecycleTests.swift‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
return zero
121121
}
122122

123-
/// Whether the client's attempt to connect timed out because the process was stopped. A stop longer than the connect timeout (10 s) while the client is connecting ends the attempt when the process continues, before the server's acknowledgement is read, and the client connects again after its backoff, as zero-client does. Without a stop, a timeout is a failure the tests catch.
123+
/// Whether the client's attempt to connect timed out because the process was stopped. A stop longer than the connect timeout (10 s) while the client is connecting ends the attempt when the process continues, before the server's acknowledgement is read, and the client connects again after its backoff, as zero-client does. Without a stop, a timeout of an attempt that reached the server is a failure the tests catch.
124124
func timedOutWhileStopped(_ zero: Zero<Void>) -> Bool {
125125
guard
126126
case .connecting(_, _, .client(.connectTimeout, _)?) = zero.engine.connectionManager.state
@@ -172,6 +172,15 @@
172172
return true
173173
}
174174

175+
/// How long a test waits for the client to connect. An attempt that a stalled system service holds up never reaches the server (freezing the iOS Simulator's `configd_sim` does it, and CI's simulator has done it): the client gives up on it at the connect timeout and tries again within the first retry's longest wait. The wait covers one such attempt, and 2 s for the retry to connect.
176+
static let connectWait = Duration.milliseconds(
177+
connectTimeoutMs + fastReconnectInitialMs + 2_000)
178+
179+
/// Waits up to `connectWait` for `zero` to connect.
180+
func connected(_ zero: Zero<Void>, _ what: String = "connected") async throws {
181+
try await eventually(what, timeout: Self.connectWait) { zero.connection.state == .connected }
182+
}
183+
175184
func eventually(
176185
_ what: String, timeout: Duration = .seconds(10), _ condition: () -> Bool
177186
) async throws {
@@ -189,12 +198,12 @@
189198
defer { server.stop() }
190199
let zero = try makeZero(server)
191200
defer { zero.close() }
192-
try await eventually("connected") { zero.connection.state == .connected }
201+
try await connected(zero)
193202
hide()
194203
try await eventually("disconnected") { zero.connection.state.status == .disconnected }
195204
try await eventually("the socket closed") { server.open == 0 }
196205
show()
197-
try await eventually("reconnected") { zero.connection.state == .connected }
206+
try await connected(zero, "reconnected")
198207
#expect(connections(2).contains(server.connected))
199208
#expect(server.open == 1)
200209
}
@@ -206,7 +215,7 @@
206215
server.acknowledge = false
207216
let zero = try makeZero(server)
208217
defer { zero.close() }
209-
try await eventually("the socket") { server.connected == 1 }
218+
try await eventually("the socket", timeout: Self.connectWait) { server.connected == 1 }
210219
hide()
211220
try await Task.sleep(for: .milliseconds(100))
212221
server.acknowledgeAll()
@@ -220,7 +229,7 @@
220229
#expect(closed, "the socket stayed open")
221230
server.acknowledge = true
222231
show()
223-
try await eventually("reconnected") { zero.connection.state == .connected }
232+
try await connected(zero, "reconnected")
224233
#expect(server.open == 1)
225234
}
226235

@@ -230,7 +239,7 @@
230239
defer { server.stop() }
231240
let zero = try makeZero(server)
232241
defer { zero.close() }
233-
try await eventually("connected") { zero.connection.state == .connected }
242+
try await connected(zero)
234243
for round in 0..<3 {
235244
for _ in 0..<50 {
236245
hide()
@@ -240,9 +249,7 @@
240249
for _ in 0..<round { await Task.yield() }
241250
}
242251
}
243-
try await eventually("connected after the transitions") {
244-
zero.connection.state == .connected
245-
}
252+
try await connected(zero, "connected after the transitions")
246253
try await eventually("one socket open", timeout: .seconds(10)) { server.open == 1 }
247254
for _ in 0..<50 {
248255
show()
@@ -261,7 +268,7 @@
261268
let server = try await Server(log)
262269
defer { server.stop() }
263270
let zero = try makeZero(server)
264-
try await eventually("connected") { zero.connection.state == .connected }
271+
try await connected(zero)
265272
zero.close()
266273
try await eventually("the socket closed") { server.open == 0 }
267274
for _ in 0..<5 {
@@ -282,7 +289,7 @@
282289
do {
283290
let zero = try makeZero(server)
284291
weakZero = zero
285-
try await eventually("connected") { zero.connection.state == .connected }
292+
try await connected(zero)
286293
}
287294
for _ in 0..<5 {
288295
hide()
@@ -300,7 +307,7 @@
300307
defer { server.stop() }
301308
let zero = try makeZero(server)
302309
defer { zero.close() }
303-
try await eventually("connected") { zero.connection.state == .connected }
310+
try await connected(zero)
304311
zero.mutate(Notes.put(.init(id: "n1", text: "pending")))
305312
try await eventually("the push") { server.messages.contains { $0.hasPrefix(#"["push""#) } }
306313
hide()
@@ -317,7 +324,7 @@
317324
defer { server.stop() }
318325
let zero = try makeZero(server)
319326
defer { zero.close() }
320-
try await eventually("connected") { zero.connection.state == .connected }
327+
try await connected(zero)
321328
zero.mutate(Notes.put(.init(id: "n1", text: "pending")))
322329
let start = ContinuousClock.now
323330
hide()

‎swift/Tests/E2ETests/TransportProbeTests.swift‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import Foundation
22
import Synchronization
33
import Testing
4-
import Zero
4+
5+
@testable import Zero
56

67
/// A transport the probes run against.
78
enum TransportKind: String, CustomTestStringConvertible, CaseIterable, Sendable {
@@ -268,14 +269,18 @@ struct TransportProbeTests {
268269
schema: .test, options: options, socketFactory: TransportSocketFactory(kind.transport))
269270
var states: [ConnectionStatus] = []
270271
let unsubscribe = zero.connection.subscribe { states.append($0.status) }
271-
let reconnected = try await within(.seconds(15)) {
272+
// URLSession sees the cut only once the client writes, which is its ping after `pingTimeoutMs`. A reconnect that a stalled system service holds up never reaches the server (freezing the iOS Simulator's `configd_sim` does it, and CI's simulator has done it): the client gives up on it at the connect timeout and tries again within the first retry's longest wait. The window covers one such attempt, and 2 s for the retry to connect.
273+
let window = Duration.milliseconds(
274+
options.pingTimeoutMs + connectTimeoutMs + fastReconnectInitialMs + 2_000)
275+
let reconnected = try await within(window) {
272276
connections.withLock { $0 } >= 2 && zero.connection.state == .connected
273277
}
274278
print(
275279
"[probe] \(kind) client, server gone mid-\(binary ? "binary" : "text") frame: reconnected \(reconnected), states \(states), \(zero.connection.state)"
276280
)
277281
if !reconnected {
278-
print("[probe] server: \(seen.withLock { $0 })")
282+
print(
283+
"[probe] server: \(seen.withLock { $0 }), TCP connections accepted: \(server.accepted)")
279284
for line in logs.all.suffix(8) { print("[probe] \(line.prefix(300))") }
280285
}
281286
#expect(reconnected, "binary \(binary): \(zero.connection.state)")

0 commit comments

Comments
 (0)