Skip to content

Commit e2cf73a

Browse files
committed
Merge branch 'do-settings-migration-from-the-packet-tunnel-ios-573'
2 parents b54a6c3 + 1d98737 commit e2cf73a

File tree

8 files changed

+151
-23
lines changed

8 files changed

+151
-23
lines changed

Diff for: ios/MullvadREST/RetryStrategy/RetryStrategy.swift

+10
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ extension REST {
7878
),
7979
applyJitter: true
8080
)
81+
82+
public static var failedMigrationRecovery = RetryStrategy(
83+
maxRetryCount: .max,
84+
delay: .exponentialBackoff(
85+
initial: .seconds(5),
86+
multiplier: UInt64(1),
87+
maxDelay: .minutes(1)
88+
),
89+
applyJitter: true
90+
)
8191
}
8292

8393
public enum RetryDelay: Equatable {

Diff for: ios/MullvadSettings/MigrationManager.swift

+33-17
Original file line numberDiff line numberDiff line change
@@ -23,38 +23,54 @@ public enum SettingsMigrationResult {
2323

2424
public struct MigrationManager {
2525
private let logger = Logger(label: "MigrationManager")
26+
private let cacheDirectory: URL
2627

27-
public init() {}
28+
public init(cacheDirectory: URL) {
29+
self.cacheDirectory = cacheDirectory.appendingPathComponent("migrationState.json")
30+
}
2831

2932
/// Migrate settings store if needed.
3033
///
3134
/// Reads the current settings, upgrades them to the latest version if needed
3235
/// and writes back to `store` when settings are updated.
36+
///
37+
/// In order to avoid migration happening from both the VPN and the host processes at the same time,
38+
/// a non existant file path is used as a lock to synchronize access between the processes.
39+
/// This file is accessed by `NSFileCoordinator` in order to prevent multiple processes accessing at the same time.
3340
/// - Parameters:
3441
/// - store: The store to from which settings are read and written to.
35-
/// - proxyFactory: Factory used for migrations that involve API calls.
3642
/// - migrationCompleted: Completion handler called with a migration result.
3743
public func migrateSettings(
3844
store: SettingsStore,
3945
migrationCompleted: @escaping (SettingsMigrationResult) -> Void
4046
) {
41-
let resetStoreHandler = { (result: SettingsMigrationResult) in
42-
// Reset store upon failure to migrate settings.
43-
if case .failure = result {
44-
SettingsManager.resetStore()
47+
let fileCoordinator = NSFileCoordinator(filePresenter: nil)
48+
var error: NSError?
49+
50+
// This will block the calling thread if another process is currently running the same code.
51+
// This is intentional to avoid TOCTOU issues, and guaranteeing settings cannot be read
52+
// in a half written state.
53+
// The resulting effect is that only one process at a time can do settings migrations.
54+
// The other process will be blocked, and will have nothing to do as long as settings were successfully upgraded.
55+
fileCoordinator.coordinate(writingItemAt: cacheDirectory, error: &error) { _ in
56+
let resetStoreHandler = { (result: SettingsMigrationResult) in
57+
// Reset store upon failure to migrate settings.
58+
if case .failure = result {
59+
SettingsManager.resetStore()
60+
}
61+
migrationCompleted(result)
4562
}
46-
migrationCompleted(result)
47-
}
4863

49-
do {
50-
try upgradeSettingsToLatestVersion(
51-
store: store,
52-
migrationCompleted: migrationCompleted
53-
)
54-
} catch .itemNotFound as KeychainError {
55-
migrationCompleted(.nothing)
56-
} catch {
57-
resetStoreHandler(.failure(error))
64+
do {
65+
try upgradeSettingsToLatestVersion(
66+
store: store,
67+
migrationCompleted: migrationCompleted
68+
)
69+
} catch .itemNotFound as KeychainError {
70+
migrationCompleted(.nothing)
71+
} catch {
72+
resetStoreHandler(.failure(error))
73+
}
5874
}
5975
}
6076

Diff for: ios/MullvadVPN.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,7 @@
696696
A932D9F32B5EB61100999395 /* HeadRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A932D9F22B5EB61100999395 /* HeadRequestTests.swift */; };
697697
A932D9F52B5EBB9D00999395 /* RESTTransportStub.swift in Sources */ = {isa = PBXBuildFile; fileRef = A932D9F42B5EBB9D00999395 /* RESTTransportStub.swift */; };
698698
A935594C2B4C2DA900D5D524 /* APIAvailabilityTestRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A935594B2B4C2DA900D5D524 /* APIAvailabilityTestRequest.swift */; };
699+
A939661B2CAE6CE1008128CA /* MigrationManagerMultiProcessUpgradeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A939661A2CAE6CE1008128CA /* MigrationManagerMultiProcessUpgradeTests.swift */; };
699700
A94D691A2ABAD66700413DD4 /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58FE25E22AA72AE9003D1918 /* WireGuardKitTypes */; };
700701
A94D691B2ABAD66700413DD4 /* WireGuardKitTypes in Frameworks */ = {isa = PBXBuildFile; productRef = 58FE25E72AA7399D003D1918 /* WireGuardKitTypes */; };
701702
A95EEE362B722CD600A8A39B /* TunnelMonitorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95EEE352B722CD600A8A39B /* TunnelMonitorState.swift */; };
@@ -2020,6 +2021,7 @@
20202021
A932D9F42B5EBB9D00999395 /* RESTTransportStub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTTransportStub.swift; sourceTree = "<group>"; };
20212022
A935594B2B4C2DA900D5D524 /* APIAvailabilityTestRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAvailabilityTestRequest.swift; sourceTree = "<group>"; };
20222023
A935594D2B4E919F00D5D524 /* Api.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Api.xcconfig; sourceTree = "<group>"; };
2024+
A939661A2CAE6CE1008128CA /* MigrationManagerMultiProcessUpgradeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManagerMultiProcessUpgradeTests.swift; sourceTree = "<group>"; };
20232025
A944F2712B8E02E800473F4C /* libmullvad_ios.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libmullvad_ios.a; path = "../target/aarch64-apple-ios/debug/libmullvad_ios.a"; sourceTree = "<group>"; };
20242026
A9467E7E2A29DEFE000DC21F /* RelayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCacheTests.swift; sourceTree = "<group>"; };
20252027
A948809A2BC9308D0090A44C /* EphemeralPeerExchangeActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EphemeralPeerExchangeActor.swift; sourceTree = "<group>"; };
@@ -2574,6 +2576,7 @@
25742576
7A5869C22B5820CE00640D27 /* IPOverrideRepositoryTests.swift */,
25752577
7AB4CCB82B69097E006037F5 /* IPOverrideTests.swift */,
25762578
7A516C3B2B712F0B00BBD33D /* IPOverrideWrapperTests.swift */,
2579+
A939661A2CAE6CE1008128CA /* MigrationManagerMultiProcessUpgradeTests.swift */,
25772580
A9B6AC172ADE8F4300F7802A /* MigrationManagerTests.swift */,
25782581
F072D3CE2C07122400906F64 /* SettingsUpdaterTests.swift */,
25792582
449872E32B7CB96300094DDC /* TunnelSettingsUpdateTests.swift */,
@@ -5377,6 +5380,7 @@
53775380
7A9BE5A52B90760C00E2A7D0 /* CustomListsDataSourceTests.swift in Sources */,
53785381
A9A5FA1B2ACB05160083449F /* Tunnel.swift in Sources */,
53795382
A9A5FA1C2ACB05160083449F /* Tunnel+Messaging.swift in Sources */,
5383+
A939661B2CAE6CE1008128CA /* MigrationManagerMultiProcessUpgradeTests.swift in Sources */,
53805384
7A9BE5A92B90806800E2A7D0 /* CustomListsRepositoryStub.swift in Sources */,
53815385
F09D04BB2AE95396003D4F89 /* URLSessionStub.swift in Sources */,
53825386
A9A5FA1D2ACB05160083449F /* TunnelBlockObserver.swift in Sources */,

