Skip to content

Commit a34e63e

Browse files
committed
Add watchdog tests and fix double-reconnect bug on cancelled tasks
- Fix: NSURLErrorCancelled in didCompleteWithError now returns early, preventing double-reconnect when watchdog or reconnect cancels the task - Add HoldingURLProtocol mock and 3 watchdog tests - Make watchdogInterval, baseReconnectDelay, urlSessionConfiguration injectable
1 parent c4c5a1b commit a34e63e

File tree

2 files changed

+133
-7
lines changed

2 files changed

+133
-7
lines changed

Sources/NtfyClient.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ final class NtfyClient: NSObject, @unchecked Sendable {
9393

9494
private var reconnectAttempts = 0
9595
private let maxReconnectAttempts = 10
96-
private let baseReconnectDelay: TimeInterval = 2.0
96+
private let baseReconnectDelay: TimeInterval
9797
private let maxReconnectDelay: TimeInterval = 300.0 // 5 minutes max
9898
private var reconnectTimer: Timer?
9999
private var isConnecting = false
@@ -103,15 +103,17 @@ final class NtfyClient: NSObject, @unchecked Sendable {
103103
private let lastMessageTimeKey: String // UserDefaults key for persistence
104104

105105
// Watchdog: reconnect if no data received for this long (ntfy sends keepalives every ~55s)
106-
private let watchdogInterval: TimeInterval = 120.0
106+
private let watchdogInterval: TimeInterval
107107
private var watchdogTimer: Timer?
108108
private var lastDataReceived: Date = .distantPast
109109

110-
init(serverURL: String, topics: [String], authToken: String? = nil, fetchMissed: Bool = false) {
110+
init(serverURL: String, topics: [String], authToken: String? = nil, fetchMissed: Bool = false, watchdogInterval: TimeInterval = 120.0, baseReconnectDelay: TimeInterval = 2.0, urlSessionConfiguration: URLSessionConfiguration? = nil) {
111111
self.serverURL = serverURL
112112
self.topics = topics
113113
self._authToken = authToken
114114
self.fetchMissed = fetchMissed
115+
self.watchdogInterval = watchdogInterval
116+
self.baseReconnectDelay = baseReconnectDelay
115117

116118
// Restore last message time from UserDefaults for fetch_missed
117119
let topicsKey = topics.sorted().joined(separator: ",")
@@ -125,7 +127,7 @@ final class NtfyClient: NSObject, @unchecked Sendable {
125127

126128
super.init()
127129

128-
let config = URLSessionConfiguration.default
130+
let config = urlSessionConfiguration ?? URLSessionConfiguration.default
129131
config.timeoutIntervalForRequest = .infinity
130132
config.timeoutIntervalForResource = .infinity
131133
config.httpMaximumConnectionsPerHost = 1
@@ -352,11 +354,12 @@ extension NtfyClient: URLSessionDataDelegate {
352354
stopWatchdog()
353355

354356
if let error = error {
355-
// Don't log cancelled errors (normal during reconnect)
356357
let nsError = error as NSError
357-
if nsError.domain != NSURLErrorDomain || nsError.code != NSURLErrorCancelled {
358-
Log.error("Connection error: \(error.localizedDescription)")
358+
// Cancelled errors are expected when we cancel the task ourselves (reconnect/disconnect) — ignore them
359+
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
360+
return
359361
}
362+
Log.error("Connection error: \(error.localizedDescription)")
360363
callDelegate { delegate in
361364
delegate.ntfyClient(self, didEncounterError: error)
362365
delegate.ntfyClientDidDisconnect(self)

Tests/ntfy-macosTests/NtfyClientTests.swift

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,70 @@
11
import XCTest
22
@testable import ntfy_macos
33

4+
// MARK: - Mock URLProtocol for watchdog tests
5+
6+
/// Intercepts URLSession requests and returns HTTP 200 without sending any data,
7+
/// simulating a stale connection where the server stops sending keepalives.
8+
final class HoldingURLProtocol: URLProtocol {
9+
static let requestCountLock = NSLock()
10+
nonisolated(unsafe) private static var _requestCount = 0
11+
static var requestCount: Int {
12+
get { requestCountLock.lock(); defer { requestCountLock.unlock() }; return _requestCount }
13+
set { requestCountLock.lock(); defer { requestCountLock.unlock() }; _requestCount = newValue }
14+
}
15+
nonisolated(unsafe) static var onRequest: ((Int) -> Void)?
16+
17+
static func reset() {
18+
requestCount = 0
19+
onRequest = nil
20+
}
21+
22+
override class func canInit(with request: URLRequest) -> Bool { true }
23+
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
24+
25+
override func startLoading() {
26+
let count = HoldingURLProtocol.requestCount + 1
27+
HoldingURLProtocol.requestCount = count
28+
HoldingURLProtocol.onRequest?(count)
29+
30+
// Return HTTP 200 but never send data or finish — simulates stale connection
31+
let response = HTTPURLResponse(
32+
url: request.url!,
33+
statusCode: 200,
34+
httpVersion: nil,
35+
headerFields: nil
36+
)!
37+
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
38+
// Intentionally no didLoad or didFinishLoading — connection stays "open"
39+
}
40+
41+
override func stopLoading() {}
42+
}
43+
44+
// MARK: - Mock delegate
45+
46+
final class MockNtfyDelegate: NtfyClientDelegate {
47+
var onConnect: (() -> Void)?
48+
var onDisconnect: (() -> Void)?
49+
var onError: ((Error) -> Void)?
50+
var onMessage: ((NtfyMessage) -> Void)?
51+
52+
func ntfyClientDidConnect(_ client: NtfyClient) { onConnect?() }
53+
func ntfyClientDidDisconnect(_ client: NtfyClient) { onDisconnect?() }
54+
func ntfyClient(_ client: NtfyClient, didEncounterError error: Error) { onError?(error) }
55+
func ntfyClient(_ client: NtfyClient, didReceiveMessage message: NtfyMessage) { onMessage?(message) }
56+
}
57+
58+
// MARK: - Helper
59+
60+
private func makeSessionConfig() -> URLSessionConfiguration {
61+
let config = URLSessionConfiguration.ephemeral
62+
config.protocolClasses = [HoldingURLProtocol.self]
63+
return config
64+
}
65+
66+
// MARK: - Tests
67+
468
final class NtfyClientTests: XCTestCase {
569
// MARK: - URL Construction
670

@@ -90,4 +154,63 @@ final class NtfyClientTests: XCTestCase {
90154
client.disconnect()
91155
client.disconnect()
92156
}
157+
158+
// MARK: - Watchdog
159+
160+
func testWatchdogIntervalIsConfigurable() {
161+
let client = NtfyClient(serverURL: "https://ntfy.sh", topics: ["test"], watchdogInterval: 30.0)
162+
XCTAssertNotNil(client)
163+
}
164+
165+
/// After disconnect(), the watchdog must not fire and trigger a reconnect.
166+
@MainActor func testWatchdogDoesNotFireAfterDisconnect() {
167+
HoldingURLProtocol.reset()
168+
let connectExp = expectation(description: "No reconnect after disconnect")
169+
connectExp.isInverted = true
170+
171+
let delegate = MockNtfyDelegate()
172+
delegate.onConnect = { connectExp.fulfill() }
173+
174+
let client = NtfyClient(
175+
serverURL: "https://ntfy.sh", topics: ["test"],
176+
watchdogInterval: 0.05, baseReconnectDelay: 0.0,
177+
urlSessionConfiguration: makeSessionConfig()
178+
)
179+
client.delegate = delegate
180+
client.connect()
181+
client.disconnect() // Immediately cancel — watchdog must not trigger
182+
183+
waitForExpectations(timeout: 0.3)
184+
}
185+
186+
/// Cancelled task errors must not trigger reconnect (avoids double-reconnect when watchdog fires).
187+
@MainActor func testCancelledTaskDoesNotTriggerReconnect() {
188+
HoldingURLProtocol.reset()
189+
190+
// Connect, then immediately disconnect — only 1 request should ever be made
191+
// (no spurious reconnect from the cancelled task's error)
192+
let initialExp = expectation(description: "Initial request received")
193+
HoldingURLProtocol.onRequest = { count in
194+
if count >= 1 { initialExp.fulfill() }
195+
}
196+
197+
let client = NtfyClient(
198+
serverURL: "https://ntfy.sh", topics: ["test"],
199+
watchdogInterval: 60.0, baseReconnectDelay: 0.0,
200+
urlSessionConfiguration: makeSessionConfig()
201+
)
202+
client.connect()
203+
waitForExpectations(timeout: 1.0)
204+
205+
// Disconnect and wait — request count must stay at 1
206+
client.disconnect()
207+
let noExtraExp = expectation(description: "No extra reconnect request")
208+
noExtraExp.isInverted = true
209+
HoldingURLProtocol.onRequest = { count in
210+
if count >= 2 { noExtraExp.fulfill() }
211+
}
212+
waitForExpectations(timeout: 0.3)
213+
214+
XCTAssertEqual(HoldingURLProtocol.requestCount, 1)
215+
}
93216
}

0 commit comments

Comments
 (0)