Skip to content

Commit 6f6049e

Browse files
chore: manage mutagen daemon lifecycle (#98)
Closes coder/internal#381. - Moves the VPN-specific app files into a `VPN` folder. - Adds an empty `Resources` folder whose contents are copied into the bundle at build time. - Adds a `MutagenDaemon` abstraction for managing the mutagen daemon lifecycle, this class: - Starts the mutagen daemon using `mutagen daemon run`, with a `MUTAGEN_DATA_DIRECTORY` in `Application Support/Coder Desktop/Mutagen`, to avoid collisions with a system mutagen using `~/.mutagen`. - Maintains a `gRPC` connection to the daemon socket. - Stops the mutagen daemon over `gRPC` - Relays stdout & stderr from the daemon, and watches if the process exits unexpectedly. - Handles replacing an orphaned `mutagen daemon run` process if one exists. This PR does not embed the mutagen binaries within the bundle, it just handles the case where they're present. ## Why is the file sync code in VPNLib? When I had the FileSync code (namely protobuf definitions) in either: - The app target - A new `FSLib` framework target Either the network extension crashed (in the first case) or the app crashed (in the second case) on launch. The crash was super obtuse: ``` Library not loaded: @rpath/SwiftProtobuf.framework/Versions/A/SwiftProtobuf ``` especially considering `SwiftProtobuf` doesn't have a stable ABI and shouldn't be compiled as a framework. At least one other person has ran into this issue when importing `SwiftProtobuf` multiple times: apple/swift-protobuf#1506 (comment) Curiously, this also wasn't happening on local development builds (building and running via the XCode GUI), only when exporting via our build script. ### Solution We're just going to overload `VPNLib` as the source of all our SwiftProtobuf & GRPC code. Since it's pretty big, and we don't want to embed it twice, we'll embed it once within the System Extension, and then have the app look for it in that bundle, see `LD_RUNPATH_SEARCH_PATHS`. It's not exactly ideal, but I don't think it's worth going to war with XCode over. #### TODO - [x] Replace the `Process` with https://github.com/jamf/Subprocess
1 parent 2094e9f commit 6f6049e

17 files changed

+746
-9
lines changed

Diff for: .swiftlint.yml

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# TODO: Remove this once the grpc-swift-protobuf generator adds a lint disable comment
2+
excluded:
3+
- "**/*.pb.swift"
4+
- "**/*.grpc.swift"

Diff for: Coder Desktop/.swiftformat

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
--selfrequired log,info,error,debug,critical,fault
2-
--exclude **.pb.swift
2+
--exclude **.pb.swift,**.grpc.swift
33
--condassignment always

Diff for: Coder Desktop/Coder Desktop/Coder_DesktopApp.swift

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import FluidMenuBarExtra
22
import NetworkExtension
33
import SwiftUI
4+
import VPNLib
45

56
@main
67
struct DesktopApp: App {
@@ -30,10 +31,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
3031
private var menuBar: MenuBarController?
3132
let vpn: CoderVPNService
3233
let state: AppState
34+
let fileSyncDaemon: MutagenDaemon
3335

3436
override init() {
3537
vpn = CoderVPNService()
3638
state = AppState(onChange: vpn.configureTunnelProviderProtocol)
39+
fileSyncDaemon = MutagenDaemon()
3740
}
3841

3942
func applicationDidFinishLaunching(_: Notification) {
@@ -56,14 +59,23 @@ class AppDelegate: NSObject, NSApplicationDelegate {
5659
state.reconfigure()
5760
}
5861
}
62+
// TODO: Start the daemon only once a file sync is configured
63+
Task {
64+
await fileSyncDaemon.start()
65+
}
5966
}
6067

6168
// This function MUST eventually call `NSApp.reply(toApplicationShouldTerminate: true)`
6269
// or return `.terminateNow`
6370
func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply {
64-
if !state.stopVPNOnQuit { return .terminateNow }
6571
Task {
66-
await vpn.stop()
72+
async let vpnTask: Void = {
73+
if await self.state.stopVPNOnQuit {
74+
await self.vpn.stop()
75+
}
76+
}()
77+
async let fileSyncTask: Void = self.fileSyncDaemon.stop()
78+
_ = await (vpnTask, fileSyncTask)
6779
NSApp.reply(toApplicationShouldTerminate: true)
6880
}
6981
return .terminateLater

Diff for: Coder Desktop/Coder Desktop/State.swift

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import KeychainAccess
44
import NetworkExtension
55
import SwiftUI
66

7+
@MainActor
78
class AppState: ObservableObject {
89
let appId = Bundle.main.bundleIdentifier!
910

Diff for: Coder Desktop/Resources/.gitkeep

Whitespace-only changes.

Diff for: Coder Desktop/VPNLib/FileSync/FileSyncDaemon.swift

+225
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import Foundation
2+
import GRPC
3+
import NIO
4+
import os
5+
import Subprocess
6+
7+
@MainActor
8+
public protocol FileSyncDaemon: ObservableObject {
9+
var state: DaemonState { get }
10+
func start() async
11+
func stop() async
12+
}
13+
14+
@MainActor
15+
public class MutagenDaemon: FileSyncDaemon {
16+
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "mutagen")
17+
18+
@Published public var state: DaemonState = .stopped {
19+
didSet {
20+
logger.info("daemon state changed: \(self.state.description, privacy: .public)")
21+
}
22+
}
23+
24+
private var mutagenProcess: Subprocess?
25+
private let mutagenPath: URL!
26+
private let mutagenDataDirectory: URL
27+
private let mutagenDaemonSocket: URL
28+
29+
private var group: MultiThreadedEventLoopGroup?
30+
private var channel: GRPCChannel?
31+
private var client: Daemon_DaemonAsyncClient?
32+
33+
public init() {
34+
#if arch(arm64)
35+
mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-arm64", withExtension: nil)
36+
#elseif arch(x86_64)
37+
mutagenPath = Bundle.main.url(forResource: "mutagen-darwin-amd64", withExtension: nil)
38+
#else
39+
fatalError("unknown architecture")
40+
#endif
41+
mutagenDataDirectory = FileManager.default.urls(
42+
for: .applicationSupportDirectory,
43+
in: .userDomainMask
44+
).first!.appending(path: "Coder Desktop").appending(path: "Mutagen")
45+
mutagenDaemonSocket = mutagenDataDirectory.appending(path: "daemon").appending(path: "daemon.sock")
46+
// It shouldn't be fatal if the app was built without Mutagen embedded,
47+
// but file sync will be unavailable.
48+
if mutagenPath == nil {
49+
logger.warning("Mutagen not embedded in app, file sync will be unavailable")
50+
state = .unavailable
51+
}
52+
}
53+
54+
public func start() async {
55+
if case .unavailable = state { return }
56+
57+
// Stop an orphaned daemon, if there is one
58+
try? await connect()
59+
await stop()
60+
61+
mutagenProcess = createMutagenProcess()
62+
// swiftlint:disable:next large_tuple
63+
let (standardOutput, standardError, waitForExit): (Pipe.AsyncBytes, Pipe.AsyncBytes, @Sendable () async -> Void)
64+
do {
65+
(standardOutput, standardError, waitForExit) = try mutagenProcess!.run()
66+
} catch {
67+
state = .failed(DaemonError.daemonStartFailure(error))
68+
return
69+
}
70+
71+
Task {
72+
await streamHandler(io: standardOutput)
73+
logger.info("standard output stream closed")
74+
}
75+
76+
Task {
77+
await streamHandler(io: standardError)
78+
logger.info("standard error stream closed")
79+
}
80+
81+
Task {
82+
await terminationHandler(waitForExit: waitForExit)
83+
}
84+
85+
do {
86+
try await connect()
87+
} catch {
88+
state = .failed(DaemonError.daemonStartFailure(error))
89+
return
90+
}
91+
92+
state = .running
93+
logger.info(
94+
"""
95+
mutagen daemon started, pid:
96+
\(self.mutagenProcess?.pid.description ?? "unknown", privacy: .public)
97+
"""
98+
)
99+
}
100+
101+
private func connect() async throws(DaemonError) {
102+
guard client == nil else {
103+
// Already connected
104+
return
105+
}
106+
group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
107+
do {
108+
channel = try GRPCChannelPool.with(
109+
target: .unixDomainSocket(mutagenDaemonSocket.path),
110+
transportSecurity: .plaintext,
111+
eventLoopGroup: group!
112+
)
113+
client = Daemon_DaemonAsyncClient(channel: channel!)
114+
logger.info(
115+
"Successfully connected to mutagen daemon, socket: \(self.mutagenDaemonSocket.path, privacy: .public)"
116+
)
117+
} catch {
118+
logger.error("Failed to connect to gRPC: \(error)")
119+
try? await cleanupGRPC()
120+
throw DaemonError.connectionFailure(error)
121+
}
122+
}
123+
124+
private func cleanupGRPC() async throws {
125+
try? await channel?.close().get()
126+
try? await group?.shutdownGracefully()
127+
128+
client = nil
129+
channel = nil
130+
group = nil
131+
}
132+
133+
public func stop() async {
134+
if case .unavailable = state { return }
135+
state = .stopped
136+
guard FileManager.default.fileExists(atPath: mutagenDaemonSocket.path) else {
137+
// Already stopped
138+
return
139+
}
140+
141+
// "We don't check the response or error, because the daemon
142+
// may terminate before it has a chance to send the response."
143+
_ = try? await client?.terminate(
144+
Daemon_TerminateRequest(),
145+
callOptions: .init(timeLimit: .timeout(.milliseconds(500)))
146+
)
147+
148+
try? await cleanupGRPC()
149+
150+
mutagenProcess?.kill()
151+
mutagenProcess = nil
152+
logger.info("Daemon stopped and gRPC connection closed")
153+
}
154+
155+
private func createMutagenProcess() -> Subprocess {
156+
let process = Subprocess([mutagenPath.path, "daemon", "run"])
157+
process.environment = [
158+
"MUTAGEN_DATA_DIRECTORY": mutagenDataDirectory.path,
159+
]
160+
logger.info("setting mutagen data directory: \(self.mutagenDataDirectory.path, privacy: .public)")
161+
return process
162+
}
163+
164+
private func terminationHandler(waitForExit: @Sendable () async -> Void) async {
165+
await waitForExit()
166+
167+
switch state {
168+
case .stopped:
169+
logger.info("mutagen daemon stopped")
170+
default:
171+
logger.error(
172+
"""
173+
mutagen daemon exited unexpectedly with code:
174+
\(self.mutagenProcess?.exitCode.description ?? "unknown")
175+
"""
176+
)
177+
state = .failed(.terminatedUnexpectedly)
178+
}
179+
}
180+
181+
private func streamHandler(io: Pipe.AsyncBytes) async {
182+
for await line in io.lines {
183+
logger.info("\(line, privacy: .public)")
184+
}
185+
}
186+
}
187+
188+
public enum DaemonState {
189+
case running
190+
case stopped
191+
case failed(DaemonError)
192+
case unavailable
193+
194+
var description: String {
195+
switch self {
196+
case .running:
197+
"Running"
198+
case .stopped:
199+
"Stopped"
200+
case let .failed(error):
201+
"Failed: \(error)"
202+
case .unavailable:
203+
"Unavailable"
204+
}
205+
}
206+
}
207+
208+
public enum DaemonError: Error {
209+
case daemonStartFailure(Error)
210+
case connectionFailure(Error)
211+
case terminatedUnexpectedly
212+
213+
var description: String {
214+
switch self {
215+
case let .daemonStartFailure(error):
216+
"Daemon start failure: \(error)"
217+
case let .connectionFailure(error):
218+
"Connection failure: \(error)"
219+
case .terminatedUnexpectedly:
220+
"Daemon terminated unexpectedly"
221+
}
222+
}
223+
224+
var localizedDescription: String { description }
225+
}

0 commit comments

Comments
 (0)