Diff for: ios/MullvadVPN/AppDelegate.swift

+5-3
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
4242
private(set) var storePaymentManager: StorePaymentManager!
4343
private var transportMonitor: TransportMonitor!
4444
private var settingsObserver: TunnelBlockObserver!
45-
private let migrationManager = MigrationManager()
45+
private var migrationManager: MigrationManager!
4646

4747
private(set) var accessMethodRepository = AccessMethodRepository()
4848
private(set) var shadowsocksLoader: ShadowsocksLoaderProtocol!
@@ -67,7 +67,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
6767
}
6868

6969
let containerURL = ApplicationConfiguration.containerURL
70-
70+
migrationManager = MigrationManager(cacheDirectory: containerURL)
7171
configureLogging()
7272

7373
addressCache = REST.AddressCache(canWriteToCache: true, cacheDirectory: containerURL)
@@ -478,14 +478,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
478478
switch migrationResult {
479479
case .success:
480480
// Tell the tunnel to re-read tunnel configuration after migration.
481-
logger.debug("Reconnect the tunnel after settings migration.")
481+
logger.debug("Successful migration from UI Process")
482482
tunnelManager.reconnectTunnel(selectNewRelay: true)
483483
fallthrough
484484

485485
case .nothing:
486+
logger.debug("Attempted migration from UI Process, but found nothing to do")
486487
finish(nil)
487488

488489
case let .failure(error):
490+
logger.error("Failed migration from UI Process: \(error)")
489491
let migrationUIHandler = application.connectedScenes
490492
.first { $0 is SettingsMigrationUIHandler } as? SettingsMigrationUIHandler
491493

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//
2+
// MigrationManagerMultiProcessUpgradeTests.swift
3+
// MullvadVPNTests
4+
//
5+
// Created by Marco Nikic on 2024-10-03.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
@testable import MullvadMockData
10+
@testable import MullvadREST
11+
@testable import MullvadSettings
12+
@testable import MullvadTypes
13+
import XCTest
14+
15+
extension MigrationManagerTests {
16+
func testMigrationDoesNothingIfAnotherProcessIsRunningUpdates() throws {
17+
let hostProcess = DispatchQueue(label: "net.tests.HostMigration")
18+
let packetTunnelProcess = DispatchQueue(label: "net.tests.PacketTunnelMigration")
19+
let osakaRelayConstraints = RelayConstraints(
20+
exitLocations: .only(UserSelectedRelays(locations: [.city("jp", "osa")]))
21+
)
22+
var settingsV1 = TunnelSettingsV1()
23+
settingsV1.relayConstraints = osakaRelayConstraints
24+
25+
try write(settings: settingsV1, version: SchemaVersion.v1.rawValue, in: Self.store)
26+
27+
let backgroundMigrationExpectation = expectation(description: "Migration from packet tunnel")
28+
let foregroundMigrationExpectation = expectation(description: "Migration from host")
29+
var migrationHappenedInPacketTunnel = false
30+
var migrationHappenedInHost = false
31+
32+
packetTunnelProcess.async { [unowned self] in
33+
manager.migrateSettings(store: MigrationManagerTests.store) { backgroundMigrationResult in
34+
if case .success = backgroundMigrationResult {
35+
migrationHappenedInPacketTunnel = true
36+
}
37+
backgroundMigrationExpectation.fulfill()
38+
}
39+
}
40+
41+
hostProcess.async { [unowned self] in
42+
manager.migrateSettings(store: MigrationManagerTests.store) { foregroundMigrationResult in
43+
if case .success = foregroundMigrationResult {
44+
migrationHappenedInHost = true
45+
}
46+
foregroundMigrationExpectation.fulfill()
47+
}
48+
}
49+
50+
wait(for: [backgroundMigrationExpectation, foregroundMigrationExpectation], timeout: .UnitTest.invertedTimeout)
51+
52+
// Migration happens either in one process, or the other.
53+
// This check guarantees it didn't happen in both simultaneously.
54+
XCTAssertNotEqual(migrationHappenedInPacketTunnel, migrationHappenedInHost)
55+
let latestSettings = try SettingsManager.readSettings()
56+
XCTAssertEqual(osakaRelayConstraints, latestSettings.relayConstraints)
57+
}
58+
}

Diff for: ios/MullvadVPNTests/MullvadSettings/MigrationManagerTests.swift

+10-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ final class MigrationManagerTests: XCTestCase {
1616
static let store = InMemorySettingsStore<SettingNotFound>()
1717

1818
var manager: MigrationManager!
19+
var testFileURL: URL!
1920
override static func setUp() {
2021
SettingsManager.unitTestStore = store
2122
}
@@ -25,7 +26,14 @@ final class MigrationManagerTests: XCTestCase {
2526
}
2627

2728
override func setUpWithError() throws {
28-
manager = MigrationManager()
29+
testFileURL = FileManager.default.temporaryDirectory
30+
.appendingPathComponent("MigrationManagerTests-\(UUID().uuidString)", isDirectory: true)
31+
try FileManager.default.createDirectory(at: testFileURL, withIntermediateDirectories: true)
32+
manager = MigrationManager(cacheDirectory: testFileURL)
33+
}
34+
35+
override func tearDownWithError() throws {
36+
try FileManager.default.removeItem(at: testFileURL)
2937
}
3038

3139
func testNothingToMigrate() throws {
@@ -224,7 +232,7 @@ final class MigrationManagerTests: XCTestCase {
224232
wait(for: [successfulMigrationExpectation], timeout: .UnitTest.timeout)
225233
}
226234

227-
private func write(settings: any TunnelSettings, version: Int, in store: SettingsStore) throws {
235+
func write(settings: any TunnelSettings, version: Int, in store: SettingsStore) throws {
228236
let parser = SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder())
229237
let payload = try parser.producePayload(settings, version: version)
230238
try store.write(payload, for: .settings)

Diff for: ios/MullvadVPNTests/MullvadTypes/FileCacheTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class FileCacheTests: XCTestCase {
1515

1616
override func setUp() {
1717
testFileURL = FileManager.default.temporaryDirectory
18-
.appendingPathComponent("FileCacheTest-\(UUID().uuidString)", isDirectory: true)
18+
.appendingPathComponent("FileCacheTest-\(UUID().uuidString)", isDirectory: false)
1919
}
2020

2121
override func tearDown() {

Diff for: ios/PacketTunnel/PacketTunnelProvider/PacketTunnelProvider.swift

+30
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
2929
private var ephemeralPeerExchangingPipeline: EphemeralPeerExchangingPipeline!
3030
private let tunnelSettingsUpdater: SettingsUpdater!
3131
private var encryptedDNSTransport: EncryptedDNSTransport!
32+
private var migrationManager: MigrationManager!
33+
let migrationFailureIterator = REST.RetryStrategy.failedMigrationRecovery.makeDelayIterator()
3234

3335
private let tunnelSettingsListener = TunnelSettingsListener()
3436
private lazy var ephemeralPeerReceiver = {
@@ -49,9 +51,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
4951
ipOverrideRepository: IPOverrideRepository()
5052
)
5153
tunnelSettingsUpdater = SettingsUpdater(listener: tunnelSettingsListener)
54+
migrationManager = MigrationManager(cacheDirectory: containerURL)
5255

5356
super.init()
5457

58+
performSettingsMigration()
59+
5560
let transportProvider = setUpTransportProvider(
5661
appContainerURL: containerURL,
5762
ipOverrideWrapper: ipOverrideWrapper,
@@ -168,6 +173,31 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
168173
actor.onWake()
169174
}
170175

176+
private func performSettingsMigration() {
177+
migrationManager.migrateSettings(
178+
store: SettingsManager.store,
179+
migrationCompleted: { [unowned self] migrationResult in
180+
switch migrationResult {
181+
case .success:
182+
providerLogger.debug("Successful migration from PacketTunnel")
183+
case .nothing:
184+
providerLogger.debug("Attempted migration from PacketTunnel, but found nothing to do")
185+
case let .failure(error):
186+
// `next` returns an Optional value, but this iterator is guaranteed to always have a next value
187+
guard let delay = migrationFailureIterator.next() else { return }
188+
providerLogger
189+
.error(
190+
"Failed migration from PacketTunnel: \(error), retrying in \(delay.timeInterval) seconds"
191+
)
192+
// Block the launch of the Packet Tunnel for as long as the settings migration fail.
193+
// The process watchdog introduced by iOS 17 will kill this process after 60 seconds.
194+
Thread.sleep(forTimeInterval: delay.timeInterval)
195+
performSettingsMigration()
196+
}
197+
}
198+
)
199+
}
200+
171201
private func setUpTransportProvider(
172202
appContainerURL: URL,
173203
ipOverrideWrapper: IPOverrideWrapper,

0 commit comments

Comments
 (0)