/** * The rig's contract with the Docker host it runs on. * * On Linux, docker runs on the host kernel and there is no contract to state. * On macOS it runs inside a virtual machine, and two of that VM's settings * dominate how long a run takes. Both were measured on 2026-09-05, on an M1 Pro * with 32 GiB, while a suite was running: * * **Memory.** The VM held 24 GiB of the host's 32. It used 1.9 GiB and filled * the rest with page cache, which is what a Linux guest is supposed to do. But * the guest's idea of "cached in RAM" is the host's idea of "anonymous memory I * may compress or swap", so the host sat at 21.9 GiB of swap used with * `kernel_task` (the memory compressor) at 60% of a core, and every image-layer * read the guest thought was free became a host decompress or an SSD read. The * guest cannot give the memory back — there is no balloon driver — so the only * lever is not to hand it over in the first place. * * **Mount type.** The celilo checkout is bind-mounted into the management * container. Under colima's default `sshfs` every file operation crosses FUSE, * an SSH channel, a userspace TCP stack on the host, an ssh multiplexer and * `sftp-server`. `virtiofs` is a shared-memory transport with none of that * chain. * * The two costs had to be separated, because measuring them together * attributed nearly all of it to the wrong one. Same probe, same image, three * VM configurations: * * | VM | CLI on image disk | CLI over mount | * |-------------------------------------|-------------------|----------------| * | 24 GiB sshfs (host swapping 21.9 G) | 0.27s | 2.50s | * | 12 GiB sshfs | 0.16s | 0.56s | * | 12 GiB virtiofs | 0.14s | 0.31s | * * So the 2.3-second gap in the first row was about 1.9s of host swap and 0.4s * of transport. Memory is the big lever; the mount is real but secondary, and * shows up most on bulk reads — reading 200 source files took 0.24s on sshfs * and 0.073s on virtiofs, a factor of 3.3. * * Nothing here can fix either one — colima refuses to change a VM's mount type * after creation, and shrinking memory needs a restart. So this module's job is * to READ what the VM is actually running and say plainly when it disagrees * with the policy, which is what `cele2e doctor` and `cele2e host` do with it. * * It reads lima's own instance config rather than colima's saved profile, * because those two can disagree and only one of them is the running VM. They * disagreed while this was being written: colima accepts `--mount-type` on the * command line, prints `'volume mount type' cannot be updated after initial * setup, discarded`, and rewrites its saved profile back — so the profile is a * record of what was asked for, and lima's is a record of what happened. */ import { existsSync, readFileSync } from 'node:fs'; import { cpus, homedir, totalmem } from 'node:os'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; /** What the VM is actually running, read from lima's instance config. */ export interface HostVmFacts { /** colima profile name, e.g. `default`. */ profile: string; cpus: number; memoryGiB: number; /** Normalized: lima's `reverse-sshfs` is colima's `sshfs`. */ mountType: string; /** `vz` (Apple Virtualization) or `qemu`. virtiofs requires `vz`. */ vmType: string; } /** The host the VM is carved out of. */ export interface HostFacts { cpus: number; memoryGiB: number; } /** What the VM should be given. */ export interface HostVmBudget { cpus: number; memoryGiB: number; mountType: 'virtiofs' | 'sshfs'; } /** * The share of host RAM above which the host starts paying for the guest's * page cache in swap. Not a tuning knob so much as a line: at 24 of 32 GiB * (75%) the host swapped 21.9 GiB; the policy below lands at 37.5%, which * leaves the guest more than it uses and the host its own working set. */ export const MAX_HOST_MEMORY_SHARE = 0.5; const TARGET_HOST_MEMORY_SHARE = 0.375; /** Floor and ceiling on the VM's memory, in GiB. */ const MIN_VM_MEMORY_GIB = 4; const MAX_VM_MEMORY_GIB = 16; /** Cores left to the host, so the Mac stays usable while a suite runs. */ const HOST_RESERVED_CPUS = 2; /** * The VM settings this rig wants on a given host. * * Pure, and expressed as a fraction rather than a constant, so it says * something true on a 16 GiB laptop and a 64 GiB desktop rather than encoding * one machine's answer. */ export function recommendedBudget(host: HostFacts, vmType: string): HostVmBudget { const memoryGiB = Math.min( MAX_VM_MEMORY_GIB, Math.max(MIN_VM_MEMORY_GIB, Math.floor(host.memoryGiB * TARGET_HOST_MEMORY_SHARE)), ); return { cpus: Math.max(2, host.cpus - HOST_RESERVED_CPUS), memoryGiB, // virtiofs is an Apple Virtualization feature; a qemu VM cannot have it. mountType: vmType === 'vz' ? 'virtiofs' : 'sshfs', }; } export function readHostFacts(): HostFacts { return { cpus: cpus().length, memoryGiB: Math.round(totalmem() / 1024 ** 3) }; } /** colima's lima instance directory: `colima` for the default profile, `colima-

` otherwise. */ export function limaInstanceDir(profile: string): string { const instance = profile === 'default' ? 'colima' : `colima-${profile}`; return join(homedir(), '.colima', '_lima', instance); } export function activeProfile(): string { return process.env.COLIMA_PROFILE || 'default'; } /** `12288MiB` / `12GiB` / `12884901888` → GiB. */ export function parseLimaMemory(raw: unknown): number { if (typeof raw === 'number') return Math.round(raw / 1024 ** 3); if (typeof raw !== 'string') return 0; const match = raw.trim().match(/^([\d.]+)\s*([KMGT]i?B)?$/i); if (!match) return 0; const value = Number.parseFloat(match[1]); const unit = (match[2] ?? 'B').toUpperCase(); const scale: Record = { B: 1, KB: 1024, KIB: 1024, MB: 1024 ** 2, MIB: 1024 ** 2, GB: 1024 ** 3, GIB: 1024 ** 3, TB: 1024 ** 4, TIB: 1024 ** 4, }; return Math.round((value * (scale[unit] ?? 1)) / 1024 ** 3); } /** Parse a lima instance config. Pure, so the normalization is testable without a VM. */ export function parseLimaConfig(yamlText: string, profile: string): HostVmFacts | null { try { const parsed = parseYaml(yamlText) as { cpus?: number; memory?: string | number; mountType?: string; vmType?: string; }; if (!parsed || typeof parsed !== 'object') return null; return { profile, cpus: parsed.cpus ?? 0, memoryGiB: parseLimaMemory(parsed.memory), // lima calls it reverse-sshfs; colima's flag and its docs call it sshfs. mountType: (parsed.mountType ?? '').replace(/^reverse-/, ''), vmType: parsed.vmType ?? '', }; } catch { return null; } } /** * What the VM is running, or null when this host has no colima VM — which is * the normal case on Linux and in CI, and means there is no policy to enforce. */ export function readHostVmFacts(profile = activeProfile()): HostVmFacts | null { const limaYaml = join(limaInstanceDir(profile), 'lima.yaml'); if (!existsSync(limaYaml)) return null; try { return parseLimaConfig(readFileSync(limaYaml, 'utf-8'), profile); } catch { return null; } } export interface HostVmVerdict { /** Every way the VM disagrees with the budget, in plain sentences. */ problems: string[]; /** True when a fix requires destroying and recreating the VM. */ needsRecreate: boolean; } /** * Compare the running VM against the budget. * * Pure, so each rule is testable with the condition deliberately broken — * a check nobody has seen fail is not a check (Rule 7.6). */ export function evaluateHostVm( facts: HostVmFacts, host: HostFacts, budget: HostVmBudget, ): HostVmVerdict { const problems: string[] = []; let needsRecreate = false; if (facts.memoryGiB > host.memoryGiB * MAX_HOST_MEMORY_SHARE) { problems.push( `VM memory is ${facts.memoryGiB} GiB of the host's ${host.memoryGiB} GiB — the guest fills the surplus with page cache the host then compresses or swaps, so every image read becomes host I/O. Recommended: ${budget.memoryGiB} GiB.`, ); } if (facts.mountType !== budget.mountType) { problems.push( `VM mounts the host filesystem over ${facts.mountType || 'an unknown transport'}; ${budget.mountType} is a shared-memory transport with no SSH hop. Measured on the mounted checkout: reading 200 source files took 0.24s over sshfs and 0.073s over virtiofs.`, ); // colima discards a mount-type change on an existing VM. needsRecreate = true; } if (facts.memoryGiB < budget.memoryGiB) { problems.push( `VM memory is ${facts.memoryGiB} GiB, below the ${budget.memoryGiB} GiB this host can afford it — the guest has less page cache than the rig's images need, so reads that should be cached hit the disk.`, ); } if (facts.cpus > host.cpus) { problems.push( `VM is configured with ${facts.cpus} CPUs but the host has ${host.cpus}. Recommended: ${budget.cpus}.`, ); } else if (facts.cpus < budget.cpus) { // colima resizes CPUs on a restart, so this does NOT need a recreate. problems.push( `VM has ${facts.cpus} CPUs against a policy of ${budget.cpus} on a ${host.cpus}-core host — a suite runs on the cores the VM was given, not the ones the host has. Recommended: ${budget.cpus}.`, ); } return { problems, needsRecreate }; }