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
@@ -74,18 +74,31 @@ struct TailMacConfigHelper {
return networkDevice return networkDevice
} }
/// Creates a NIC configuration connected to the vnet dgram socket.
func createSocketNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration { func createSocketNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration {
let networkDevice = VZVirtioNetworkDeviceConfiguration() let networkDevice = VZVirtioNetworkDeviceConfiguration()
networkDevice.macAddress = VZMACAddress(string: config.mac)! 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) let socket = Darwin.socket(AF_UNIX, SOCK_DGRAM, 0)
// Outbound network packets let clientSocket = "/tmp/qemu-dgram-\(clientID).sock"
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"
unlink(clientSocket) unlink(clientSocket)
var clientAddr = sockaddr_un() var clientAddr = sockaddr_un()
@@ -102,7 +115,7 @@ struct TailMacConfigHelper {
if bindRes == -1 { if bindRes == -1 {
print("Error binding virtual network client socket - \(String(cString: strerror(errno)))") print("Error binding virtual network client socket - \(String(cString: strerror(errno)))")
return networkDevice return nil
} }
var serverAddr = sockaddr_un() var serverAddr = sockaddr_un()
@@ -119,18 +132,15 @@ struct TailMacConfigHelper {
if connectRes == -1 { if connectRes == -1 {
print("Error connecting to server socket \(serverSocket) - \(String(cString: strerror(errno)))") print("Error connecting to server socket \(serverSocket) - \(String(cString: strerror(errno)))")
return networkDevice return nil
} }
print("Virtual if mac address is \(config.mac)") print("Virtual if mac address is \(config.mac)")
print("Client bound to \(clientSocket)") print("Client bound to \(clientSocket)")
print("Connected to server at \(serverSocket)") print("Connected to server at \(serverSocket)")
print("Socket fd is \(socket)")
let handle = FileHandle(fileDescriptor: socket) let handle = FileHandle(fileDescriptor: socket)
let device = VZFileHandleNetworkDeviceAttachment(fileHandle: handle) return VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
networkDevice.attachment = device
return networkDevice
} }
func createPointingDeviceConfiguration() -> VZPointingDeviceConfiguration { func createPointingDeviceConfiguration() -> VZPointingDeviceConfiguration {
+247 -2
View File
@@ -21,6 +21,9 @@ extension HostCli {
@Option var id: String @Option var id: String
@Option var share: String? @Option var share: String?
@Flag(help: "Run without GUI (for automated testing)") var headless: Bool = false @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() { mutating func run() {
config = Config(id) config = Config(id)
@@ -28,20 +31,84 @@ extension HostCli {
print("Running vm with identifier \(id) and sharedDir \(share ?? "<none>")") print("Running vm with identifier \(id) and sharedDir \(share ?? "<none>")")
if headless { if headless {
let attachSocket = attachNetwork
let disconnected = disconnectedNic || attachSocket != nil
let wantScreenshots = screenshotPort != nil
let requestedPort = UInt16(screenshotPort ?? 0)
DispatchQueue.main.async { DispatchQueue.main.async {
let controller = VMController() 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 let fileManager = FileManager.default
if fileManager.fileExists(atPath: config.saveFileURL.path) { if fileManager.fileExists(atPath: config.saveFileURL.path) {
print("Restoring virtual machine state from \(config.saveFileURL)") print("Restoring virtual machine state from \(config.saveFileURL)")
controller.restoreVirtualMachine() controller.restoreVirtualMachine()
doAttach()
} else { } else {
print("Starting virtual machine") print("Starting virtual machine")
controller.startVirtualMachine() 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 { } else {
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) _ = 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])
}
}
+25 -6
View File
@@ -81,7 +81,7 @@ class VMController: NSObject, VZVirtualMachineDelegate {
return macPlatform return macPlatform
} }
func createVirtualMachine(headless: Bool = false) { func createVirtualMachine(headless: Bool = false, disconnectedNIC: Bool = false) {
let virtualMachineConfiguration = VZVirtualMachineConfiguration() let virtualMachineConfiguration = VZVirtualMachineConfiguration()
virtualMachineConfiguration.platform = createMacPlaform() virtualMachineConfiguration.platform = createMacPlaform()
@@ -91,11 +91,14 @@ class VMController: NSObject, VZVirtualMachineDelegate {
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()] virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()] virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()]
if headless { if headless {
// In headless mode, use only the socket-based NIC. This matches if disconnectedNIC {
// the single-NIC configuration used when creating the base VM. // Create a NIC with no attachment. The NIC exists in the hardware
// Using a different NIC count would make saved state restoration // config (so saved state is compatible) but appears disconnected.
// fail silently. // Call attachNetwork() after restore to hot-swap the attachment.
virtualMachineConfiguration.networkDevices = [helper.createSocketNetworkDeviceConfiguration()] virtualMachineConfiguration.networkDevices = [helper.createDisconnectedNetworkDeviceConfiguration()]
} else {
virtualMachineConfiguration.networkDevices = [helper.createSocketNetworkDeviceConfiguration()]
}
} else { } else {
virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()] virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()]
} }
@@ -117,6 +120,22 @@ class VMController: NSObject, VZVirtualMachineDelegate {
virtualMachine.delegate = self 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() { func startVirtualMachine() {
virtualMachine.start(completionHandler: { (result) in virtualMachine.start(completionHandler: { (result) in