From 0a01a4430c307d40548536882e74f5748aa040a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tor=20Arne=20Vestb=C3=B8?= Date: Wed, 10 Jun 2026 00:29:19 +0200 Subject: [PATCH] Fix build warnings (#1262) * Use let for the immutable disk image storage attachment * Don't bind the unused error when catching connection-pool failures * Report errors thrown inside tart run's fire-and-forget tasks We were discarding any error thrown inside these unstructured tasks, which silently hid failures to run the control socket or to start and stop the VM, and which the compiler now warns about. Wrap them in an ErrorReportingTask, which spawns the task and reports any thrown error to stderr, rather than repeating a do/catch at every call site. An unstructured task spawned from a synchronous context (a signal handler or SwiftUI action) has no parent to propagate the error to, so reporting it is the best we can do. * Avoid blocking SwiftNIO calls in async guest agent connections The gRPC channel setup in "tart exec" and the MAC address resolver created a dedicated event loop group and tore both it and the channel down with the blocking syncShutdownGracefully() and wait(), which are unavailable from async contexts (the former is an error in the Swift 6 language mode). Factor the connection out into a withGuestAgentChannel() helper that uses the process-wide singleton event loop group, so there is no group to shut down, and closes the channel with the async close().get(). --- Sources/tart/Commands/Exec.swift | 21 +++--------- Sources/tart/Commands/Run.swift | 10 +++--- Sources/tart/GuestAgentChannel.swift | 28 +++++++++++++++ .../MACAddressResolver/AgentResolver.swift | 34 ++++++------------- Sources/tart/Utils.swift | 18 ++++++++++ Sources/tart/VM.swift | 2 +- 6 files changed, 66 insertions(+), 47 deletions(-) create mode 100644 Sources/tart/GuestAgentChannel.swift 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]