Skip to content

feat: support restarting file sync sessions #124

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 @@ -27,4 +27,6 @@ final class PreviewFileSync: FileSyncDaemon {
func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}

func resumeSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}

func resetSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
}
128 changes: 80 additions & 48 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
@State private var editingSession: FileSyncSession?

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

Expand Down Expand Up @@ -50,14 +50,14 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
FileSyncSessionModal<VPN, FS>(existingSession: session)
.frame(width: 700)
}.alert("Error", isPresented: Binding(
get: { deleteError != nil },
get: { actionError != nil },
set: { isPresented in
if !isPresented {
deleteError = nil
actionError = nil
}
}
)) {} message: {
Text(deleteError?.description ?? "An unknown error occurred.")
Text(actionError?.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
Expand Down Expand Up @@ -89,7 +89,7 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
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
// Opens the log file in Console
NSWorkspace.shared.open(fileSync.logFile)
}
}.task {
Expand Down Expand Up @@ -120,58 +120,90 @@ struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View {
addingNewSession = true
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 24)
.frame(width: 24, height: 24).help("Create")
}.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
sessionControls
}
.buttonStyle(.borderless)
}
.background(.primary.opacity(0.04))
.fixedSize(horizontal: false, vertical: true)
}

var sessionControls: some View {
Group {
if let selection {
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) {
Divider()
Button { Task { await delete(session: selectedSession) } }
label: {
Image(systemName: "minus").frame(width: 24, height: 24).help("Terminate")
}
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: {
Divider()
Button { Task { await pauseResume(session: selectedSession) } }
label: {
switch selectedSession.status {
case .paused:
Image(systemName: "play").frame(width: 24, height: 24)
case .paused, .error(.haltedOnRootEmptied),
.error(.haltedOnRootDeletion),
.error(.haltedOnRootTypeChange):
Image(systemName: "play").frame(width: 24, height: 24).help("Pause")
default:
Image(systemName: "pause").frame(width: 24, height: 24)
Image(systemName: "pause").frame(width: 24, height: 24).help("Resume")
}
}
}
Divider()
Button { Task { await reset(session: selectedSession) } }
label: {
Image(systemName: "arrow.clockwise").frame(width: 24, height: 24).help("Reset")
}
}
}
.buttonStyle(.borderless)
}
.background(.primary.opacity(0.04))
.fixedSize(horizontal: false, vertical: true)
}

// TODO: Support selecting & deleting multiple sessions at once
func delete(session _: FileSyncSession) async {
loading = true
defer { loading = false }
do throws(DaemonError) {
try await fileSync.deleteSessions(ids: [selection!])
if fileSync.sessionState.isEmpty {
// Last session was deleted, stop the daemon
await fileSync.stop()
}
} catch {
actionError = error
}
selection = nil
}

// TODO: Support pausing & resuming multiple sessions at once
func pauseResume(session: FileSyncSession) async {
loading = true
defer { loading = false }
do throws(DaemonError) {
switch session.status {
case .paused, .error(.haltedOnRootEmptied),
.error(.haltedOnRootDeletion),
.error(.haltedOnRootTypeChange):
try await fileSync.resumeSessions(ids: [session.id])
default:
try await fileSync.pauseSessions(ids: [session.id])
}
} catch {
actionError = error
}
}

// TODO: Support restarting multiple sessions at once
func reset(session: FileSyncSession) async {
loading = true
defer { loading = false }
do throws(DaemonError) {
try await fileSync.resetSessions(ids: [session.id])
} catch {
actionError = error
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions Coder-Desktop/Coder-DesktopTests/Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class MockFileSyncDaemon: FileSyncDaemon {
func pauseSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}

func resumeSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}

func resetSessions(ids _: [String]) async throws(VPNLib.DaemonError) {}
}

extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}
1 change: 1 addition & 0 deletions Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public protocol FileSyncDaemon: ObservableObject {
func deleteSessions(ids: [String]) async throws(DaemonError)
func pauseSessions(ids: [String]) async throws(DaemonError)
func resumeSessions(ids: [String]) async throws(DaemonError)
func resetSessions(ids: [String]) async throws(DaemonError)
}

@MainActor
Expand Down
23 changes: 20 additions & 3 deletions Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,8 @@ public extension MutagenDaemon {
}

func resumeSessions(ids: [String]) async throws(DaemonError) {
// Resuming sessions does not require prompting, according to the
// Mutagen CLI
let (stream, promptID) = try await host(allowPrompts: false)
// Resuming sessions does use prompting, as it may start a new SSH connection
let (stream, promptID) = try await host(allowPrompts: true)
defer { stream.cancel() }
guard case .running = state else { return }
do {
Expand All @@ -117,4 +116,22 @@ public extension MutagenDaemon {
}
await refreshSessions()
}

func resetSessions(ids: [String]) async throws(DaemonError) {
// Resetting a session involves pausing & resuming, so it does use prompting
let (stream, promptID) = try await host(allowPrompts: true)
defer { stream.cancel() }
guard case .running = state else { return }
do {
_ = try await client!.sync.reset(Synchronization_ResetRequest.with { req in
req.prompter = promptID
req.selection = .with { selection in
selection.specifications = ids
}
}, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout)))
} catch {
throw .grpcFailure(error)
}
await refreshSessions()
}
}
Loading