Repository navigation
feat(vm): bigger macOS job disks + sweep leaked macOS VM clones - #216
Merged
Merged
Conversation
…ize_gb) Every macOS job boots from an APFS clone of base.img, so the guest got the base image's 50 GB (~41 GiB usable). ePHPm's nightly PHP-linked debug test build (each test binary embeds libphp + ICU) filled it every night since at least 2026-10-14 with "No space left on device". [vm.macos] disk_size_gb (GiB, default 0 = unchanged) now: - extends each job's clone with os.Truncate before boot. Sparse on APFS, and the blocks shared with base.img stay shared; - grows the guest's boot container over SSH before the runner starts: `diskutil repairDisk <whole>` then `diskutil apfs resizeContainer <store> 0`. The store is read from `diskutil info`, not assumed, and validated before it is pasted into the command. The fleet's base image has no Recovery partition after the container (ISC, APFS 44.1 GB, free space), so the container can grow in place. Best-effort throughout: a value not larger than the base image is ignored with a warning (never shrinks), and any guest-side failure is a WARN and the job runs on the base-size disk. Each guest command has a 90 s deadline, well inside the 5 m provisioning timeout. The same one-shot setup step also untaps the base image's buildkite/buildkite Homebrew tap, as the admin user. Nothing uses it, and every brew command in a job warned that it is untrusted.
A macOS VM deletes its <id>.img/<id>.aux clones and share dir in Stop, but nothing removed them when Stop never ran: a daemon restart or crash mid-job, or a stop that wedged in Virtualization.framework and was abandoned. The guests die with the daemon, the files do not, and each .img diverges from base.img as the job writes. One from 2026-10-01 held 38 GB on the fleet's Mac. `ephemerd doctor` only removed the share directories, never the clones. SweepStaleMacOSClones removes jobs/ entries older than 24 h at startup, before macOS jobs are accepted, and logs each one. A live guest keeps writing its clone, so the age gate is a second guard. doctor uses the same sweep with no age gate (it only cleans with the daemon stopped), so the disk clones go too.
…eriodically Root cause of the 38 GB clone left on the fleet's Mac from 2026-10-01: a VM deleted its <id>.img/<id>.aux clones and share dir in exactly one place, at the end of Stop, AFTER the Virtualization.framework stop returned. That stop can hang forever (#196). The scheduler then abandons the VM, and the deletion never runs. A daemon restart or crash mid-job leaked the same files: the guest dies with the process and nothing came back for them. Which of the two hit the Oct 1 job can't be told from code alone, so both are closed: - Stop runs the guest stop on its own goroutine and removes the files when it returns OR after 45 s (teardownMacVMFiles), whichever comes first. Unlinking under a wedged guest is safe; the guest keeps its descriptor. Stop itself still blocks until the guest is gone, so the scheduler's abandoned-VM accounting is unchanged. - Start removes the clones itself on any error before the VM exists. - A live-VM registry in pkg/vm (register on NewMacOSVM, unregister when Stop completes) covers VMs that are provisioning or stuck stopping, which s.running does not. - The scheduler sweeps vm/macos/jobs at startup (kicked by SetMacOSVMConfig) and every 30 min. It removes only entries no live VM owns AND unwritten for 2 h, never anything outside jobs/, and logs each removal with its allocated size. Totals appear in status as macos_clones_swept / macos_clone_bytes_swept. - `ephemerd doctor` uses the same sweep with no live set or grace. This replaces the startup-only, mtime-only sweep from the previous commit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
ePHPm's nightly "Build (PHP x, macos-aarch64)" fills the macOS job VM to 98–99% during the PHP-linked debug test build. Each test binary embeds libphp + ICU. It has failed with "No space left on device" every night since at least 2026-10-14. ePHPm#634 deletes
target/debugafter the test step, but the test step itself still runs out mid-link. Every job boots from an APFS clone ofbase.img(50,000,000,000 bytes), so the guest gets about 41 GiB.Change
[vm.macos] disk_size_gb(GiB). The default 0 keeps today's behavior.os.Truncateextends the job's clone. It's sparse on APFS (no host disk is used until the guest writes), and the blocks shared withbase.imgstay shared.diskutil repairDisk <whole>moves the GPT backup header to the new end of the disk, thendiskutil apfs resizeContainer <store> 0.diskutil info /System/Volumes/Data, with/as a fallback. It isn't hard-coded, and it's validated against^disk[0-9]+s[0-9]+$before going into the command.Verified layout of the fleet's base image (
hdiutil attach -nomount -readonly+diskutil list, 2026-10-22):disk5s1Apple_APFS_ISC 524.3 MB,disk5s2Apple_APFS 44.1 GB, then 5.4 GB of free space. There's no Recovery partition after the container, so it can grow in place.Failure behavior: warn and carry on.
Homebrew: the same one-shot step runs
brew untap --force buildkite/buildkiteasadmin(the brew owner on Tart images), if the tap is present. Nothing uses it, and every brew call in ePHPm's nightly warns that it's untrusted. It's best-effort, logged at debug level, with a 60 s cap.Leaked clones: the Mac's
vm/macos/jobsheld a 38 GB.imgfrom 2026-10-01 that was never removed.Stop, after the Virtualization.framework stop returned. That stop can hang forever (macOS: the provisioning-deadline self-heal reclaims the VM but LEAKS the concurrency slot — node goes silently dead #196). When it does, the scheduler abandons the VM and the deletion never runs. A daemon restart or crash mid-job leaks the same files: the guest dies with the process and nothing comes back for them. Which of the two hit the Oct 1 job can't be told from code alone, so every path is closed:Stopruns the guest stop on its own goroutine and removes the files when it returns or after 45 s, whichever is first (teardownMacVMFiles). Unlinking under a wedged guest is safe: it keeps its descriptor.Stopstill blocks until the guest is gone, so the scheduler's abandoned-VM accounting (which decides whether another VM may start) is unchanged.Startremoves the clones itself on any error before the VM exists.pkg/vmregisters a VM inNewMacOSVMand unregisters it whenStopcompletes. It covers VMs that are provisioning or stuck stopping, whichs.runningdoesn't.SetMacOSVMConfig) and every 30 min. It removes onlyjobs/entries that no live VM owns and that haven't been written for 2 h. It never touches anything outsidejobs/(base.img, aux.bin). It logs each removal with its allocated size (not the sparse apparent size). Totals appear in status asmacos_clones_swept/macos_clone_bytes_swept.ephemerd doctorused to remove only the share directories. It now uses the same sweep with no live set or grace (it only cleans with the daemon stopped), so the disk clones go too.injectRunnerIntoClone(which mounts the clone) has no callers, so the truncate can't interfere with a host-side mount.Verified vs. not
ResolveMacOSDiskSizeagainst a sparse 50e9-byte file;diskutil infoparsing, from a fixture built from the verified layout, plus refusal of whole disks and injection attempts;go test ./pkg/vm ./pkg/scheduler ./pkg/configon Windows, plus linux test binaries under WSL;golangci-lint run(v2.11.4), clean for bothGOOS=linuxand windows;go veton Windows andGOOS=linux.diskutil repairDisk/resizeContainercommands and the brew untap. They need a canary job on the Mac, and neither can fail a job;TestTeardownMacVMFiles_WedgedStopStillRemovesFiles.Rollout
mayfly already renders
[vm.macos] disk_size_gb(VMSpec.DiskSizeGB→writeVMSection), so only config is needed:[pools.mac-arm64.vm.macos] disk_size_gb = 100, then a release andmayfly node reconfigureon the Mac. The Mac has 232 GiB free and runs one macOS VM at a time.