tstest/tailmac: add NIC hot-swap, disconnected NIC, and screenshot server

Add NIC attachment hot-swap support to Host.app: VZNetworkDevice.attachment
is writable at runtime, so --disconnected-nic creates a NIC with no
attachment, and --attach-network hot-swaps it to a vnet dgram socket
after boot/restore. macOS detects link-up and does DHCP.

Refactor TailMacConfigHelper: extract createDgramAttachment() and
createDisconnectedNetworkDeviceConfiguration() from the monolithic
createSocketNetworkDeviceConfiguration().

Add --screenshot-port flag for headless mode. Host.app serves GET
/screenshot as JPEG via a localhost HTTP server, capturing the
VZVirtualMachineView via CGWindowListCreateImage. The Go test harness
polls these to push live thumbnails to the web dashboard.

Also: SIGINT handler in headless mode for clean VM state save.

Updates #13038

Change-Id: I42fba0ecd760371b4ec5b26a0557e3dd0ba9ecae
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick 2026-04-27 15:02:33 -07:00 committed by Brad Fitzpatrick
parent 5c1738fd56
commit c0e6ffed0d
3 changed files with 294 additions and 20 deletions

View File

@ -74,18 +74,31 @@ struct TailMacConfigHelper {
return networkDevice
}
/// Creates a NIC configuration connected to the vnet dgram socket.
func createSocketNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration {
let networkDevice = VZVirtioNetworkDeviceConfiguration()
networkDevice.macAddress = VZMACAddress(string: config.mac)!
if let attachment = createDgramAttachment(serverSocket: config.serverSocket, clientID: config.vmID) {
networkDevice.attachment = attachment
}
return networkDevice
}
/// Creates a NIC configuration with no attachment (disconnected).
/// The attachment can be hot-swapped later via VZNetworkDevice.attachment.
func createDisconnectedNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration {
let networkDevice = VZVirtioNetworkDeviceConfiguration()
networkDevice.macAddress = VZMACAddress(string: config.mac)!
// No attachment NIC appears disconnected to the guest.
return networkDevice
}
/// Creates a dgram socket attachment for connecting to a vnet server.
/// Returns nil on error.
func createDgramAttachment(serverSocket: String, clientID: String) -> VZFileHandleNetworkDeviceAttachment? {
let socket = Darwin.socket(AF_UNIX, SOCK_DGRAM, 0)
// Outbound network packets
let serverSocket = config.serverSocket
// Inbound network packets bind a client socket so the server can reply.
let clientSockId = config.vmID
let clientSocket = "/tmp/qemu-dgram-\(clientSockId).sock"
let clientSocket = "/tmp/qemu-dgram-\(clientID).sock"
unlink(clientSocket)
var clientAddr = sockaddr_un()
@ -102,7 +115,7 @@ struct TailMacConfigHelper {
if bindRes == -1 {
print("Error binding virtual network client socket - \(String(cString: strerror(errno)))")
return networkDevice
return nil
}
var serverAddr = sockaddr_un()
@ -119,18 +132,15 @@ struct TailMacConfigHelper {
if connectRes == -1 {
print("Error connecting to server socket \(serverSocket) - \(String(cString: strerror(errno)))")
return networkDevice
return nil
}
print("Virtual if mac address is \(config.mac)")
print("Client bound to \(clientSocket)")
print("Connected to server at \(serverSocket)")
print("Socket fd is \(socket)")
let handle = FileHandle(fileDescriptor: socket)
let device = VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
networkDevice.attachment = device
return networkDevice
return VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
}
func createPointingDeviceConfiguration() -> VZPointingDeviceConfiguration {

View File

@ -21,6 +21,9 @@ extension HostCli {
@Option var id: String
@Option var share: String?
@Flag(help: "Run without GUI (for automated testing)") var headless: Bool = false
@Flag(help: "Create NIC with no attachment (for later hot-swap)") var disconnectedNic: Bool = false
@Option(help: "Hot-swap NIC to this dgram socket path after boot/restore") var attachNetwork: String?
@Option(help: "Serve screenshots on this localhost port (0 = auto)") var screenshotPort: Int?
mutating func run() {
config = Config(id)
@ -28,20 +31,84 @@ extension HostCli {
print("Running vm with identifier \(id) and sharedDir \(share ?? "<none>")")
if headless {
let attachSocket = attachNetwork
let disconnected = disconnectedNic || attachSocket != nil
let wantScreenshots = screenshotPort != nil
let requestedPort = UInt16(screenshotPort ?? 0)
DispatchQueue.main.async {
let controller = VMController()
controller.createVirtualMachine(headless: true)
controller.createVirtualMachine(headless: true, disconnectedNIC: disconnected)
// Handle SIGINT (from test cleanup) by saving VM state before exit.
let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
signal(SIGINT, SIG_IGN) // Let DispatchSource handle it
sigintSource.setEventHandler {
print("SIGINT received, saving VM state...")
controller.pauseAndSaveVirtualMachine {
print("VM state saved, exiting.")
Foundation.exit(0)
}
}
sigintSource.resume()
// Set up screenshot HTTP server if requested.
// The window must be ordered on-screen for the window server
// to composite VZVirtualMachineView's content. We place it
// behind all other windows and make it tiny (1x1) so it's
// effectively invisible.
if wantScreenshots {
let vmView = VZVirtualMachineView()
vmView.virtualMachine = controller.virtualMachine
vmView.frame = NSRect(x: 0, y: 0, width: 1920, height: 1200)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1920, height: 1200),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.contentView = vmView
// Place behind all other windows so it's not visible to the user.
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.minimumWindow)) - 1)
window.orderFront(nil)
startScreenshotServer(view: vmView, port: requestedPort)
}
let doAttach = {
if let sock = attachSocket {
// Give macOS a moment to settle after boot/restore,
// then hot-swap the NIC attachment.
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
controller.attachNetwork(serverSocket: sock, clientID: config.vmID)
}
}
}
let fileManager = FileManager.default
if fileManager.fileExists(atPath: config.saveFileURL.path) {
print("Restoring virtual machine state from \(config.saveFileURL)")
controller.restoreVirtualMachine()
doAttach()
} else {
print("Starting virtual machine")
controller.startVirtualMachine()
doAttach()
}
}
RunLoop.main.run()
if wantScreenshots {
// NSApp event loop needed for VZVirtualMachineView rendering.
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
print("STARTING_NSAPP")
fflush(stdout)
app.run()
} else {
RunLoop.main.run()
}
} else {
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
}
@ -49,3 +116,181 @@ extension HostCli {
}
}
// startScreenshotServer starts a localhost HTTP server that serves VM display
// screenshots on GET /screenshot as JPEG. The port is printed to stdout as
// "SCREENSHOT_PORT=<port>" so the Go test harness can discover it.
var screenshotServer: ScreenshotHTTPServer? // prevent GC
func startScreenshotServer(view: NSView, port: UInt16) {
let server = ScreenshotHTTPServer(view: view)
screenshotServer = server
server.start(port: port)
}
/// Minimal HTTP server that serves screenshots of a VZVirtualMachineView.
class ScreenshotHTTPServer: NSObject {
let view: NSView
var acceptSource: DispatchSourceRead? // prevent GC
init(view: NSView) {
self.view = view
}
private func log(_ msg: String) {
let s = msg + "\n"
FileHandle.standardError.write(Data(s.utf8))
}
func start(port: UInt16) {
let queue = DispatchQueue(label: "screenshot-server")
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else {
log("screenshot server: socket() failed")
return
}
var yes: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = port.bigEndian
addr.sin_addr.s_addr = UInt32(0x7f000001).bigEndian // 127.0.0.1
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
Darwin.bind(fd, sockPtr, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bindResult == 0 else {
log("screenshot server: bind() failed: \(errno)")
close(fd)
return
}
guard Darwin.listen(fd, 4) == 0 else {
log("screenshot server: listen() failed")
close(fd)
return
}
var boundAddr = sockaddr_in()
var boundLen = socklen_t(MemoryLayout<sockaddr_in>.size)
withUnsafeMutablePointer(to: &boundAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
getsockname(fd, sockPtr, &boundLen)
}
}
let actualPort = UInt16(bigEndian: boundAddr.sin_port)
print("SCREENSHOT_PORT=\(actualPort)")
fflush(stdout)
let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue)
source.setEventHandler { [self] in
let clientFd = accept(fd, nil, nil)
self.log("screenshot: accept fd=\(clientFd)")
guard clientFd >= 0 else { return }
self.handleConnection(clientFd)
}
source.setCancelHandler { close(fd) }
source.resume()
self.acceptSource = source
}
private func handleConnection(_ fd: Int32) {
var buf = [UInt8](repeating: 0, count: 4096)
let n = read(fd, &buf, buf.count)
let requestLine = n > 0 ? String(bytes: buf[..<n], encoding: .utf8) ?? "" : ""
// Route: POST /keypress?key=<keycode> send a key event to the VM.
if requestLine.contains("/keypress") {
handleKeypress(fd, requestLine)
return
}
// Route: GET /screenshot capture the VM display.
let wantFull = requestLine.contains("full=1")
let sem = DispatchSemaphore(value: 0)
var jpegData: Data?
DispatchQueue.main.async { [self] in
jpegData = self.captureScreenshot(fullSize: wantFull)
sem.signal()
}
sem.wait()
guard let data = jpegData else {
let resp = Data("HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n".utf8)
resp.withUnsafeBytes { write(fd, $0.baseAddress!, resp.count) }
close(fd)
return
}
var response = Data("HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: \(data.count)\r\nConnection: close\r\n\r\n".utf8)
response.append(data)
response.withUnsafeBytes { ptr in
var total = 0
while total < response.count {
let n = write(fd, ptr.baseAddress! + total, response.count - total)
if n <= 0 { break }
total += n
}
}
close(fd)
log("screenshot: served \(data.count) bytes")
}
private func captureScreenshot(fullSize: Bool = false) -> Data? {
guard let window = view.window else {
log("screenshot: no window")
return nil
}
// Use CGWindowListCreateImage to capture the composited window content,
// which includes GPU-rendered layers like VZVirtualMachineView's Metal surface.
let windowID = CGWindowID(window.windowNumber)
guard let cgImage = CGWindowListCreateImage(
.null,
.optionIncludingWindow,
windowID,
[.boundsIgnoreFraming, .bestResolution]
) else {
log("screenshot: CGWindowListCreateImage returned nil")
return nil
}
if fullSize {
let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
return bitmapRep.representation(using: .jpeg, properties: [.compressionFactor: 0.85])
}
// Resize to ~800px wide for thumbnails.
let targetWidth = 800
let scale = Double(targetWidth) / Double(cgImage.width)
let targetHeight = Int(Double(cgImage.height) * scale)
guard let ctx = CGContext(
data: nil,
width: targetWidth,
height: targetHeight,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
) else {
log("screenshot: CGContext creation failed")
return nil
}
ctx.interpolationQuality = .high
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight))
guard let resized = ctx.makeImage() else {
log("screenshot: makeImage failed")
return nil
}
let bitmapRep = NSBitmapImageRep(cgImage: resized)
return bitmapRep.representation(using: .jpeg, properties: [.compressionFactor: 0.6])
}
}

