diff --git a/Sources/tart/Commands/Exec.swift b/Sources/tart/Commands/Exec.swift index 88f1d13..daa9504 100644 --- a/Sources/tart/Commands/Exec.swift +++ b/Sources/tart/Commands/Exec.swift @@ -1,6 +1,5 @@ import ArgumentParser import Foundation -import NIOPosix import GRPC import Cirruslabs_TartGuestAgent_Grpc_Swift @@ -41,12 +40,6 @@ struct Exec: AsyncParsableCommand { throw RuntimeError.VMNotRunning(name) } - // Create a gRPC channel connected to the VM's control socket - let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - defer { - try! group.syncShutdownGracefully() - } - // Change the current working directory to a VM's base directory // to work around Unix domain socket 104 byte limitation [1] // @@ -55,15 +48,6 @@ struct Exec: AsyncParsableCommand { FileManager.default.changeCurrentDirectoryPath(baseURL.path()) } - let channel = try GRPCChannelPool.with( - target: .unixDomainSocket(vmDir.controlSocketURL.relativePath), - transportSecurity: .plaintext, - eventLoopGroup: group, - ) - defer { - try! channel.close().wait() - } - // Switch controlling terminal into raw mode when remote pseudo-terminal is requested var state: State? = nil @@ -79,7 +63,10 @@ struct Exec: AsyncParsableCommand { // Execute a command in a running VM do { - try await execute(channel) + let controlSocketPath = vmDir.controlSocketURL.relativePath + try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in + try await execute(channel) + } } catch let error as GRPCConnectionPoolError { throw RuntimeError.Generic("Failed to connect to the VM using its control socket: \(error.localizedDescription), is the Tart Guest Agent running?") } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 013b80d..9788db1 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -558,7 +558,7 @@ struct Run: AsyncParsableCommand { } if #available(macOS 14, *) { - Task { + ErrorReportingTask("Failed to run control socket") { try await ControlSocket(vmDir.controlSocketURL).run() } } @@ -630,7 +630,7 @@ struct Run: AsyncParsableCommand { signal(SIGUSR2, SIG_IGN) let sigusr2Src = DispatchSource.makeSignalSource(signal: SIGUSR2) sigusr2Src.setEventHandler { - Task { + ErrorReportingTask("Failed to request guest OS to stop") { print("Requesting guest OS to stop...") try vm!.virtualMachine.requestStop() } @@ -847,13 +847,13 @@ struct MainApp: App { CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) } CommandMenu("Control") { Button("Start") { - Task { try await vm!.virtualMachine.start() } + ErrorReportingTask("Failed to start VM") { try await vm!.virtualMachine.start() } } Button("Stop") { - Task { try await vm!.virtualMachine.stop() } + ErrorReportingTask("Failed to stop VM") { try await vm!.virtualMachine.stop() } } Button("Request Stop") { - Task { try vm!.virtualMachine.requestStop() } + ErrorReportingTask("Failed to request VM stop") { try vm!.virtualMachine.requestStop() } } if #available(macOS 14, *) { if (MainApp.suspendable) { diff --git a/Sources/tart/GuestAgentChannel.swift b/Sources/tart/GuestAgentChannel.swift new file mode 100644 index 0000000..f754f2f --- /dev/null +++ b/Sources/tart/GuestAgentChannel.swift @@ -0,0 +1,28 @@ +import GRPC +import NIOPosix + +/// Connects to a guest agent's gRPC endpoint over a VM's control socket, runs +/// `body` with the resulting channel, and closes the channel afterwards on both +/// the success and error paths. +/// +/// The connection uses the process-wide singleton event loop group, which must +/// not be shut down, so there is no group lifecycle to manage here. +func withGuestAgentChannel( + unixDomainSocketPath socketPath: String, + _ body: (GRPCChannel) async throws -> T +) async throws -> T { + let channel = try GRPCChannelPool.with( + target: .unixDomainSocket(socketPath), + transportSecurity: .plaintext, + eventLoopGroup: .singletonMultiThreadedEventLoopGroup, + ) + + do { + let result = try await body(channel) + try await channel.close().get() + return result + } catch { + try? await channel.close().get() + throw error + } +} diff --git a/Sources/tart/MACAddressResolver/AgentResolver.swift b/Sources/tart/MACAddressResolver/AgentResolver.swift index 2978ce9..a45739b 100644 --- a/Sources/tart/MACAddressResolver/AgentResolver.swift +++ b/Sources/tart/MACAddressResolver/AgentResolver.swift @@ -1,6 +1,5 @@ import Foundation import Network -import NIOPosix import GRPC import Cirruslabs_TartGuestAgent_Apple_Swift import Cirruslabs_TartGuestAgent_Grpc_Swift @@ -9,34 +8,21 @@ class AgentResolver { static func ResolveIP(_ controlSocketPath: String) async throws -> IPv4Address? { do { return try await resolveIP(controlSocketPath) - } catch let error as GRPCConnectionPoolError { + } catch is GRPCConnectionPoolError { return nil } } private static func resolveIP(_ controlSocketPath: String) async throws -> IPv4Address? { - // Create a gRPC channel connected to the VM's control socket - let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - defer { - try! group.syncShutdownGracefully() + try await withGuestAgentChannel(unixDomainSocketPath: controlSocketPath) { channel in + // Invoke ResolveIP() gRPC method + let callOptions = CallOptions(timeLimit: .timeout(.seconds(1))) + let agentAsyncClient = AgentAsyncClient(channel: channel) + let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions) + + let response = try await resolveIPCall.response + + return IPv4Address(response.ip) } - - let channel = try GRPCChannelPool.with( - target: .unixDomainSocket(controlSocketPath), - transportSecurity: .plaintext, - eventLoopGroup: group, - ) - defer { - try! channel.close().wait() - } - - // Invoke ResolveIP() gRPC method - let callOptions = CallOptions(timeLimit: .timeout(.seconds(1))) - let agentAsyncClient = AgentAsyncClient(channel: channel) - let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions) - - let response = try await resolveIPCall.response - - return IPv4Address(response.ip) } } diff --git a/Sources/tart/Utils.swift b/Sources/tart/Utils.swift index ddb54bb..1f2dc70 100644 --- a/Sources/tart/Utils.swift +++ b/Sources/tart/Utils.swift @@ -1,5 +1,23 @@ import Foundation +// A fire-and-forget task that reports any thrown error to stderr. An unstructured +// Task spawned from a synchronous context (a signal handler, a SwiftUI action) has +// no parent to propagate its error to, so we report it here instead of dropping it. +struct ErrorReportingTask { + let task: Task + + @discardableResult + init(_ context: String, operation: @escaping @Sendable () async throws -> Void) { + task = Task { + do { + try await operation() + } catch { + fputs("\(context): \(error)\n", stderr) + } + } + } +} + extension Collection { subscript (safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 1e6a6cb..77ef457 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -404,7 +404,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { } // Storage - var attachment = try VZDiskImageStorageDeviceAttachment( + let attachment = try VZDiskImageStorageDeviceAttachment( url: diskURL, readOnly: false, // When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]