From cb25fe59d78ee7718fe628317cf2cd84c143fcef Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 24 Mar 2025 15:36:09 +1100 Subject: [PATCH 1/6] chore: create & delete sync sessions over gRPC --- .../VPNLib/FileSync/FileSyncDaemon.swift | 47 +---------- .../VPNLib/FileSync/FileSyncManagement.swift | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+), 46 deletions(-) create mode 100644 Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index eafd4dc..1dd6b95 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -162,7 +162,7 @@ public class MutagenDaemon: FileSyncDaemon { // Already connected return } - group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + group = MultiThreadedEventLoopGroup(numberOfThreads: 2) do { channel = try GRPCChannelPool.with( target: .unixDomainSocket(mutagenDaemonSocket.path), @@ -252,51 +252,6 @@ public class MutagenDaemon: FileSyncDaemon { logger.info("\(line, privacy: .public)") } } - - public func refreshSessions() async { - guard case .running = state else { return } - // TODO: Implement - } - - public func createSession( - localPath _: String, - agentHost _: String, - remotePath _: String - ) async throws(DaemonError) { - if case .stopped = state { - do throws(DaemonError) { - try await start() - } catch { - state = .failed(error) - throw error - } - } - // TODO: Add session - } - - public func deleteSessions(ids _: [String]) async throws(DaemonError) { - // TODO: Delete session - await stopIfNoSessions() - } - - private func stopIfNoSessions() async { - let sessions: Synchronization_ListResponse - do { - sessions = try await client!.sync.list(Synchronization_ListRequest.with { req in - req.selection = .with { selection in - selection.all = true - } - }) - } catch { - state = .failed(.daemonStartFailure(error)) - return - } - // If there's no configured sessions, the daemon doesn't need to be running - if sessions.sessionStates.isEmpty { - logger.info("No sync sessions found") - await stop() - } - } } struct DaemonClient { diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift new file mode 100644 index 0000000..e654866 --- /dev/null +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift @@ -0,0 +1,80 @@ +public extension MutagenDaemon { + func refreshSessions() async { + guard case .running = state else { return } + let sessions: Synchronization_ListResponse + do { + sessions = try await client!.sync.list(Synchronization_ListRequest.with { req in + req.selection = .with { selection in + selection.all = true + } + }) + } catch { + state = .failed(.grpcFailure(error)) + return + } + sessionState = sessions.sessionStates.map { FileSyncSession(state: $0) } + if sessionState.isEmpty { + logger.info("No sync sessions found") + await stop() + } + } + + func createSession( + localPath: String, + agentHost: String, + remotePath: String + ) async throws(DaemonError) { + if case .stopped = state { + do throws(DaemonError) { + try await start() + } catch { + state = .failed(error) + throw error + } + } + let (stream, promptID) = try await host() + defer { stream.cancel() } + let req = Synchronization_CreateRequest.with { req in + req.prompter = promptID + req.specification = .with { spec in + spec.alpha = .with { alpha in + alpha.protocol = .local + alpha.path = localPath + } + spec.beta = .with { beta in + beta.protocol = .ssh + beta.host = agentHost + beta.path = remotePath + } + // TODO: Ingest a config from somewhere + spec.configuration = Synchronization_Configuration() + spec.configurationAlpha = Synchronization_Configuration() + spec.configurationBeta = Synchronization_Configuration() + } + } + do { + _ = try await client!.sync.create(req) + } catch { + throw .grpcFailure(error) + } + await refreshSessions() + } + + func deleteSessions(ids: [String]) async throws(DaemonError) { + // Terminating sessions does not require prompting + let (stream, promptID) = try await host(allowPrompts: false) + defer { stream.cancel() } + guard case .running = state else { return } + do { + _ = try await client!.sync.terminate(Synchronization_TerminateRequest.with { req in + req.prompter = promptID + req.selection = .with { selection in + selection.specifications = ids + } + }) + } catch { + throw .grpcFailure(error) + } + await refreshSessions() + } +} From 127807063eae536e4c9c6ba253b64836ee6f35af Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 24 Mar 2025 23:32:25 +1100 Subject: [PATCH 2/6] pausing & unpausing --- .../Preview Content/PreviewFileSync.swift | 4 +++ .../Views/FileSync/FileSyncConfig.swift | 10 +++++- .../VPNLib/FileSync/FileSyncDaemon.swift | 2 ++ .../VPNLib/FileSync/FileSyncManagement.swift | 34 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift index 8db30e3..082c144 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift @@ -21,4 +21,8 @@ final class PreviewFileSync: FileSyncDaemon { func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {} func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} + + func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} + + func resumeSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} } diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index dc83c17..1abc8e8 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -65,7 +65,15 @@ struct FileSyncConfig: View { if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) { Divider() Button { - // TODO: Pause & Unpause + Task { + // TODO: Support pausing & resuming multiple selections + 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: diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 1dd6b95..641f4e5 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -15,6 +15,8 @@ public protocol FileSyncDaemon: ObservableObject { func refreshSessions() async func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError) func deleteSessions(ids: [String]) async throws(DaemonError) + func pauseSessions(ids: [String]) async throws(DaemonError) + func resumeSessions(ids: [String]) async throws(DaemonError) } @MainActor diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift index e654866..1bc4c0c 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift @@ -77,4 +77,38 @@ public extension MutagenDaemon { } await refreshSessions() } + + func pauseSessions(ids: [String]) async throws(DaemonError) { + let (stream, promptID) = try await host() + defer { stream.cancel() } + guard case .running = state else { return } + do { + _ = try await client!.sync.pause(Synchronization_PauseRequest.with { req in + req.prompter = promptID + req.selection = .with { selection in + selection.specifications = ids + } + }) + } catch { + throw .grpcFailure(error) + } + await refreshSessions() + } + + func resumeSessions(ids: [String]) async throws(DaemonError) { + let (stream, promptID) = try await host() + defer { stream.cancel() } + guard case .running = state else { return } + do { + _ = try await client!.sync.resume(Synchronization_ResumeRequest.with { req in + req.prompter = promptID + req.selection = .with { selection in + selection.specifications = ids + } + }) + } catch { + throw .grpcFailure(error) + } + await refreshSessions() + } } From dfd1bc8eaf3f2207895fdaa1f5d58a67e61a5229 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 24 Mar 2025 23:34:51 +1100 Subject: [PATCH 3/6] fixup --- .../Views/FileSync/FileSyncConfig.swift | 10 ++++++++-- .../Views/FileSync/FileSyncSessionModal.swift | 1 - Coder-Desktop/Coder-DesktopTests/Util.swift | 4 ++++ .../VPNLib/FileSync/FileSyncDaemon.swift | 4 ++++ .../VPNLib/FileSync/FileSyncManagement.swift | 15 ++++++++------- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index 1abc8e8..5a7257b 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -51,11 +51,15 @@ struct FileSyncConfig: View { 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 } - await fileSync.refreshSessions() selection = nil } } label: { @@ -66,7 +70,9 @@ struct FileSyncConfig: View { Divider() Button { Task { - // TODO: Support pausing & resuming multiple selections + // 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]) diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift index c0c7a35..2539e9d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift @@ -83,7 +83,6 @@ struct FileSyncSessionModal: View { defer { loading = false } do throws(DaemonError) { if let existingSession { - // TODO: Support selecting & deleting multiple sessions at once try await fileSync.deleteSessions(ids: [existingSession.id]) } try await fileSync.createSession( diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index e38fe33..cad7eac 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -48,6 +48,10 @@ class MockFileSyncDaemon: FileSyncDaemon { } func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {} + + func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} + + func resumeSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} } extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {} diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 641f4e5..4fa7611 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -77,6 +77,10 @@ public class MutagenDaemon: FileSyncDaemon { return } await refreshSessions() + if sessionState.isEmpty { + logger.info("No sync sessions found on startup, stopping daemon") + await stop() + } } } diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift index 1bc4c0c..8c4eb0c 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift @@ -13,10 +13,6 @@ public extension MutagenDaemon { return } sessionState = sessions.sessionStates.map { FileSyncSession(state: $0) } - if sessionState.isEmpty { - logger.info("No sync sessions found") - await stop() - } } func createSession( @@ -61,7 +57,8 @@ public extension MutagenDaemon { } func deleteSessions(ids: [String]) async throws(DaemonError) { - // Terminating sessions does not require prompting + // Terminating sessions does not require prompting, according to the + // Mutagen CLI let (stream, promptID) = try await host(allowPrompts: false) defer { stream.cancel() } guard case .running = state else { return } @@ -79,7 +76,9 @@ public extension MutagenDaemon { } func pauseSessions(ids: [String]) async throws(DaemonError) { - let (stream, promptID) = try await host() + // Pausing sessions does not require prompting, according to the + // Mutagen CLI + let (stream, promptID) = try await host(allowPrompts: false) defer { stream.cancel() } guard case .running = state else { return } do { @@ -96,7 +95,9 @@ public extension MutagenDaemon { } func resumeSessions(ids: [String]) async throws(DaemonError) { - let (stream, promptID) = try await host() + // Resuming sessions does not require prompting, according to the + // Mutagen CLI + let (stream, promptID) = try await host(allowPrompts: false) defer { stream.cancel() } guard case .running = state else { return } do { From 1b6444429a3f299bbf6270d3c60610bceb129eb7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 25 Mar 2025 16:53:21 +1100 Subject: [PATCH 4/6] set generous timeouts on session requests --- Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift | 3 +++ Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift | 10 ++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 4fa7611..11b42af 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -43,6 +43,9 @@ public class MutagenDaemon: FileSyncDaemon { private let mutagenDataDirectory: URL private let mutagenDaemonSocket: URL + // Managing sync sessions can take a while, especially with prompting + let sessionMgmtReqTimeout: TimeAmount = .seconds(5) + // Non-nil when the daemon is running var client: DaemonClient? private var group: MultiThreadedEventLoopGroup? diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift index 8c4eb0c..1be95a6 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift @@ -1,3 +1,5 @@ +import NIOCore + public extension MutagenDaemon { func refreshSessions() async { guard case .running = state else { return } @@ -49,7 +51,7 @@ public extension MutagenDaemon { } } do { - _ = try await client!.sync.create(req) + _ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout))) } catch { throw .grpcFailure(error) } @@ -68,7 +70,7 @@ public extension MutagenDaemon { req.selection = .with { selection in selection.specifications = ids } - }) + }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout))) } catch { throw .grpcFailure(error) } @@ -87,7 +89,7 @@ public extension MutagenDaemon { req.selection = .with { selection in selection.specifications = ids } - }) + }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout))) } catch { throw .grpcFailure(error) } @@ -106,7 +108,7 @@ public extension MutagenDaemon { req.selection = .with { selection in selection.specifications = ids } - }) + }, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout))) } catch { throw .grpcFailure(error) } From 91a5b36307f4327ddf55ea95213f90fef0bfbcf7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 26 Mar 2025 23:26:22 +1100 Subject: [PATCH 5/6] very important fix --- .../Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift index 2539e9d..d398172 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift @@ -68,7 +68,7 @@ struct FileSyncSessionModal: View { }.disabled(loading) .alert("Error", isPresented: Binding( get: { createError != nil }, - set: { if $0 { createError = nil } } + set: { if !$0 { createError = nil } } )) {} message: { Text(createError?.description ?? "An unknown error occurred.") } From 58f9775efba9e7b229034fa99f7a827d52c6c3eb Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 26 Mar 2025 23:43:26 +1100 Subject: [PATCH 6/6] bump timeouts --- Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift | 4 ++-- Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 11b42af..2adce4b 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -43,8 +43,8 @@ public class MutagenDaemon: FileSyncDaemon { private let mutagenDataDirectory: URL private let mutagenDaemonSocket: URL - // Managing sync sessions can take a while, especially with prompting - let sessionMgmtReqTimeout: TimeAmount = .seconds(5) + // 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? diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift index 1be95a6..c826fa7 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift @@ -51,7 +51,10 @@ public extension MutagenDaemon { } } do { - _ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout))) + // The first creation will need to transfer the agent binary + // TODO: Because this is pretty long, we should show progress updates + // using the prompter messages + _ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout * 4))) } catch { throw .grpcFailure(error) }