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().
This commit is contained in:
Tor Arne Vestbø 2026-06-10 00:29:19 +02:00 committed by GitHub
parent d1bfda63fc
commit 0a01a4430c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 66 additions and 47 deletions

View File

@ -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?")
}

View File

@ -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) {

View File

@ -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<T>(
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
}
}

View File

@ -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)
}
}

View File

@ -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<Void, Never>
@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

View File

@ -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]