Skip to content

feat: add file sync daemon error handling to the UI #122

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import VPNLib

@MainActor
final class PreviewFileSync: FileSyncDaemon {
var logFile: URL = .init(filePath: "~/log.txt")!
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows we are putting this in $MUTAGEN_DATA_DIR/daemon.log. It shouldn't go in the home directory

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note that when it's a .log file there most likely isn't an associated program for .log, so you could either leave it as .txt e.g. daemon-log.txt or just open the directory (which is what I'll do on Windows)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a class for mocking previews, but in the real class it's going in $MUTAGEN_DATA_DIR/daemon.log

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, you're right mb. As long as we're not creating a file in ~/ when using the preview version then it's fine.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default on macOS .log files are opened in Console.app


var sessionState: [VPNLib.FileSyncSession] = []

var state: DaemonState = .running
Expand All @@ -10,7 +12,7 @@ final class PreviewFileSync: FileSyncDaemon {

func refreshSessions() async {}

func start() async throws(DaemonError) {
func tryStart() async {
state = .running
}

Expand Down
191 changes: 123 additions & 68 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {

@State private var loading: Bool = false
@State private var deleteError: DaemonError?
@State private var isVisible: Bool = false
@State private var dontRetry: Bool = false

var body: some View {
Group {
Expand All @@ -36,87 +38,140 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
.frame(minWidth: 400, minHeight: 200)
.padding(.bottom, 25)
.overlay(alignment: .bottom) {
VStack(alignment: .leading, spacing: 0) {
Divider()
HStack(spacing: 0) {
Button {
addingNewSession = true
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 24)
}.disabled(vpn.menuState.agents.isEmpty)
tableFooter
}
// Only the table & footer should be disabled if the daemon has crashed
// otherwise the alert buttons will be disabled too
}.disabled(fileSync.state.isFailed)
.sheet(isPresented: $addingNewSession) {
FileSyncSessionModal<VPN, FS>()
.frame(width: 700)
}.sheet(item: $editingSession) { session in
FileSyncSessionModal<VPN, FS>(existingSession: session)
.frame(width: 700)
}.alert("Error", isPresented: Binding(
get: { deleteError != nil },
set: { isPresented in
if !isPresented {
deleteError = nil
}
}
)) {} message: {
Text(deleteError?.description ?? "An unknown error occurred.")
}.alert("Error", isPresented: Binding(
// We only show the alert if the file config window is open
// Users will see the alert symbol on the menu bar to prompt them to
// open it. The requirement on `!loading` prevents the alert from
// re-opening immediately.
get: { !loading && isVisible && fileSync.state.isFailed },
set: { isPresented in
if !isPresented {
if dontRetry {
dontRetry = false
return
}
loading = true
Task {
await fileSync.tryStart()
loading = false
}
}
}
)) {
Button("Retry") {}
// This gives the user an out if the daemon is crashing on launch,
// they can cancel the alert, and it will reappear if they re-open the
// file sync window.
Button("Cancel", role: .cancel) {
dontRetry = true
}
} message: {
Text("""
File sync daemon failed. The daemon log file at\n\(fileSync.logFile.path)\nhas been opened.
""").onAppear {
// Open the log file in the default editor
NSWorkspace.shared.open(fileSync.logFile)
}
}.task {
// When the Window is visible, poll for session updates every
// two seconds.
while !Task.isCancelled {
if !fileSync.state.isFailed {
await fileSync.refreshSessions()
}
try? await Task.sleep(for: .seconds(2))
}
}.onAppear {
isVisible = true
}.onDisappear {
isVisible = false
// If the failure alert is dismissed without restarting the daemon,
// (by clicking cancel) this makes it clear that the daemon
// is still in a failed state.
}.navigationTitle("Coder File Sync \(fileSync.state.isFailed ? "- Failed" : "")")
.disabled(loading)
}

var tableFooter: some View {
VStack(alignment: .leading, spacing: 0) {
Divider()
HStack(spacing: 0) {
Button {
addingNewSession = true
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 24)
}.disabled(vpn.menuState.agents.isEmpty)
Divider()
Button {
Task {
loading = true
defer { loading = false }
do throws(DaemonError) {
// TODO: Support selecting & deleting multiple sessions at once
try await fileSync.deleteSessions(ids: [selection!])
if fileSync.sessionState.isEmpty {
// Last session was deleted, stop the daemon
await fileSync.stop()
}
} catch {
deleteError = error
}
selection = nil
}
} label: {
Image(systemName: "minus").frame(width: 24, height: 24)
}.disabled(selection == nil)
if let selection {
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
Divider()
Button {
Task {
// TODO: Support pausing & resuming multiple sessions at once
loading = true
defer { loading = false }
do throws(DaemonError) {
// TODO: Support selecting & deleting multiple sessions at once
try await fileSync.deleteSessions(ids: [selection!])
if fileSync.sessionState.isEmpty {
// Last session was deleted, stop the daemon
await fileSync.stop()
}
} catch {
deleteError = error
switch selectedSession.status {
case .paused:
try await fileSync.resumeSessions(ids: [selectedSession.id])
default:
try await fileSync.pauseSessions(ids: [selectedSession.id])
}
selection = nil
}
} label: {
Image(systemName: "minus").frame(width: 24, height: 24)
}.disabled(selection == nil)
if let selection {
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
Divider()
Button {
Task {
// TODO: Support pausing & resuming multiple sessions at once
loading = true
defer { loading = false }
switch selectedSession.status {
case .paused:
try await fileSync.resumeSessions(ids: [selectedSession.id])
default:
try await fileSync.pauseSessions(ids: [selectedSession.id])
}
}
} label: {
switch selectedSession.status {
case .paused:
Image(systemName: "play").frame(width: 24, height: 24)
default:
Image(systemName: "pause").frame(width: 24, height: 24)
}
}
switch selectedSession.status {
case .paused:
Image(systemName: "play").frame(width: 24, height: 24)
default:
Image(systemName: "pause").frame(width: 24, height: 24)
}
}
}
.buttonStyle(.borderless)
}
.background(.primary.opacity(0.04))
.fixedSize(horizontal: false, vertical: true)
}
}.sheet(isPresented: $addingNewSession) {
FileSyncSessionModal<VPN, FS>()
.frame(width: 700)
}.sheet(item: $editingSession) { session in
FileSyncSessionModal<VPN, FS>(existingSession: session)
.frame(width: 700)
}.alert("Error", isPresented: Binding(
get: { deleteError != nil },
set: { isPresented in
if !isPresented {
deleteError = nil
}
}
)) {} message: {
Text(deleteError?.description ?? "An unknown error occurred.")
}.task {
while !Task.isCancelled {
await fileSync.refreshSessions()
try? await Task.sleep(for: .seconds(2))
}
}.disabled(loading)
.buttonStyle(.borderless)
}
.background(.primary.opacity(0.04))
.fixedSize(horizontal: false, vertical: true)
}
}

Expand Down
9 changes: 5 additions & 4 deletions Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
} label: {
ButtonRowView {
HStack {
// TODO: A future PR will provide users a way to recover from a daemon failure without
// needing to restart the app
if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) {
if fileSync.state.isFailed || sessionsHaveError(fileSync.sessionState) {
Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
.frame(width: 12, height: 12).help("One or more sync sessions have errors")
.frame(width: 12, height: 12)
.help(fileSync.state.isFailed ?
"The file sync daemon encountered an error" :
"One or more file sync sessions have errors")
}
Text("File sync")
}
Expand Down
6 changes: 3 additions & 3 deletions Coder-Desktop/Coder-DesktopTests/Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class MockVPNService: VPNService, ObservableObject {

@MainActor
class MockFileSyncDaemon: FileSyncDaemon {
var logFile: URL = .init(filePath: "~/log.txt")

var sessionState: [VPNLib.FileSyncSession] = []

func refreshSessions() async {}
Expand All @@ -37,9 +39,7 @@ class MockFileSyncDaemon: FileSyncDaemon {

var state: VPNLib.DaemonState = .running

func start() async throws(VPNLib.DaemonError) {
return
}
func tryStart() async {}

func stop() async {}

Expand Down
Loading
Loading