Skip to content

Commit 40f2de1

Browse files
sumeruchatclaudejoaodordio
authored
SDK-468 Fix NetworkMonitor memory leak (#1065)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Joao Dordio <joaodordio@icloud.com>
1 parent f534e15 commit 40f2de1

6 files changed

Lines changed: 256 additions & 10 deletions

File tree

agent_test.sh

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ fi
5757

5858
echo "Running Iterable Swift SDK unit tests..."
5959

60+
# Resolve the simulator by name and boot it via CoreSimulator. Targeting by
61+
# UDID avoids xcodebuild's "no matching device" failures when multiple devices
62+
# share the same name across runtimes. This does NOT touch any Simulator.app
63+
# instance the user may already have open for another project.
64+
SIMULATOR_NAME="${SIMULATOR_NAME:-iPhone 17 Pro}"
65+
SIMULATOR_ID=$(xcrun simctl list devices available | grep -F "$SIMULATOR_NAME (" | head -1 | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/')
66+
67+
if [[ -z "$SIMULATOR_ID" ]]; then
68+
echo "❌ Could not find available simulator named '$SIMULATOR_NAME'"
69+
exit 1
70+
fi
71+
72+
xcrun simctl boot "$SIMULATOR_ID" >/dev/null 2>&1 || true
73+
xcrun simctl bootstatus "$SIMULATOR_ID" -b >/dev/null
74+
6075
# Create a temporary file for the test output
6176
TEMP_OUTPUT=$(mktemp)
6277

@@ -65,7 +80,7 @@ XCODEBUILD_CMD="xcodebuild test \
6580
-project swift-sdk.xcodeproj \
6681
-scheme swift-sdk \
6782
-sdk iphonesimulator \
68-
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
83+
-destination 'platform=iOS Simulator,id=$SIMULATOR_ID' \
6984
-enableCodeCoverage YES \
7085
-skipPackagePluginValidation \
7186
CODE_SIGNING_REQUIRED=NO"
@@ -124,4 +139,4 @@ fi
124139
# Remove the temporary file
125140
rm $TEMP_OUTPUT
126141

127-
exit $FINAL_STATUS
142+
exit $FINAL_STATUS

swift-sdk.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@
404404
ACDBB33B239582450036BB38 /* NotificationExtensionConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACDBB33A239582450036BB38 /* NotificationExtensionConstants.swift */; };
405405
ACE34AB5213776CB00691224 /* LocalStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACE34AB4213776CB00691224 /* LocalStorageTests.swift */; };
406406
ACED4C01213F50B30055A497 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACED4C00213F50B30055A497 /* LoggingTests.swift */; };
407+
8A4680022E4D000100000001 /* NetworkMonitorLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4680012E4D000100000001 /* NetworkMonitorLifecycleTests.swift */; };
407408
ACEDF41F2183C436000B9BFE /* PendingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACEDF41E2183C436000B9BFE /* PendingTests.swift */; };
408409
5C0471F221F0F0F0000B7CCC /* PendingConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0471F121F0F0F0000B7CCC /* PendingConcurrencyTests.swift */; };
409410
ACF406252507F90F005FD775 /* NetworkConnectivityManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACF406242507F90F005FD775 /* NetworkConnectivityManagerTests.swift */; };
@@ -850,6 +851,7 @@
850851
ACDBB33A239582450036BB38 /* NotificationExtensionConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationExtensionConstants.swift; sourceTree = "<group>"; };
851852
ACE34AB4213776CB00691224 /* LocalStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalStorageTests.swift; sourceTree = "<group>"; };
852853
ACED4C00213F50B30055A497 /* LoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingTests.swift; sourceTree = "<group>"; };
854+
8A4680012E4D000100000001 /* NetworkMonitorLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitorLifecycleTests.swift; sourceTree = "<group>"; };
853855
ACEDF41E2183C436000B9BFE /* PendingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingTests.swift; sourceTree = "<group>"; };
854856
5C0471F121F0F0F0000B7CCC /* PendingConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingConcurrencyTests.swift; sourceTree = "<group>"; };
855857
ACF406242507F90F005FD775 /* NetworkConnectivityManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkConnectivityManagerTests.swift; sourceTree = "<group>"; };
@@ -1373,6 +1375,7 @@
13731375
55B37FC0229620D20042F13A /* CommerceItemTests.swift */,
13741376
5536781E2576FF9000DB3652 /* IterableUtilTests.swift */,
13751377
ACED4C00213F50B30055A497 /* LoggingTests.swift */,
1378+
8A4680012E4D000100000001 /* NetworkMonitorLifecycleTests.swift */,
13761379
55B37FC5229752DD0042F13A /* OrderedDictionaryTests.swift */,
13771380
ACEDF41E2183C436000B9BFE /* PendingTests.swift */,
13781381
5C0471F121F0F0F0000B7CCC /* PendingConcurrencyTests.swift */,
@@ -2410,6 +2413,7 @@
24102413
5588DFE928C046D7000697D7 /* MockInboxState.swift in Sources */,
24112414
181063DF2C9D51000078E0ED /* ValidateStoredEventCheckUnknownToKnownUserTest.swift in Sources */,
24122415
ACED4C01213F50B30055A497 /* LoggingTests.swift in Sources */,
2416+
8A4680022E4D000100000001 /* NetworkMonitorLifecycleTests.swift in Sources */,
24132417
AC52C5B8272A8B32000DCDCF /* KeychainWrapperTests.swift in Sources */,
24142418
ACC3FD9E2536D7A30004A2E0 /* InAppFilePersistenceTests.swift in Sources */,
24152419
5B5AA714284F1A6D0093FED4 /* MockNetworkSession.swift in Sources */,

