From 4689f22b18d68cab4de309a3ac68ab2ef75bf3ab Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Wed, 26 Mar 2025 16:55:19 +1100 Subject: [PATCH 1/2] feat: add file sync daemon error handling to the UI --- .../Preview Content/PreviewFileSync.swift | 4 +- .../Views/FileSync/FileSyncConfig.swift | 189 +++++++++++------- .../Coder-Desktop/Views/VPN/VPNMenu.swift | 9 +- Coder-Desktop/Coder-DesktopTests/Util.swift | 6 +- .../VPNLib/FileSync/FileSyncDaemon.swift | 45 ++++- Coder-Desktop/VPNLib/Util.swift | 36 ++++ Coder-Desktop/VPNLibTests/UtilTests.swift | 91 +++++++++ 7 files changed, 300 insertions(+), 80 deletions(-) create mode 100644 Coder-Desktop/VPNLibTests/UtilTests.swift diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift index 082c144f..af24c086 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift @@ -6,11 +6,13 @@ final class PreviewFileSync: FileSyncDaemon { var state: DaemonState = .running + var recentLogs: [String] = [] + init() {} func refreshSessions() async {} - func start() async throws(DaemonError) { + func tryStart() async { state = .running } diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index 5a7257b0..4d144ea6 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -11,6 +11,8 @@ struct FileSyncConfig: 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,138 @@ struct FileSyncConfig: 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() + .frame(width: 700) + }.sheet(item: $editingSession) { session in + FileSyncSessionModal(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: { + // You can't have styled text in alert messages + Text(""" + File sync daemon failed: \(fileSync.state.description)\n\n\(fileSync.recentLogs.joined(separator: "\n")) + """) + }.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() - .frame(width: 700) - }.sheet(item: $editingSession) { session in - FileSyncSessionModal(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) } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift index b3fa74e2..207f0d96 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift @@ -68,11 +68,12 @@ struct VPNMenu: 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") } diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index cad7eaca..f43a9a5a 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -33,13 +33,13 @@ class MockFileSyncDaemon: FileSyncDaemon { func refreshSessions() async {} + var recentLogs: [String] = [] + func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} var state: VPNLib.DaemonState = .running - func start() async throws(VPNLib.DaemonError) { - return - } + func tryStart() async {} func stop() async {} diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index 2adce4b2..b457f532 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -10,7 +10,8 @@ import SwiftUI public protocol FileSyncDaemon: ObservableObject { var state: DaemonState { get } var sessionState: [FileSyncSession] { get } - func start() async throws(DaemonError) + var recentLogs: [String] { get } + func tryStart() async func stop() async func refreshSessions() async func createSession(localPath: String, agentHost: String, remotePath: String) async throws(DaemonError) @@ -38,6 +39,10 @@ public class MutagenDaemon: FileSyncDaemon { @Published public var sessionState: [FileSyncSession] = [] + // We store the last N log lines to show in the UI if the daemon crashes + private var logBuffer: RingBuffer + public var recentLogs: [String] { logBuffer.elements } + private var mutagenProcess: Subprocess? private let mutagenPath: URL! private let mutagenDataDirectory: URL @@ -50,6 +55,7 @@ public class MutagenDaemon: FileSyncDaemon { 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) @@ -58,8 +64,10 @@ public class MutagenDaemon: FileSyncDaemon { mutagenDataDirectory: URL = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask - ).first!.appending(path: "Coder Desktop").appending(path: "Mutagen")) + ).first!.appending(path: "Coder Desktop").appending(path: "Mutagen"), + logBufferCapacity: Int = 10) { + logBuffer = .init(capacity: logBufferCapacity) self.mutagenPath = mutagenPath self.mutagenDataDirectory = mutagenDataDirectory mutagenDaemonSocket = mutagenDataDirectory.appending(path: "daemon").appending(path: "daemon.sock") @@ -87,13 +95,31 @@ 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 + if let waitForExit { + await waitForExit() + // We *need* to be sure the process is dead or the app ends up in an + // unrecoverable state + try? await Task.sleep(for: .seconds(1)) + } + await transition.wait() defer { transition.signal() } logger.info("starting mutagen daemon") @@ -106,6 +132,7 @@ public class MutagenDaemon: FileSyncDaemon { } catch { throw .daemonStartFailure(error) } + self.waitForExit = waitForExit Task { await streamHandler(io: standardOutput) @@ -259,6 +286,7 @@ public class MutagenDaemon: FileSyncDaemon { private func streamHandler(io: Pipe.AsyncBytes) async { for await line in io.lines { logger.info("\(line, privacy: .public)") + logBuffer.append(line) } } } @@ -282,7 +310,7 @@ public enum DaemonState { case .stopped: "Stopped" case let .failed(error): - "Failed: \(error)" + "\(error.description)" case .unavailable: "Unavailable" } @@ -300,6 +328,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 { diff --git a/Coder-Desktop/VPNLib/Util.swift b/Coder-Desktop/VPNLib/Util.swift index fd9bbc3f..9f39efe0 100644 --- a/Coder-Desktop/VPNLib/Util.swift +++ b/Coder-Desktop/VPNLib/Util.swift @@ -29,3 +29,39 @@ public func makeNSError(suffix: String, code: Int = -1, desc: String) -> NSError userInfo: [NSLocalizedDescriptionKey: desc] ) } + +// Insertion-only RingBuffer for buffering the last `capacity` elements, +// and retrieving them in insertion order. +public struct RingBuffer { + private var buffer: [T?] + private var start = 0 + private var size = 0 + + public init(capacity: Int) { + buffer = Array(repeating: nil, count: capacity) + } + + public mutating func append(_ element: T) { + let writeIndex = (start + size) % buffer.count + buffer[writeIndex] = element + + if size < buffer.count { + size += 1 + } else { + start = (start + 1) % buffer.count + } + } + + public var elements: [T] { + var result = [T]() + result.reserveCapacity(size) + for i in 0 ..< size { + let index = (start + i) % buffer.count + if let element = buffer[index] { + result.append(element) + } + } + + return result + } +} diff --git a/Coder-Desktop/VPNLibTests/UtilTests.swift b/Coder-Desktop/VPNLibTests/UtilTests.swift new file mode 100644 index 00000000..53f0950b --- /dev/null +++ b/Coder-Desktop/VPNLibTests/UtilTests.swift @@ -0,0 +1,91 @@ +import Testing +@testable import VPNLib + +@Suite() +struct RingBufferTests { + @Test + func belowCapacity() { + var buffer = RingBuffer(capacity: 3) + + buffer.append(1) + buffer.append(2) + + #expect(buffer.elements.count == 2) + #expect(buffer.elements == [1, 2]) + } + + @Test + func toCapacity() { + var buffer = RingBuffer(capacity: 3) + + buffer.append(1) + buffer.append(2) + buffer.append(3) + + #expect(buffer.elements.count == 3) + #expect(buffer.elements == [1, 2, 3]) + } + + @Test + func pastCapacity() { + var buffer = RingBuffer(capacity: 3) + + buffer.append(1) + buffer.append(2) + buffer.append(3) + buffer.append(4) + buffer.append(5) + + #expect(buffer.elements.count == 3) + #expect(buffer.elements == [3, 4, 5]) + } + + @Test + func singleCapacity() { + var buffer = RingBuffer(capacity: 1) + + buffer.append(1) + #expect(buffer.elements == [1]) + + buffer.append(2) + #expect(buffer.elements == [2]) + + buffer.append(3) + #expect(buffer.elements == [3]) + } + + @Test + func replaceAll() { + var buffer = RingBuffer(capacity: 3) + + buffer.append(1) + buffer.append(2) + buffer.append(3) + + buffer.append(4) + buffer.append(5) + buffer.append(6) + + #expect(buffer.elements.count == 3) + #expect(buffer.elements == [4, 5, 6]) + } + + @Test + func replacePartial() { + var buffer = RingBuffer(capacity: 3) + + buffer.append(1) + buffer.append(2) + buffer.append(3) + + buffer.append(4) + buffer.append(5) + + #expect(buffer.elements == [3, 4, 5]) + + buffer.append(6) + buffer.append(7) + + #expect(buffer.elements == [5, 6, 7]) + } +} From 4537dbc6d2ed8c3e3640e7f9ac86df6b2d3bce6a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Fri, 28 Mar 2025 13:41:46 +1100 Subject: [PATCH 2/2] use a log file for daemon --- .../Preview Content/PreviewFileSync.swift | 4 +- .../Views/FileSync/FileSyncConfig.swift | 8 +- Coder-Desktop/Coder-DesktopTests/Util.swift | 4 +- .../VPNLib/FileSync/FileSyncDaemon.swift | 55 ++++++----- Coder-Desktop/VPNLib/Util.swift | 36 -------- Coder-Desktop/VPNLibTests/UtilTests.swift | 91 ------------------- 6 files changed, 39 insertions(+), 159 deletions(-) delete mode 100644 Coder-Desktop/VPNLibTests/UtilTests.swift diff --git a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift index af24c086..608b3684 100644 --- a/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift +++ b/Coder-Desktop/Coder-Desktop/Preview Content/PreviewFileSync.swift @@ -2,12 +2,12 @@ import VPNLib @MainActor final class PreviewFileSync: FileSyncDaemon { + var logFile: URL = .init(filePath: "~/log.txt")! + var sessionState: [VPNLib.FileSyncSession] = [] var state: DaemonState = .running - var recentLogs: [String] = [] - init() {} func refreshSessions() async {} diff --git a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift index 4d144ea6..ff4fbe1a 100644 --- a/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift +++ b/Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift @@ -86,10 +86,12 @@ struct FileSyncConfig: View { dontRetry = true } } message: { - // You can't have styled text in alert messages Text(""" - File sync daemon failed: \(fileSync.state.description)\n\n\(fileSync.recentLogs.joined(separator: "\n")) - """) + 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. diff --git a/Coder-Desktop/Coder-DesktopTests/Util.swift b/Coder-Desktop/Coder-DesktopTests/Util.swift index f43a9a5a..bfae5167 100644 --- a/Coder-Desktop/Coder-DesktopTests/Util.swift +++ b/Coder-Desktop/Coder-DesktopTests/Util.swift @@ -29,12 +29,12 @@ class MockVPNService: VPNService, ObservableObject { @MainActor class MockFileSyncDaemon: FileSyncDaemon { + var logFile: URL = .init(filePath: "~/log.txt") + var sessionState: [VPNLib.FileSyncSession] = [] func refreshSessions() async {} - var recentLogs: [String] = [] - func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} var state: VPNLib.DaemonState = .running diff --git a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift index b457f532..1bac93cb 100644 --- a/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift +++ b/Coder-Desktop/VPNLib/FileSync/FileSyncDaemon.swift @@ -10,7 +10,7 @@ import SwiftUI public protocol FileSyncDaemon: ObservableObject { var state: DaemonState { get } var sessionState: [FileSyncSession] { get } - var recentLogs: [String] { get } + var logFile: URL { get } func tryStart() async func stop() async func refreshSessions() async @@ -39,15 +39,13 @@ public class MutagenDaemon: FileSyncDaemon { @Published public var sessionState: [FileSyncSession] = [] - // We store the last N log lines to show in the UI if the daemon crashes - private var logBuffer: RingBuffer - public var recentLogs: [String] { logBuffer.elements } - private var mutagenProcess: Subprocess? private let mutagenPath: URL! 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) @@ -64,13 +62,12 @@ public class MutagenDaemon: FileSyncDaemon { mutagenDataDirectory: URL = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask - ).first!.appending(path: "Coder Desktop").appending(path: "Mutagen"), - logBufferCapacity: Int = 10) + ).first!.appending(path: "Coder Desktop").appending(path: "Mutagen")) { - logBuffer = .init(capacity: logBufferCapacity) 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 { @@ -113,34 +110,23 @@ public class MutagenDaemon: FileSyncDaemon { // Creating the same process twice from Swift will crash the MainActor, // so we need to wait for an earlier process to die - if let waitForExit { - await waitForExit() - // We *need* to be sure the process is dead or the app ends up in an - // unrecoverable state - try? await Task.sleep(for: .seconds(1)) - } + 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") } @@ -283,11 +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)") - logBuffer.append(line) + + do { + try fileHandle.write(contentsOf: Data("\(line)\n".utf8)) + } catch { + logger.error("Failed to write to daemon log file: \(error)") + } } + + try? fileHandle.close() } } diff --git a/Coder-Desktop/VPNLib/Util.swift b/Coder-Desktop/VPNLib/Util.swift index 9f39efe0..fd9bbc3f 100644 --- a/Coder-Desktop/VPNLib/Util.swift +++ b/Coder-Desktop/VPNLib/Util.swift @@ -29,39 +29,3 @@ public func makeNSError(suffix: String, code: Int = -1, desc: String) -> NSError userInfo: [NSLocalizedDescriptionKey: desc] ) } - -// Insertion-only RingBuffer for buffering the last `capacity` elements, -// and retrieving them in insertion order. -public struct RingBuffer { - private var buffer: [T?] - private var start = 0 - private var size = 0 - - public init(capacity: Int) { - buffer = Array(repeating: nil, count: capacity) - } - - public mutating func append(_ element: T) { - let writeIndex = (start + size) % buffer.count - buffer[writeIndex] = element - - if size < buffer.count { - size += 1 - } else { - start = (start + 1) % buffer.count - } - } - - public var elements: [T] { - var result = [T]() - result.reserveCapacity(size) - for i in 0 ..< size { - let index = (start + i) % buffer.count - if let element = buffer[index] { - result.append(element) - } - } - - return result - } -} diff --git a/Coder-Desktop/VPNLibTests/UtilTests.swift b/Coder-Desktop/VPNLibTests/UtilTests.swift deleted file mode 100644 index 53f0950b..00000000 --- a/Coder-Desktop/VPNLibTests/UtilTests.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Testing -@testable import VPNLib - -@Suite() -struct RingBufferTests { - @Test - func belowCapacity() { - var buffer = RingBuffer(capacity: 3) - - buffer.append(1) - buffer.append(2) - - #expect(buffer.elements.count == 2) - #expect(buffer.elements == [1, 2]) - } - - @Test - func toCapacity() { - var buffer = RingBuffer(capacity: 3) - - buffer.append(1) - buffer.append(2) - buffer.append(3) - - #expect(buffer.elements.count == 3) - #expect(buffer.elements == [1, 2, 3]) - } - - @Test - func pastCapacity() { - var buffer = RingBuffer(capacity: 3) - - buffer.append(1) - buffer.append(2) - buffer.append(3) - buffer.append(4) - buffer.append(5) - - #expect(buffer.elements.count == 3) - #expect(buffer.elements == [3, 4, 5]) - } - - @Test - func singleCapacity() { - var buffer = RingBuffer(capacity: 1) - - buffer.append(1) - #expect(buffer.elements == [1]) - - buffer.append(2) - #expect(buffer.elements == [2]) - - buffer.append(3) - #expect(buffer.elements == [3]) - } - - @Test - func replaceAll() { - var buffer = RingBuffer(capacity: 3) - - buffer.append(1) - buffer.append(2) - buffer.append(3) - - buffer.append(4) - buffer.append(5) - buffer.append(6) - - #expect(buffer.elements.count == 3) - #expect(buffer.elements == [4, 5, 6]) - } - - @Test - func replacePartial() { - var buffer = RingBuffer(capacity: 3) - - buffer.append(1) - buffer.append(2) - buffer.append(3) - - buffer.append(4) - buffer.append(5) - - #expect(buffer.elements == [3, 4, 5]) - - buffer.append(6) - buffer.append(7) - - #expect(buffer.elements == [5, 6, 7]) - } -}