Skip to content

Commit be70ade

Browse files
committedMar 27, 2025·
feat: add file sync daemon error handling to the UI
1 parent eb29807 commit be70ade

File tree

7 files changed

+300
-80
lines changed

7 files changed

+300
-80
lines changed
 

Diff for: ‎Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ final class PreviewFileSync: FileSyncDaemon {
66

77
var state: DaemonState = .running
88

9+
var recentLogs: [String] = []
10+
911
init() {}
1012

1113
func refreshSessions() async {}
1214

13-
func start() async throws(DaemonError) {
15+
func tryStart() async {
1416
state = .running
1517
}
1618

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift

+121-68
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
1111

1212
@State private var loading: Bool = false
1313
@State private var deleteError: DaemonError?
14+
@State private var isVisible: Bool = false
15+
@State private var dontRetry: Bool = false
1416

1517
var body: some View {
1618
Group {
@@ -36,87 +38,138 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
3638
.frame(minWidth: 400, minHeight: 200)
3739
.padding(.bottom, 25)
3840
.overlay(alignment: .bottom) {
39-
VStack(alignment: .leading, spacing: 0) {
40-
Divider()
41-
HStack(spacing: 0) {
42-
Button {
43-
addingNewSession = true
44-
} label: {
45-
Image(systemName: "plus")
46-
.frame(width: 24, height: 24)
47-
}.disabled(vpn.menuState.agents.isEmpty)
41+
tableFooter
42+
}
43+
// Only the table & footer should be disabled if the daemon has crashed
44+
// otherwise the alert buttons will be disabled too
45+
}.disabled(fileSync.state.isFailed)
46+
.sheet(isPresented: $addingNewSession) {
47+
FileSyncSessionModal<VPN, FS>()
48+
.frame(width: 700)
49+
}.sheet(item: $editingSession) { session in
50+
FileSyncSessionModal<VPN, FS>(existingSession: session)
51+
.frame(width: 700)
52+
}.alert("Error", isPresented: Binding(
53+
get: { deleteError != nil },
54+
set: { isPresented in
55+
if !isPresented {
56+
deleteError = nil
57+
}
58+
}
59+
)) {} message: {
60+
Text(deleteError?.description ?? "An unknown error occurred.")
61+
}.alert("Error", isPresented: Binding(
62+
// We only show the alert if the file config window is open
63+
// Users will see the alert symbol on the menu bar to prompt them to
64+
// open it. The requirement on `!loading` prevents the alert from
65+
// re-opening immediately.
66+
get: { !loading && isVisible && fileSync.state.isFailed },
67+
set: { isPresented in
68+
if !isPresented {
69+
if dontRetry {
70+
dontRetry = false
71+
return
72+
}
73+
loading = true
74+
Task {
75+
await fileSync.tryStart()
76+
loading = false
77+
}
78+
}
79+
}
80+
)) {
81+
Button("Retry") {}
82+
// This gives the user an out if the daemon is crashing on launch,
83+
// they can cancel the alert, and it will reappear if they re-open the
84+
// file sync window.
85+
Button("Cancel", role: .cancel) {
86+
dontRetry = true
87+
}
88+
} message: {
89+
// You can't have styled text in alert messages
90+
Text("""
91+
File sync daemon failed: \(fileSync.state.description)\n\n\(fileSync.recentLogs.joined(separator: "\n"))
92+
""")
93+
}.task {
94+
// When the Window is visible, poll for session updates every
95+
// two seconds.
96+
while !Task.isCancelled {
97+
if !fileSync.state.isFailed {
98+
await fileSync.refreshSessions()
99+
}
100+
try? await Task.sleep(for: .seconds(2))
101+
}
102+
}.onAppear {
103+
isVisible = true
104+
}.onDisappear {
105+
isVisible = false
106+
// If the failure alert is dismissed without restarting the daemon,
107+
// (by clicking cancel) this makes it clear that the daemon
108+
// is still in a failed state.
109+
}.navigationTitle("Coder File Sync \(fileSync.state.isFailed ? "- Failed" : "")")
110+
.disabled(loading)
111+
}
112+
113+
var tableFooter: some View {
114+
VStack(alignment: .leading, spacing: 0) {
115+
Divider()
116+
HStack(spacing: 0) {
117+
Button {
118+
addingNewSession = true
119+
} label: {
120+
Image(systemName: "plus")
121+
.frame(width: 24, height: 24)
122+
}.disabled(vpn.menuState.agents.isEmpty)
123+
Divider()
124+
Button {
125+
Task {
126+
loading = true
127+
defer { loading = false }
128+
do throws(DaemonError) {
129+
// TODO: Support selecting & deleting multiple sessions at once
130+
try await fileSync.deleteSessions(ids: [selection!])
131+
if fileSync.sessionState.isEmpty {
132+
// Last session was deleted, stop the daemon
133+
await fileSync.stop()
134+
}
135+
} catch {
136+
deleteError = error
137+
}
138+
selection = nil
139+
}
140+
} label: {
141+
Image(systemName: "minus").frame(width: 24, height: 24)
142+
}.disabled(selection == nil)
143+
if let selection {
144+
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
48145
Divider()
49146
Button {
50147
Task {
148+
// TODO: Support pausing & resuming multiple sessions at once
51149
loading = true
52150
defer { loading = false }
53-
do throws(DaemonError) {
54-
// TODO: Support selecting & deleting multiple sessions at once
55-
try await fileSync.deleteSessions(ids: [selection!])
56-
if fileSync.sessionState.isEmpty {
57-
// Last session was deleted, stop the daemon
58-
await fileSync.stop()
59-
}
60-
} catch {
61-
deleteError = error
151+
switch selectedSession.status {
152+
case .paused:
153+
try await fileSync.resumeSessions(ids: [selectedSession.id])
154+
default:
155+
try await fileSync.pauseSessions(ids: [selectedSession.id])
62156
}
63-
selection = nil
64157
}
65158
} label: {
66-
Image(systemName: "minus").frame(width: 24, height: 24)
67-
}.disabled(selection == nil)
68-
if let selection {
69-
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
70-
Divider()
71-
Button {
72-
Task {
73-
// TODO: Support pausing & resuming multiple sessions at once
74-
loading = true
75-
defer { loading = false }
76-
switch selectedSession.status {
77-
case .paused:
78-
try await fileSync.resumeSessions(ids: [selectedSession.id])
79-
default:
80-
try await fileSync.pauseSessions(ids: [selectedSession.id])
81-
}
82-
}
83-
} label: {
84-
switch selectedSession.status {
85-
case .paused:
86-
Image(systemName: "play").frame(width: 24, height: 24)
87-
default:
88-
Image(systemName: "pause").frame(width: 24, height: 24)
89-
}
90-
}
159+
switch selectedSession.status {
160+
case .paused:
161+
Image(systemName: "play").frame(width: 24, height: 24)
162+
default:
163+
Image(systemName: "pause").frame(width: 24, height: 24)
91164
}
92165
}
93166
}
94-
.buttonStyle(.borderless)
95167
}
96-
.background(.primary.opacity(0.04))
97-
.fixedSize(horizontal: false, vertical: true)
98-
}
99-
}.sheet(isPresented: $addingNewSession) {
100-
FileSyncSessionModal<VPN, FS>()
101-
.frame(width: 700)
102-
}.sheet(item: $editingSession) { session in
103-
FileSyncSessionModal<VPN, FS>(existingSession: session)
104-
.frame(width: 700)
105-
}.alert("Error", isPresented: Binding(
106-
get: { deleteError != nil },
107-
set: { isPresented in
108-
if !isPresented {
109-
deleteError = nil
110-
}
111-
}
112-
)) {} message: {
113-
Text(deleteError?.description ?? "An unknown error occurred.")
114-
}.task {
115-
while !Task.isCancelled {
116-
await fileSync.refreshSessions()
117-
try? await Task.sleep(for: .seconds(2))
118168
}
119-
}.disabled(loading)
169+
.buttonStyle(.borderless)
170+
}
171+
.background(.primary.opacity(0.04))
172+
.fixedSize(horizontal: false, vertical: true)
120173
}
121174
}
122175

Diff for: ‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift

+5-4
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
6868
} label: {
6969
ButtonRowView {
7070
HStack {
71-
// TODO: A future PR will provide users a way to recover from a daemon failure without
72-
// needing to restart the app
73-
if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) {
71+
if fileSync.state.isFailed || sessionsHaveError(fileSync.sessionState) {
7472
Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
75-
.frame(width: 12, height: 12).help("One or more sync sessions have errors")
73+
.frame(width: 12, height: 12)
74+
.help(fileSync.state.isFailed ?
75+
"The file sync daemon encountered an error" :
76+
"One or more file sync sessions have errors")
7677
}
7778
Text("File sync")
7879
}

Diff for: ‎Coder-Desktop/Coder-DesktopTests/Util.swift

+3-3
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ class MockFileSyncDaemon: FileSyncDaemon {
3333

3434
func refreshSessions() async {}
3535

36+
var recentLogs: [String] = []
37+
3638
func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
3739

3840
var state: VPNLib.DaemonState = .running
3941

40-
func start() async throws(VPNLib.DaemonError) {
41-
return
42-
}
42+
func tryStart() async {}
4343

4444
func stop() async {}
4545

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

+41-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import SwiftUI
1010
public protocol FileSyncDaemon: ObservableObject {
1111
var state: DaemonState { get }
1212
var sessionState: [FileSyncSession] { get }
13-
func start() async throws(DaemonError)
13+
var recentLogs: [String] { get }
14+
func tryStart() async
1415
func stop() async
1516
func refreshSessions() async
1617
func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
@@ -38,6 +39,10 @@ public class MutagenDaemon: FileSyncDaemon {
3839

3940
@Published public var sessionState: [FileSyncSession] = []
4041

42+
// We store the last N log lines to show in the UI if the daemon crashes
43+
private var logBuffer: RingBuffer<String>
44+
public var recentLogs: [String] { logBuffer.elements }
45+
4146
private var mutagenProcess: Subprocess?
4247
private let mutagenPath: URL!
4348
private let mutagenDataDirectory: URL
@@ -50,6 +55,7 @@ public class MutagenDaemon: FileSyncDaemon {
5055
var client: DaemonClient?
5156
private var group: MultiThreadedEventLoopGroup?
5257
private var channel: GRPCChannel?
58+
private var waitForExit: (@Sendable () async -> Void)?
5359

5460
// Protect start & stop transitions against re-entrancy
5561
private let transition = AsyncSemaphore(value: 1)
@@ -58,8 +64,10 @@ public class MutagenDaemon: FileSyncDaemon {
5864
mutagenDataDirectory: URL = FileManager.default.urls(
5965
for: .applicationSupportDirectory,
6066
in: .userDomainMask
61-
).first!.appending(path: "Coder Desktop").appending(path: "Mutagen"))
67+
).first!.appending(path: "Coder Desktop").appending(path: "Mutagen"),
68+
logBufferCapacity: Int = 10)
6269
{
70+
logBuffer = .init(capacity: logBufferCapacity)
6371
self.mutagenPath = mutagenPath
6472
self.mutagenDataDirectory = mutagenDataDirectory
6573
mutagenDaemonSocket = mutagenDataDirectory.appending(path: "daemon").appending(path: "daemon.sock")
@@ -87,13 +95,31 @@ public class MutagenDaemon: FileSyncDaemon {
8795
}
8896
}
8997

90-
public func start() async throws(DaemonError) {
98+
public func tryStart() async {
99+
if case .failed = state { state = .stopped }
100+
do throws(DaemonError) {
101+
try await start()
102+
} catch {
103+
state = .failed(error)
104+
}
105+
}
106+
107+
func start() async throws(DaemonError) {
91108
if case .unavailable = state { return }
92109

93110
// Stop an orphaned daemon, if there is one
94111
try? await connect()
95112
await stop()
96113

114+
// Creating the same process twice from Swift will crash the MainActor,
115+
// so we need to wait for an earlier process to die
116+
if let waitForExit {
117+
await waitForExit()
118+
// We *need* to be sure the process is dead or the app ends up in an
119+
// unrecoverable state
120+
try? await Task.sleep(for: .seconds(1))
121+
}
122+
97123
await transition.wait()
98124
defer { transition.signal() }
99125
logger.info("starting mutagen daemon")
@@ -106,6 +132,7 @@ public class MutagenDaemon: FileSyncDaemon {
106132
} catch {
107133
throw .daemonStartFailure(error)
108134
}
135+
self.waitForExit = waitForExit
109136

110137
Task {
111138
await streamHandler(io: standardOutput)
@@ -259,6 +286,7 @@ public class MutagenDaemon: FileSyncDaemon {
259286
private func streamHandler(io: Pipe.AsyncBytes) async {
260287
for await line in io.lines {
261288
logger.info("\(line, privacy: .public)")
289+
logBuffer.append(line)
262290
}
263291
}
264292
}
@@ -282,7 +310,7 @@ public enum DaemonState {
282310
case .stopped:
283311
"Stopped"
284312
case let .failed(error):
285-
"Failed: \(error)"
313+
"\(error.description)"
286314
case .unavailable:
287315
"Unavailable"
288316
}
@@ -300,6 +328,15 @@ public enum DaemonState {
300328
.gray
301329
}
302330
}
331+
332+
// `if case`s are a pain to work with: they're not bools (such as for ORing)
333+
// and you can't negate them without doing `if case .. {} else`.
334+
public var isFailed: Bool {
335+
if case .failed = self {
336+
return true
337+
}
338+
return false
339+
}
303340
}
304341

305342
public enum DaemonError: Error {

0 commit comments

Comments
 (0)