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
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
@@ -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
@@ -10,7 +12,7 @@ final class PreviewFileSync: FileSyncDaemon {

func refreshSessions() async {}

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

191 changes: 123 additions & 68 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
Original file line number Diff line number Diff line change
@@ -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 {
@@ -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)
}
}

9 changes: 5 additions & 4 deletions Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift
Original file line number Diff line number Diff line change
@@ -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")
}
6 changes: 3 additions & 3 deletions Coder-Desktop/Coder-DesktopTests/Util.swift
Original file line number Diff line number Diff line change
@@ -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 {}
@@ -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 {}

68 changes: 55 additions & 13 deletions Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
Original file line number Diff line number Diff line change
@@ -10,7 +10,8 @@ import SwiftUI
public protocol FileSyncDaemon: ObservableObject {
var state: DaemonState { get }
var sessionState: [FileSyncSession] { get }
func start() async throws(DaemonError)
var logFile: URL { get }
func tryStart() async
func stop() async
func refreshSessions() async
func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError)
@@ -43,13 +44,16 @@ public class MutagenDaemon: FileSyncDaemon {
private let mutagenDataDirectory: URL
private let mutagenDaemonSocket: URL

public let logFile: URL

// Managing sync sessions could take a while, especially with prompting
let sessionMgmtReqTimeout: TimeAmount = .seconds(15)

// Non-nil when the daemon is running
var client: DaemonClient?
private var group: MultiThreadedEventLoopGroup?
private var channel: GRPCChannel?
private var waitForExit: (@Sendable () async -> Void)?

// Protect start & stop transitions against re-entrancy
private let transition = AsyncSemaphore(value: 1)
@@ -63,6 +67,7 @@ public class MutagenDaemon: FileSyncDaemon {
self.mutagenPath = mutagenPath
self.mutagenDataDirectory = mutagenDataDirectory
mutagenDaemonSocket = mutagenDataDirectory.appending(path: "daemon").appending(path: "daemon.sock")
logFile = mutagenDataDirectory.appending(path: "daemon.log")
// It shouldn't be fatal if the app was built without Mutagen embedded,
// but file sync will be unavailable.
if mutagenPath == nil {
@@ -87,33 +92,41 @@ public class MutagenDaemon: FileSyncDaemon {
}
}

public func start() async throws(DaemonError) {
public func tryStart() async {
if case .failed = state { state = .stopped }
do throws(DaemonError) {
try await start()
} catch {
state = .failed(error)
}
}

func start() async throws(DaemonError) {
if case .unavailable = state { return }

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

// Creating the same process twice from Swift will crash the MainActor,
// so we need to wait for an earlier process to die
await waitForExit?()

await transition.wait()
defer { transition.signal() }
logger.info("starting mutagen daemon")

mutagenProcess = createMutagenProcess()
// swiftlint:disable:next large_tuple
let (standardOutput, standardError, waitForExit): (Pipe.AsyncBytes, Pipe.AsyncBytes, @Sendable () async -> Void)
let (standardError, waitForExit): (Pipe.AsyncBytes, @Sendable () async -> Void)
do {
(standardOutput, standardError, waitForExit) = try mutagenProcess!.run()
(_, standardError, waitForExit) = try mutagenProcess!.run()
} catch {
throw .daemonStartFailure(error)
}
self.waitForExit = waitForExit

Task {
await streamHandler(io: standardOutput)
logger.info("standard output stream closed")
}

Task {
await streamHandler(io: standardError)
await handleDaemonLogs(io: standardError)
logger.info("standard error stream closed")
}

@@ -256,10 +269,30 @@ public class MutagenDaemon: FileSyncDaemon {
}
}

private func streamHandler(io: Pipe.AsyncBytes) async {
private func handleDaemonLogs(io: Pipe.AsyncBytes) async {
if !FileManager.default.fileExists(atPath: logFile.path) {
guard FileManager.default.createFile(atPath: logFile.path, contents: nil) else {
logger.error("Failed to create log file")
return
}
}

guard let fileHandle = try? FileHandle(forWritingTo: logFile) else {
logger.error("Failed to open log file for writing")
return
}

for await line in io.lines {
logger.info("\(line, privacy: .public)")

do {
try fileHandle.write(contentsOf: Data("\(line)\n".utf8))
} catch {
logger.error("Failed to write to daemon log file: \(error)")
}
}

try? fileHandle.close()
}
}

@@ -282,7 +315,7 @@ public enum DaemonState {
case .stopped:
"Stopped"
case let .failed(error):
"Failed: \(error)"
"\(error.description)"
case .unavailable:
"Unavailable"
}
@@ -300,6 +333,15 @@ public enum DaemonState {
.gray
}
}

// `if case`s are a pain to work with: they're not bools (such as for ORing)
// and you can't negate them without doing `if case .. {} else`.
public var isFailed: Bool {
if case .failed = self {
return true
}
return false
}
}

public enum DaemonError: Error {