Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dist/

# mkdocs-material
site

# Python cache files
*.pyc
103 changes: 102 additions & 1 deletion Sources/tart/Commands/Get.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import ArgumentParser
import Foundation
import CoreGraphics
import Darwin

fileprivate struct VMInfo: Encodable {
let OS: OS
Expand All @@ -11,6 +13,29 @@ fileprivate struct VMInfo: Encodable {
let Display: String
let Running: Bool
let State: String
let NoGraphics: Bool?

enum CodingKeys: String, CodingKey {
case OS, CPU, Memory, Disk, DiskFormat, Size, Display, Running, State, NoGraphics
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(OS, forKey: .OS)
try container.encode(CPU, forKey: .CPU)
try container.encode(Memory, forKey: .Memory)
try container.encode(Disk, forKey: .Disk)
try container.encode(DiskFormat, forKey: .DiskFormat)
try container.encode(Size, forKey: .Size)
try container.encode(Display, forKey: .Display)
try container.encode(Running, forKey: .Running)
try container.encode(State, forKey: .State)
if let noGraphics = NoGraphics {
try container.encode(noGraphics, forKey: .NoGraphics)
} else {
try container.encodeNil(forKey: .NoGraphics)
}
}
}

struct Get: AsyncParsableCommand {
Expand All @@ -27,7 +52,83 @@ struct Get: AsyncParsableCommand {
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024

let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
// Check if VM is running without graphics (no windows)
var noGraphics: Bool? = nil
if try vmDir.running() {
let lock = try vmDir.lock()
let pid = try lock.pid()
if pid > 0 {
noGraphics = try hasNoWindows(pid: pid)
}
}

let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue, NoGraphics: noGraphics)
print(format.renderSingle(info))
}

private func hasNoWindows(pid: pid_t) throws -> Bool {
// Check if the process and its children have any windows using Core Graphics Window Server
// This is more reliable than checking command-line arguments since there are
// multiple ways a VM might run without graphics (--no-graphics flag, CI environment, etc.)

// Get all PIDs to check (parent + children)
var pidsToCheck = [pid]
pidsToCheck.append(contentsOf: try getChildProcesses(of: pid))

// Get all window information from the window server
guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
// If we can't get window info, assume no graphics
return true
}

// Check if any window belongs to our process or its children
for windowInfo in windowList {
if let windowPID = windowInfo[kCGWindowOwnerPID as String] as? Int32,
pidsToCheck.contains(windowPID) {
// Found a window for this process tree, so it has graphics
return false
}
}

// No windows found for this process tree
return true
}

private func getChildProcesses(of parentPID: pid_t) throws -> [pid_t] {
var children: [pid_t] = []

// Use sysctl to get process information
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
var size: size_t = 0

// Get size needed
if sysctl(&mib, 4, nil, &size, nil, 0) != 0 {
// If we can't get process list, return empty array
return children
}

// Allocate memory and get process list
let count = size / MemoryLayout<kinfo_proc>.size
var procs = Array<kinfo_proc>(repeating: kinfo_proc(), count: count)

if sysctl(&mib, 4, &procs, &size, nil, 0) != 0 {
// If we can't get process list, return empty array
return children
}

// Find direct children of the given parent PID
for proc in procs {
let ppid = proc.kp_eproc.e_ppid
let pid = proc.kp_proc.p_pid
if ppid == parentPID && pid > 0 {
children.append(pid)
// Recursively get children of children
if let grandchildren = try? getChildProcesses(of: pid) {
children.append(contentsOf: grandchildren)
}
}
}

return children
}
}
62 changes: 62 additions & 0 deletions integration-tests/test_no_graphics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import json
import os
import subprocess
import time
import uuid

import pytest


def check_json_format(tart, vm_name, running, noGraphics, expected):
stdout, _ = tart.run(["get", vm_name, "--format", "json"])
vm_info = json.loads(stdout)
actual_running = vm_info["Running"]
assert actual_running is running, f"Running is {actual_running}, expected {running}"
assert vm_info.get("NoGraphics") is noGraphics, expected

def check_text_format(tart, vm_name, running, noGraphics, expected):
stdout, _ = tart.run(["get", vm_name, "--format", "text"])
assert "NoGraphics" in stdout, "NoGraphics field should be present in text output"

# Text format is tab-separated with headers in first line
lines = stdout.strip().split('\n')
if len(lines) >= 2:
headers = lines[0].split()
values = lines[1].split()
info_dict = dict(zip(headers, values))
else:
info_dict = {}

# Convert "stopped" to false for Running field
actual_running = info_dict.get("State") != "stopped"
assert actual_running == running, f"Expected Running={running}, got State={actual_running}"
assert info_dict.get("NoGraphics") == noGraphics, expected

@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Normal graphics mode doesn't work in CI")
def test_no_graphics_normal(tart):
_test_no_graphics_impl(tart, [], False)

def test_no_graphics_disabled(tart):
_test_no_graphics_impl(tart, ["--no-graphics"], True)

def _test_no_graphics_impl(tart, graphics_mode, expected_no_graphics):
# Create a test VM (use Linux VM for faster tests)
vm_name = f"integration-test-no-graphics-{uuid.uuid4()}"
tart.run(["pull", "ghcr.io/cirruslabs/debian:latest"])
tart.run(["clone", "ghcr.io/cirruslabs/debian:latest", vm_name])

# Test 1: VM not running - NoGraphics should be None in json format
check_json_format(tart, vm_name, False, None, "NoGraphics should be None when VM is not running")

# Test 2: VM not running - NoGraphics should be NULL in text format
check_text_format(tart, vm_name, False, "NULL", "NoGraphics should be NULL when VM is not running")

# Run VM with specified graphics mode
tart_run_process = tart.run_async(["run"] + graphics_mode + [vm_name])
time.sleep(3) # Give VM time to start

# Test 3: VM running - NoGraphics should be XX in json format
check_json_format(tart, vm_name, True, expected_no_graphics, f"NoGraphics should be {expected_no_graphics} (JSON) when VM is running")

# Test 4: VM running - NoGraphics should be XX in text format
check_text_format(tart, vm_name, True, str(expected_no_graphics).lower(), f"NoGraphics should be {expected_no_graphics} (TEXT) when VM is running")