Skip to content

Commit be70ade

Browse files
committed
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

Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift

Lines changed: 3 additions & 1 deletion
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

Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift

Lines changed: 121 additions & 68 deletions
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

Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift

Lines changed: 5 additions & 4 deletions
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
}

Coder-Desktop/Coder-DesktopTests/Util.swift

Lines changed: 3 additions & 3 deletions
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

Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift

Lines changed: 41 additions & 4 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy