Skip to content

Commit f33afbd

Browse files
chore: handle waking from device sleep (#50)
Depends on coder/coder#16598. Reverts #43. Whilst everything seems to recover okay tailnet wise when waking from sleep currently, the tunnel will still miss workspace/peer updates during the sleep, causing the workspace state in the UI to be out of sync with reality. To handle this, we'll teardown the tunnel on sleep, and bring it back up on wake. Fixing the issue in `coder/coder` also revealed that the error encountered when toggling the VPN on and off quickly was another symptom, and so this change reverts the code that prevents toggling the VPN on and off quickly, as it now works flawlessly.
1 parent fe45e1c commit f33afbd

File tree

7 files changed

+41
-32
lines changed

7 files changed

+41
-32
lines changed

Coder Desktop/Coder Desktop/VPNMenuState.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ struct VPNMenuState {
104104

105105
mutating func upsertWorkspace(_ workspace: Vpn_Workspace) {
106106
guard let wsID = UUID(uuidData: workspace.id) else { return }
107-
workspaces[wsID] = Workspace(id: wsID, name: workspace.name, agents: [])
107+
// Workspace names are unique & case-insensitive, and we want to show offline workspaces
108+
// with a valid hostname (lowercase).
109+
workspaces[wsID] = Workspace(id: wsID, name: workspace.name.lowercased(), agents: [])
108110
// Check if we can associate any invalid agents with this workspace
109111
invalidAgents.filter { agent in
110112
agent.workspaceID == workspace.id

Coder Desktop/Coder Desktop/VPNService.swift

+1
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ final class CoderVPNService: NSObject, VPNService {
9090
return
9191
}
9292

93+
menuState.clear()
9394
await startTunnel()
9495
logger.debug("network extension enabled")
9596
}

Coder Desktop/Coder Desktop/Views/VPNMenu.swift

+2-19
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,6 @@ struct VPNMenu<VPN: VPNService>: View {
55
@EnvironmentObject var state: AppState
66
@Environment(\.openSettings) private var openSettings
77

8-
// There appears to be a race between the VPN service reporting itself as disconnected,
9-
// and the system extension process exiting. When the VPN is toggled off and on quickly,
10-
// an error is shown: "The VPN session failed because an internal error occurred".
11-
// This forces the user to wait a few seconds before they can toggle the VPN back on.
12-
@State private var waitCleanup = false
13-
private var waitCleanupDuration: Duration = .seconds(6)
14-
158
let inspection = Inspection<Self>()
169

1710
var body: some View {
@@ -23,7 +16,7 @@ struct VPNMenu<VPN: VPNService>: View {
2316
Toggle(isOn: Binding(
2417
get: { vpn.state == .connected || vpn.state == .connecting },
2518
set: { isOn in Task {
26-
if isOn { await vpn.start() } else { await stop() }
19+
if isOn { await vpn.start() } else { await vpn.stop() }
2720
}
2821
}
2922
)) {
@@ -93,21 +86,11 @@ struct VPNMenu<VPN: VPNService>: View {
9386
}
9487

9588
private var vpnDisabled: Bool {
96-
waitCleanup ||
97-
!state.hasSession ||
89+
!state.hasSession ||
9890
vpn.state == .connecting ||
9991
vpn.state == .disconnecting ||
10092
vpn.state == .failed(.systemExtensionError(.needsUserApproval))
10193
}
102-
103-
private func stop() async {
104-
await vpn.stop()
105-
waitCleanup = true
106-
Task {
107-
try? await Task.sleep(for: waitCleanupDuration)
108-
waitCleanup = false
109-
}
110-
}
11194
}
11295

11396
func openSystemExtensionSettings() {

Coder Desktop/VPN/Manager.swift

+4-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ actor Manager {
2727
fatalError("unknown architecture")
2828
#endif
2929
do {
30-
try await download(src: dylibPath, dest: dest)
30+
let sessionConfig = URLSessionConfiguration.default
31+
// The tunnel might be asked to start before the network interfaces have woken up from sleep
32+
sessionConfig.waitsForConnectivity = true
33+
try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig))
3134
} catch {
3235
throw .download(error)
3336
}

Coder Desktop/VPN/PacketTunnelProvider.swift

+24-4
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
4848
options _: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void
4949
) {
5050
logger.info("startTunnel called")
51+
start(completionHandler)
52+
}
53+
54+
// called by `startTunnel` and on `wake`
55+
func start(_ completionHandler: @escaping (Error?) -> Void) {
5156
guard manager == nil else {
5257
logger.error("startTunnel called with non-nil Manager")
5358
completionHandler(makeNSError(suffix: "PTP", desc: "Already running"))
@@ -99,8 +104,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
99104
with _: NEProviderStopReason, completionHandler: @escaping () -> Void
100105
) {
101106
logger.debug("stopTunnel called")
107+
teardown(completionHandler)
108+
}
109+
110+
// called by `stopTunnel` and `sleep`
111+
func teardown(_ completionHandler: @escaping () -> Void) {
102112
guard let manager else {
103-
logger.error("stopTunnel called with nil Manager")
113+
logger.error("teardown called with nil Manager")
104114
completionHandler()
105115
return
106116
}
@@ -125,15 +135,25 @@ class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
125135
}
126136
}
127137

138+
// sleep and wake reference: https://developer.apple.com/forums/thread/95988
128139
override func sleep(completionHandler: @escaping () -> Void) {
129-
// Add code here to get ready to sleep.
130140
logger.debug("sleep called")
131-
completionHandler()
141+
teardown(completionHandler)
132142
}
133143

134144
override func wake() {
135-
// Add code here to wake up.
136145
logger.debug("wake called")
146+
reasserting = true
147+
currentSettings = .init(tunnelRemoteAddress: "127.0.0.1")
148+
setTunnelNetworkSettings(nil)
149+
start { error in
150+
if let error {
151+
self.logger.error("error starting tunnel after wake: \(error.localizedDescription)")
152+
self.cancelTunnelWithError(error)
153+
} else {
154+
self.reasserting = false
155+
}
156+
}
137157
}
138158

139159
// Wrapper around `setTunnelNetworkSettings` that supports merging updates

Coder Desktop/VPNLib/Download.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ public class SignatureValidator {
101101
}
102102
}
103103

104-
public func download(src: URL, dest: URL) async throws(DownloadError) {
104+
public func download(src: URL, dest: URL, urlSession: URLSession) async throws(DownloadError) {
105105
var req = URLRequest(url: src)
106106
if FileManager.default.fileExists(atPath: dest.path) {
107107
if let existingFileData = try? Data(contentsOf: dest, options: .mappedIfSafe) {
@@ -112,7 +112,7 @@ public func download(src: URL, dest: URL) async throws(DownloadError) {
112112
let tempURL: URL
113113
let response: URLResponse
114114
do {
115-
(tempURL, response) = try await URLSession.shared.download(for: req)
115+
(tempURL, response) = try await urlSession.download(for: req)
116116
} catch {
117117
throw .networkError(error)
118118
}

Coder Desktop/VPNLibTests/DownloadTests.swift

+5-5
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ struct DownloadTests {
1313
let fileURL = URL(string: "http://example.com/test1.txt")!
1414
Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: testData]).register()
1515

16-
try await download(src: fileURL, dest: destinationURL)
16+
try await download(src: fileURL, dest: destinationURL, urlSession: URLSession.shared)
1717

1818
try #require(FileManager.default.fileExists(atPath: destinationURL.path))
1919
defer { try? FileManager.default.removeItem(at: destinationURL) }
@@ -32,7 +32,7 @@ struct DownloadTests {
3232

3333
Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: testData]).register()
3434

35-
try await download(src: fileURL, dest: destinationURL)
35+
try await download(src: fileURL, dest: destinationURL, urlSession: URLSession.shared)
3636
try #require(FileManager.default.fileExists(atPath: destinationURL.path))
3737
let downloadedData = try Data(contentsOf: destinationURL)
3838
#expect(downloadedData == testData)
@@ -44,7 +44,7 @@ struct DownloadTests {
4444
}
4545
mock.register()
4646

47-
try await download(src: fileURL, dest: destinationURL)
47+
try await download(src: fileURL, dest: destinationURL, urlSession: URLSession.shared)
4848
let unchangedData = try Data(contentsOf: destinationURL)
4949
#expect(unchangedData == testData)
5050
#expect(etagIncluded)
@@ -61,7 +61,7 @@ struct DownloadTests {
6161

6262
Mock(url: fileURL, contentType: .html, statusCode: 200, data: [.get: ogData]).register()
6363

64-
try await download(src: fileURL, dest: destinationURL)
64+
try await download(src: fileURL, dest: destinationURL, urlSession: URLSession.shared)
6565
try #require(FileManager.default.fileExists(atPath: destinationURL.path))
6666
var downloadedData = try Data(contentsOf: destinationURL)
6767
#expect(downloadedData == ogData)
@@ -73,7 +73,7 @@ struct DownloadTests {
7373
}
7474
mock.register()
7575

76-
try await download(src: fileURL, dest: destinationURL)
76+
try await download(src: fileURL, dest: destinationURL, urlSession: URLSession.shared)
7777
downloadedData = try Data(contentsOf: destinationURL)
7878
#expect(downloadedData == newData)
7979
#expect(etagIncluded)

0 commit comments

Comments
 (0)