Skip to content

Commit d2452fc

Browse files
fix: reject unsupported odd macOS CPU counts
1 parent 3ebccf6 commit d2452fc

6 files changed

Lines changed: 48 additions & 14 deletions

File tree

cmd/vm/clone.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error {
2828
if err = requireCNIVNCPassword(netMode == netCNI, vnc, vncPass); err != nil {
2929
return err
3030
}
31+
cpus := srcRec.CPUs
32+
if cmd.Flags().Changed("cpus") {
33+
cpus, _ = cmd.Flags().GetInt("cpus")
34+
}
35+
if err = validateMacOSCPUs(cpus); err != nil {
36+
return err
37+
}
3138
// SRC's disk names are reserved: extra --data-disk specs must not collide and the combined count still honors the AHCI cap
3239
reserved := make([]string, len(srcRec.DataDisks))
3340
for i, p := range srcRec.DataDisks {
@@ -53,13 +60,10 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error {
5360
}
5461
r := &record{
5562
Name: name, Image: srcRec.Image, ImageDigest: digest, Disk: overlay,
56-
OVMFCode: srcRec.OVMFCode, OVMFVars: ovmfVars, CPUs: srcRec.CPUs, Memory: srcRec.Memory,
63+
OVMFCode: srcRec.OVMFCode, OVMFVars: ovmfVars, CPUs: cpus, Memory: srcRec.Memory,
5764
DataDisks: append(copied, newDisks...),
5865
VMID: utils.GenerateID(), Created: time.Now().Format(time.RFC3339),
5966
}
60-
if cmd.Flags().Changed("cpus") {
61-
r.CPUs, _ = cmd.Flags().GetInt("cpus")
62-
}
6367
if cmd.Flags().Changed("memory") {
6468
r.Memory, _ = cmd.Flags().GetString("memory")
6569
}

cmd/vm/handler_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,34 @@ package vm
22

33
import (
44
"slices"
5+
"strings"
56
"testing"
67

78
"github.com/spf13/cobra"
89
)
910

11+
func TestValidateMacOSCPUs(t *testing.T) {
12+
for _, tt := range []struct {
13+
cpus int
14+
wantErr bool
15+
}{
16+
{cpus: -2, wantErr: true},
17+
{cpus: 0, wantErr: true},
18+
{cpus: 1, wantErr: true},
19+
{cpus: 2},
20+
{cpus: 3, wantErr: true},
21+
{cpus: 4},
22+
} {
23+
err := validateMacOSCPUs(tt.cpus)
24+
if (err != nil) != tt.wantErr {
25+
t.Fatalf("validateMacOSCPUs(%d) error = %v, wantErr %v", tt.cpus, err, tt.wantErr)
26+
}
27+
if err != nil && !strings.Contains(err.Error(), "positive even number") {
28+
t.Fatalf("validateMacOSCPUs(%d) error = %v", tt.cpus, err)
29+
}
30+
}
31+
}
32+
1033
func TestCloneOpenCoreBase(t *testing.T) {
1134
tests := []struct {
1235
name string

cmd/vm/lifecycle.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) {
127127
return nil, err
128128
}
129129
cpus, _ := cmd.Flags().GetInt("cpus")
130-
if cpus < 1 {
131-
return nil, fmt.Errorf("--cpus must be at least 1, got %d", cpus)
130+
if err = validateMacOSCPUs(cpus); err != nil {
131+
return nil, err
132132
}
133133
vnc, _ := cmd.Flags().GetInt("vnc")
134134
vncPass, _ := cmd.Flags().GetString("vnc-password")
@@ -168,6 +168,13 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) {
168168
return r, saveRec(dir, r)
169169
}
170170

171+
func validateMacOSCPUs(cpus int) error {
172+
if cpus < 1 || cpus%2 != 0 {
173+
return fmt.Errorf("--cpus must be a positive even number, got %d", cpus)
174+
}
175+
return nil
176+
}
177+
171178
func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error {
172179
ctx := cliutil.CommandContext(cmd)
173180
logger := log.WithFunc("cmd.vm.launch")

docs/vm.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ Every VM boots the same way on Intel and AMD:
1212
baked into the macOS qcow2 — the same image boots under a different loader on Intel vs AMD.
1313
- **CPU:** `Skylake-Client` spoofing `GenuineIntel` with `-hle,-rtm` (TSX off), `+invtsc`, and
1414
`vmware-cpuid-freq=on`. The TSC flags are load-bearing: without them macOS self-calibrates the TSC
15-
and spins pathologically under nested KVM on first boot. QEMU receives an explicit one-thread-per-core
16-
topology so odd vCPU counts do not get inferred as unsupported multi-threaded cores.
15+
and spins pathologically under nested KVM on first boot. `--cpus` must be a positive even number;
16+
create/run/clone reject odd counts before VM state is created because this macOS stack does not boot them.
1717
- **OpenCore picker:** the shipped `OpenCore.qcow2` template boots the default entry immediately with
1818
no picker UI (a visible picker can't be driven reliably headlessly — OpenCanopy cancels its
1919
`Timeout` countdown on stray USB-enumeration input and then waits forever). `--random-smbios`

qemu/launch.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type Spec struct {
4545

4646
// Args returns the qemu-system-x86_64 argument vector for the macOS guest.
4747
func (s Spec) Args() []string {
48+
cores := max(s.CPUs/2, 1)
4849
varsFmt := "raw"
4950
if IsQcow2NVRAM(s.OVMFVars) {
5051
varsFmt = "qcow2"
@@ -60,8 +61,7 @@ func (s Spec) Args() []string {
6061
"-enable-kvm", "-m", s.Memory,
6162
"-cpu", macOSCPU,
6263
"-machine", machine,
63-
// Implicit threads lets QEMU infer SMT layouts (3 vCPUs -> 1 core x 3 threads) that macOS boots slowly or not at all.
64-
"-smp", fmt.Sprintf("%d,cores=%d,threads=1,sockets=1", s.CPUs, s.CPUs),
64+
"-smp", fmt.Sprintf("%d,cores=%d,sockets=1", s.CPUs, cores),
6565
"-device", "qemu-xhci,id=xhci",
6666
"-device", "usb-kbd,bus=xhci.0",
6767
"-device", "usb-tablet,bus=xhci.0",

qemu/launch_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,13 @@ func TestArgsCPU(t *testing.T) {
148148
t.Fatalf("-cpu missing load-bearing %s: %s", f, cpu[0])
149149
}
150150
}
151-
if got := argVals(s.Args(), "-smp"); len(got) != 1 || got[0] != "4,cores=4,threads=1,sockets=1" {
151+
if got := argVals(s.Args(), "-smp"); len(got) != 1 || got[0] != "4,cores=2,sockets=1" {
152152
t.Fatalf("unexpected CPU topology: %v", got)
153153
}
154154

155-
s.CPUs = 3
156-
if got := argVals(s.Args(), "-smp"); len(got) != 1 || got[0] != "3,cores=3,threads=1,sockets=1" {
157-
t.Fatalf("unexpected odd CPU topology: %v", got)
155+
s.CPUs = 2
156+
if got := argVals(s.Args(), "-smp"); len(got) != 1 || got[0] != "2,cores=1,sockets=1" {
157+
t.Fatalf("unexpected two-vCPU topology: %v", got)
158158
}
159159
}
160160

0 commit comments

Comments
 (0)