swift-sdk/Internal/Network/NetworkConnectivityManager.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,14 @@ class NetworkConnectivityManager: NSObject {
4848

4949
func stop() {
5050
ITBInfo()
51+
networkMonitor.statusUpdatedCallback = nil
5152
networkMonitor.stop()
5253
stopTimer()
5354
}
5455

5556
private func startTimer() {
5657
ITBInfo()
58+
stopTimer()
5759
let interval = online ? onlineModePollingInterval : offlineModePollingInterval
5860
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true, block: { [weak self] _ in
5961
ITBInfo("timer called")
@@ -75,8 +77,8 @@ class NetworkConnectivityManager: NSObject {
7577

7678
private func updateStatus() {
7779
ITBInfo()
78-
connectivityChecker.checkConnectivity().onSuccess { connected in
79-
self.online = connected
80+
connectivityChecker.checkConnectivity().onSuccess { [weak self] connected in
81+
self?.online = connected
8082
}
8183
}
8284

@@ -116,4 +118,3 @@ class NetworkConnectivityManager: NSObject {
116118
}
117119
}
118120
}
119-

swift-sdk/Internal/Network/NetworkMonitor.swift

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,45 @@ import Foundation
88
import Network
99
#endif
1010

11+
struct NetworkPathUpdate {
12+
let debugDescription: String
13+
let status: String
14+
}
15+
16+
protocol NetworkPathMonitorProtocol: AnyObject {
17+
var pathUpdateHandler: ((NetworkPathUpdate) -> Void)? { get set }
18+
func start(queue: DispatchQueue)
19+
func cancel()
20+
}
21+
22+
#if canImport(Network)
23+
private final class NetworkPathMonitor: NetworkPathMonitorProtocol {
24+
var pathUpdateHandler: ((NetworkPathUpdate) -> Void)? {
25+
didSet {
26+
guard let pathUpdateHandler = pathUpdateHandler else {
27+
monitor.pathUpdateHandler = nil
28+
return
29+
}
30+
31+
monitor.pathUpdateHandler = { path in
32+
pathUpdateHandler(NetworkPathUpdate(debugDescription: path.debugDescription,
33+
status: String(describing: path.status)))
34+
}
35+
}
36+
}
37+
38+
func start(queue: DispatchQueue) {
39+
monitor.start(queue: queue)
40+
}
41+
42+
func cancel() {
43+
monitor.cancel()
44+
}
45+
46+
private let monitor = NWPathMonitor()
47+
}
48+
#endif
49+
1150
/// Listens to network interface to detect status change.
1251
/// It only knows that the status has changed.
1352
/// It does not know if the network is online or not.
@@ -18,8 +57,9 @@ protocol NetworkMonitorProtocol {
1857
}
1958

2059
class NetworkMonitor: NetworkMonitorProtocol {
21-
init() {
60+
init(pathMonitorFactory: @escaping () -> NetworkPathMonitorProtocol = { NetworkPathMonitor() }) {
2261
ITBInfo()
62+
self.pathMonitorFactory = pathMonitorFactory
2363
}
2464

2565
deinit {
@@ -31,10 +71,12 @@ class NetworkMonitor: NetworkMonitorProtocol {
3171

3272
func start() {
3373
ITBInfo()
34-
let networkMonitor = NWPathMonitor()
35-
networkMonitor.pathUpdateHandler = { path in
74+
stop()
75+
76+
let networkMonitor = pathMonitorFactory()
77+
networkMonitor.pathUpdateHandler = { [weak self] path in
3678
ITBInfo("networkMonitor.pathUpdateHandler, path: \(path.debugDescription), status: \(path.status)")
37-
self.statusUpdatedCallback?()
79+
self?.statusUpdatedCallback?()
3880
}
3981

4082
networkMonitor.start(queue: queue)
@@ -43,10 +85,16 @@ class NetworkMonitor: NetworkMonitorProtocol {
4385

4486
func stop() {
4587
ITBInfo()
88+
// `statusUpdatedCallback` intentionally persists across stop/start cycles:
89+
// callers (e.g. `NetworkConnectivityManager`) set it once and expect it to
90+
// survive, so they don't have to re-register after every stop. The
91+
// `pathUpdateHandler` weak-self capture means this is not a leak risk.
92+
networkMonitor?.pathUpdateHandler = nil
4693
networkMonitor?.cancel()
4794
networkMonitor = nil
4895
}
4996

50-
private weak var networkMonitor: NWPathMonitor?
97+
private var networkMonitor: NetworkPathMonitorProtocol?
98+
private let pathMonitorFactory: () -> NetworkPathMonitorProtocol
5199
private let queue = DispatchQueue(label: "NetworkMonitor")
52100
}

tests/offline-events-tests/TaskRunnerTests.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,34 @@ import XCTest
77
@testable import IterableSDK
88

99
class TaskRunnerTests: XCTestCase {
10+
private final class WeakBox<T: AnyObject> {
11+
weak var value: T?
12+
13+
init(_ value: T?) {
14+
self.value = value
15+
}
16+
}
17+
18+
private final class LifecycleNetworkMonitor: NetworkMonitorProtocol {
19+
var statusUpdatedCallback: (() -> Void)?
20+
private(set) var startCount = 0
21+
private(set) var stopCount = 0
22+
23+
func start() {
24+
startCount += 1
25+
}
26+
27+
func stop() {
28+
stopCount += 1
29+
}
30+
}
31+
32+
private final class ImmediatePersistenceContext: MockPersistenceContext {
33+
override func perform(_ block: @escaping () -> Void) {
34+
block()
35+
}
36+
}
37+
1038
override func setUpWithError() throws {
1139
try super.setUpWithError()
1240

@@ -271,6 +299,58 @@ class TaskRunnerTests: XCTestCase {
271299
verifyTaskIsExecuted(notificationCenter, withinInterval: 10.0)
272300
taskRunner.stop()
273301
}
302+
303+
func testForegroundBackgroundCyclingDoesNotLeakConnectivityLifecycle() throws {
304+
let weakMonitor = WeakBox<LifecycleNetworkMonitor>(nil)
305+
let weakManager = WeakBox<NetworkConnectivityManager>(nil)
306+
let weakTaskRunner = WeakBox<IterableTaskRunner>(nil)
307+
308+
autoreleasepool {
309+
let networkSession = MockNetworkSession()
310+
let checker = NetworkConnectivityChecker(networkSession: networkSession)
311+
let monitor = LifecycleNetworkMonitor()
312+
weakMonitor.value = monitor
313+
let notificationCenter = MockNotificationCenter()
314+
let persistenceContextProvider = MockPersistenceContextProvider(context: ImmediatePersistenceContext())
315+
let manager = NetworkConnectivityManager(networkMonitor: monitor,
316+
connectivityChecker: checker,
317+
notificationCenter: notificationCenter,
318+
offlineModePollingInterval: 600,
319+
onlineModePollingInterval: 600)
320+
weakManager.value = manager
321+
322+
let healthMonitor = HealthMonitor(dataProvider: HealthMonitorDataProvider(maxTasks: 1000,
323+
persistenceContextProvider: persistenceContextProvider),
324+
dateProvider: SystemDateProvider(),
325+
networkSession: networkSession)
326+
let taskRunner = IterableTaskRunner(networkSession: networkSession,
327+
persistenceContextProvider: persistenceContextProvider,
328+
healthMonitor: healthMonitor,
329+
notificationCenter: notificationCenter,
330+
timeInterval: 600,
331+
connectivityManager: manager)
332+
weakTaskRunner.value = taskRunner
333+
334+
taskRunner.start()
335+
XCTAssertEqual(monitor.startCount, 1)
336+
337+
for cycleIndex in 1...3 {
338+
notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil, userInfo: nil)
339+
XCTAssertEqual(monitor.stopCount, cycleIndex)
340+
341+
notificationCenter.post(name: UIApplication.willEnterForegroundNotification, object: nil, userInfo: nil)
342+
XCTAssertEqual(monitor.startCount, cycleIndex + 1)
343+
}
344+
345+
taskRunner.stop()
346+
XCTAssertEqual(monitor.stopCount, 4)
347+
XCTAssertNil(monitor.statusUpdatedCallback)
348+
}
349+
350+
XCTAssertNil(weakTaskRunner.value)
351+
XCTAssertNil(weakManager.value)
352+
XCTAssertNil(weakMonitor.value)
353+
}
274354

275355
func testSentAtInHeader() throws {
276356
let date = Date()
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//
2+
// Copyright © 2026 Iterable. All rights reserved.
3+
//
4+
5+
import XCTest
6+
7+
@testable import IterableSDK
8+
9+
class NetworkMonitorLifecycleTests: XCTestCase {
10+
func testNetworkMonitorDeallocatesAfterStartAndStop() {
11+
weak var weakMonitor: NetworkMonitor?
12+
weak var weakPathMonitor: SpyPathMonitor?
13+
14+
autoreleasepool {
15+
var monitor: NetworkMonitor? = NetworkMonitor(pathMonitorFactory: {
16+
let pathMonitor = SpyPathMonitor()
17+
weakPathMonitor = pathMonitor
18+
return pathMonitor
19+
})
20+
21+
weakMonitor = monitor
22+
monitor?.start()
23+
XCTAssertNotNil(weakPathMonitor)
24+
25+
monitor?.stop()
26+
monitor = nil
27+
}
28+
29+
XCTAssertNil(weakMonitor)
30+
XCTAssertNil(weakPathMonitor)
31+
}
32+
33+
func testStartTwiceReleasesFirstPathMonitor() {
34+
var createdPathMonitorCount = 0
35+
weak var firstPathMonitor: SpyPathMonitor?
36+
weak var secondPathMonitor: SpyPathMonitor?
37+
38+
let monitor = NetworkMonitor(pathMonitorFactory: {
39+
let pathMonitor = SpyPathMonitor()
40+
createdPathMonitorCount += 1
41+
42+
if createdPathMonitorCount == 1 {
43+
firstPathMonitor = pathMonitor
44+
} else if createdPathMonitorCount == 2 {
45+
secondPathMonitor = pathMonitor
46+
}
47+
48+
return pathMonitor
49+
})
50+
51+
monitor.start()
52+
XCTAssertNotNil(firstPathMonitor)
53+
54+
monitor.start()
55+
XCTAssertEqual(createdPathMonitorCount, 2)
56+
XCTAssertNil(firstPathMonitor)
57+
XCTAssertNotNil(secondPathMonitor)
58+
59+
monitor.stop()
60+
XCTAssertNil(secondPathMonitor)
61+
}
62+
63+
func testPathUpdateHandlerDoesNotInvokeStatusUpdatedCallbackAfterStop() {
64+
let pathMonitor = SpyPathMonitor()
65+
let monitor = NetworkMonitor(pathMonitorFactory: { pathMonitor })
66+
var statusUpdatedCallbackCount = 0
67+
monitor.statusUpdatedCallback = {
68+
statusUpdatedCallbackCount += 1
69+
}
70+
71+
monitor.start()
72+
pathMonitor.sendPathUpdate()
73+
XCTAssertEqual(statusUpdatedCallbackCount, 1)
74+
75+
monitor.stop()
76+
XCTAssertNil(pathMonitor.pathUpdateHandler)
77+
78+
pathMonitor.sendPathUpdate()
79+
XCTAssertEqual(statusUpdatedCallbackCount, 1)
80+
XCTAssertEqual(pathMonitor.cancelCount, 1)
81+
}
82+
83+
private final class SpyPathMonitor: NetworkPathMonitorProtocol {
84+
var pathUpdateHandler: ((NetworkPathUpdate) -> Void)?
85+
private(set) var cancelCount = 0
86+
87+
func start(queue _: DispatchQueue) {}
88+
89+
func cancel() {
90+
cancelCount += 1
91+
}
92+
93+
func sendPathUpdate() {
94+
pathUpdateHandler?(NetworkPathUpdate(debugDescription: "spy path",
95+
status: "satisfied"))
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)