View File

@ -81,7 +81,7 @@ class VMController: NSObject, VZVirtualMachineDelegate {
return macPlatform
}
func createVirtualMachine(headless: Bool = false) {
func createVirtualMachine(headless: Bool = false, disconnectedNIC: Bool = false) {
let virtualMachineConfiguration = VZVirtualMachineConfiguration()
virtualMachineConfiguration.platform = createMacPlaform()
@ -91,11 +91,14 @@ class VMController: NSObject, VZVirtualMachineDelegate {
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()]
if headless {
// In headless mode, use only the socket-based NIC. This matches
// the single-NIC configuration used when creating the base VM.
// Using a different NIC count would make saved state restoration
// fail silently.
virtualMachineConfiguration.networkDevices = [helper.createSocketNetworkDeviceConfiguration()]
if disconnectedNIC {
// Create a NIC with no attachment. The NIC exists in the hardware
// config (so saved state is compatible) but appears disconnected.
// Call attachNetwork() after restore to hot-swap the attachment.
virtualMachineConfiguration.networkDevices = [helper.createDisconnectedNetworkDeviceConfiguration()]
} else {
virtualMachineConfiguration.networkDevices = [helper.createSocketNetworkDeviceConfiguration()]
}
} else {
virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()]
}
@ -117,6 +120,22 @@ class VMController: NSObject, VZVirtualMachineDelegate {
virtualMachine.delegate = self
}
/// Hot-swap the NIC attachment on a running VM. The VM must have been
/// created with disconnectedNIC=true. After calling this, the guest
/// sees the link come up and does DHCP.
func attachNetwork(serverSocket: String, clientID: String) {
guard let nic = virtualMachine.networkDevices.first else {
print("attachNetwork: no network devices")
return
}
guard let attachment = helper.createDgramAttachment(serverSocket: serverSocket, clientID: clientID) else {
print("attachNetwork: failed to create attachment")
return
}
nic.attachment = attachment
print("attachNetwork: NIC attachment swapped to \(serverSocket)")
}
func startVirtualMachine() {
virtualMachine.start(completionHandler: { (result) in