Skip to content

Add macOS (Apple Silicon) host support - #1424

Open
dywongcloud wants to merge 27 commits into
microsoft:mainfrom
dywongcloud:master
Open

dywongcloud wants to merge 27 commits into
microsoft:mainfrom
dywongcloud:master

Conversation

@dywongcloud

@dywongcloud dywongcloud commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

litebox_platform_macos_userland: a LiteBox platform for running on
userland macOS on Apple Silicon, backed by Hypervisor.framework -- HVF
vCPU/memory management, guest-thread state, the W^X toggle for JIT
regions, and the guest-entry monitor trampoline.

litebox_runner_linux_on_macos_userland: the runner that hosts a Linux
guest on top of that platform, including its NAT-style network proxy.

litebox_platform_multiplex: dispatches to whichever of the platforms
above (or the existing ones) is active, needed by litebox_runner_lvbs.

Screenshot 2026-09-25 at 4 00 51 PM

dywongcloud and others added 27 commits September 23, 2026 00:35
This crate was removed on the alternate branch's main; drop it here too
ahead of syncing the rest of litebox core and its platform/runner crates
to that branch's tip, which no longer has it as a member.
…te main

Bring litebox core and every already-present platform, runner, and shim
crate up to the alternate branch's tip: the Platform trait and several
downstream APIs changed shape there (shared-futex-backed VmArea, dirfd-
relative sys_openat replacing sys_open, aarch64 syscall-rewriter support,
9P/proc/flock/framebuffer filesystem backends, a new litebox::broker
module, etc.), so every implementor needs the same update to keep
building. litebox_packager additionally gains the musl x18-register
patching support macOS-hosted guests need (see docs/roadmap.md's "XNU
destroys a live guest x18"); its desktop-image-specific manifest and
build script are left out, matching the exclusion of the XFCE/HVF-desktop
example recipes elsewhere in this series.
litebox_runner_linux_userland now unconditionally depends on
litebox_broker_transport_linux_userland (for its multi-process broker
socket support), whose own dependency chain and dev-dependencies pull in
the rest of the broker crates: litebox_broker_core, _host, _local,
_protocol, and _transport, plus litebox_broker_userland (the standalone
broker daemon binary, needed for the runner's own broker integration
tests). This is the full closure required for the already-synced runner
crates to build and test; it does not add the broker daemon as a
user-facing feature on its own.
litebox_platform_macos_userland: a LiteBox platform for running on
userland macOS on Apple Silicon, backed by Hypervisor.framework -- HVF
vCPU/memory management, guest-thread state, the W^X toggle for JIT
regions, and the guest-entry monitor trampoline.

litebox_runner_linux_on_macos_userland: the runner that hosts a Linux
guest on top of that platform, including its NAT-style network proxy.

litebox_rfb: an RFB (VNC) server used to expose the macOS runner's
guest framebuffer.

litebox_platform_multiplex: dispatches to whichever of the platforms
above (or the existing ones) is active, needed by litebox_runner_lvbs.

Desktop/VNC-testing demo image-build infrastructure (XFCE and
HVF-desktop example recipes) is intentionally left out.
… the new crates

- Add a macos-14 build_and_test_macos job, an AArch64 shim build step and
  binutils install to the existing build/test jobs, and a miri job
  scoped to litebox's mm:: tests.
- Update confirm_no_std's allow-list: litebox_platform_macos_userland,
  litebox_runner_linux_on_macos_userland, litebox_rfb,
  litebox_broker_transport_linux_userland, and litebox_broker_userland
  are userland implementations needing std, matching the existing
  litebox_platform_linux_userland/litebox_platform_windows_userland
  entries; litebox_platform_multiplex needs it transitively through the
  macOS platform it multiplexes over.
- Update dev_tests' copyright-header and ratchet (transmute/global/
  MaybeUninit usage count) checks for the crates this series adds,
  removes, or otherwise changes, and skip the one file each check
  cannot apply to for a structural reason (an Xcode entitlements
  property list, requiring `<?xml ...?>` as its literal first bytes).
- Extend the tun-access nextest retry allowance to
  test_handle_futex_death_wakes_waiter_and_sets_owner_died: under enough
  concurrent CPU contention from the rest of the suite, its real-threads
  fixed-sleep synchronization can race handle_futex_death's one-shot
  wake against a waiter that has not registered yet.
initialize_socket stored SockType as fd-scoped metadata, which
descriptor_table().duplicate() deliberately does not copy. A dup'd INET
socket therefore had no SockType, so get_socket_type (and everything
routed through it: connect, accept, getsockopt(SO_TYPE), sendto) failed
NoSuchMetadata -> ENOTSOCK once any path consulted it, regardless of
whether the original fd was still open. SO_TYPE is a property of the
socket, shared by every descriptor referring to it, so store it as entry
metadata alongside SocketOptions and SocketOFlags.

Un-ignores test_tun_tcp_connection_refused, which exercised exactly this.
The test kept the futex word on the host test-thread stack. handle_futex_death
wakes with shared (non-PRIVATE) semantics, whose key lookup resolves the
word through the VMM, and a host-stack page is only known to the VMM if this
thread's stack happened to be mapped when the process-wide test platform
took its one-time host-mapping snapshot. Whenever it was not, the wake
failed EFAULT (swallowed, as on Linux), the waiter timed out, and the test
failed -- about half the time under a full-suite run, deterministically by
test order rather than timing, which is why widening the fixed sleep did not
help. Map the word through the task instead, as a real guest's would be.

Drops the nextest retry allowance added for this test: the fix removes the
flake rather than tolerating it.
A MAP_SHARED file mapping's writes land in the file, so Linux requires
the backing fd to be open for writing: mmap(MAP_SHARED | PROT_WRITE)
on an O_RDONLY fd fails with EACCES, and such a mapping never gets
VM_MAYWRITE, so a later mprotect(PROT_WRITE) fails with EACCES too.

The shim enforced neither. A guest could open a file read-only, map it
MAP_SHARED | PROT_READ, mprotect it writable, and dirty the file on
munmap through the copy-back path without ever holding a writable fd.

Record whether the fd was open for writing (from the open-time
FdOpenFlags entry metadata) on each SharedFileMapping, carry it across
splits and remaps, and check it in both do_mmap and sys_mprotect. Fds
with no open-flags record keep the previous behaviour.

test_map_shared_readonly_file, which asserted the mprotect half of this
and was #[ignore]d for it, is re-enabled and also covers the mmap half.
CI installs the current stable toolchain (1.98), which rejects a few
things 1.94 accepted, and two of its jobs build for aarch64, which
compiles code the x86_64 host never sees:

- litebox_common_linux: `UserPtRegs::write_into` casts `u64` to
  `usize`; the module is aarch64-only, so the widths match. Expect the
  lint there instead of adding a fallible conversion that cannot fail.
- litebox_shim_linux: SCM_RIGHTS parsing now uses `as_chunks` for the
  constant-size fd chunks (chunks_exact_to_as_chunks), and a test's
  one-byte array is a byte string (byte_char_slices).
- litebox_shim_linux: on aarch64 `ThreadInitState::NewThread` carried
  a 528-byte FPSIMD register file inline (large_enum_variant); box it.
- litebox: the `Network` struct's doc comment had `accept_ready`
  inserted between it and the struct, so the docs (and their `Self::`
  links) were attached to the function. Move the helper above the docs.
- litebox: three doc links pointed at private items; make them plain
  code spans so `cargo doc` with `-Dwarnings` passes without
  `--document-private-items`.
- litebox_shim_linux: two doc links did not resolve. `InotifyEvent`
  lives in litebox_common_linux, and `elf_patch_cache` is a field of
  `Process`, not of the struct whose doc called it `Self::`.
DiodServer::start picks a port by binding port 0 and releasing it, then
starts diod on it, and the tests run in parallel. Two tests can get the
same port. The second diod then fails to bind ("Address already in
use") and exits, but the readiness probe was a plain TCP connect, which
can reach the first test's diod before the second one exits. The test
then attaches with its own export path to a server that does not export
it, and FileSystem::new fails with Io.

This showed up as roughly one failing run in sixteen of the nine_p
suite, always in a different test. Every failure with diod's bind error
also showed the listener was not owned by the test's diod, and ownership
recorded at the moment the old probe declared ready was false.

Judge readiness by the spawned diod owning a LISTEN socket on the port:
look the port up in /proc/net/tcp and match the socket inode against the
child's /proc/<pid>/fd links. The module is already Linux-only. A diod
that lost the race still exits and is retried on a new port as before.

With the check in place the suite passed 80 runs out of 80.
The transactional rewrite of WindowsUserland::allocate_pages refused
every fixed allocation that touched a committed page, for Replace as
well as NoReplace, because decommitting cannot be rolled back. But Vmem
asks the platform for Replace exactly when its own mappings cover the
whole range: that is MAP_FIXED over an existing mapping, which the shim
does while setting up every process. Loading any program on Windows
therefore failed with LoadError(Map(ENOMEM)).

Plan committed pages under Replace as RecommitCommitted: decommit, then
commit afresh with the new protection, which also gives the zeroed
pages MAP_FIXED promises (the pre-rewrite code did the same). It runs
after every reversible step. A failure before any page is discarded
rolls back as before; after that, returning would leave Vmem believing
the old mapping is intact, so it aborts, like an incomplete rollback.
Hint and NoReplace are unchanged.
run_test_thread is a ThreadProvider method, not an inherent one, so
`WindowsUserland::run_test_thread` did not resolve under rustdoc.
The dynamic-binary test built its rootfs with the Windows `tar`, which
has no execute bit to record and archives the hooked program, libc and
ld.so as 0644. The shim now checks execute permission on the program
and its interpreter the way Linux execve does, so the runner failed
with EACCES opening the program.

Write the archive with the tar crate instead (already in Cargo.lock via
litebox_packager), giving every entry mode 0755 as a Linux rootfs
ships these files, and filling in the uid, gid and mtime fields that
litebox's tar reader parses.

Checked on Linux with the same shim: the test's own binaries, rewritten
and archived by this helper, run through litebox_runner_linux_userland
and print "hello world."; the same files archived 0644 fail with
EACCES, the error the Windows job reported.
litebox_platform_macos_userland's Hypervisor.framework shim refuses to
build against anything older than the macOS 26 SDK. The macos-14 image
ships Xcode 15.4 with the 14.5 SDK, so its build script failed before
clippy could run.
The vectored exception handler returned EXCEPTION_CONTINUE_SEARCH as
soon as the faulting thread had no TlsState, before it consulted the
exception table. So a guarded guest-memory access (memcpy_fallible and
friends) that faulted on a host thread that had never entered the
guest crashed the process instead of returning a failure.

Guest threads always have TLS state, so the runner never hit this. The
shim's unit tests do: they call syscall handlers directly on the test
thread, and test_setgroups_getgroups_round_trip_boundaries_and_copy_on_write
passes a null list to setgroups expecting EFAULT, which aborted with
0xc0000005 on Windows. (test_fallible_read is cfg'd out on Windows for
the same reason.)

A thread without TLS state cannot be in the guest, so treat its faults
like any other fault outside the guest: look up the exception table and
resume at the recovery point, else continue the search as before.
These only build for aarch64, so the x86_64 doc job never saw them; the
macOS job's `cargo doc -Dwarnings` does.

- litebox_common_linux: the `ptrace` module's own doc linked NT_PRSTATUS
  and NT_ARM_TLS, which resolve from the parent scope only as
  `ptrace::...`.
- litebox_shim_linux: ptrace.rs linked `crate::wait::Task`, a path that
  does not exist; `prepare_to_run_guest` is a method of `crate::Task`
  (implemented in wait.rs).
The HVF backend landed in bulk without passing the macOS CI job (which
stopped at the SDK build step), so `cargo clippy -Dwarnings` now reports
274 lints and 2 dead methods in this crate, and `cargo doc -Dwarnings`
broken or private links. Fixed without blanket allows:

- result_large_err (143): the lane and backend errors carried a whole
  HvfVcpuExit / register-state snapshot inline. Box those payloads
  (HvfVcpuLaneError's exit/state/request fields, HvfBackendError's
  Lane variant and UnexpectedExit exit, HvfVcpuDiagnosticError's Lane),
  and box the error of the few functions that hand values back in their
  error (LaneMaintenanceFailure, admit's rejected Command, the two claim
  helpers, behind named aliases).
- large_enum_variant: box the register file in Command::Execute.
- Machine-applicable fixes from `cargo clippy --fix` (map_or,
  let-else, is_multiple_of, contains, collapsible if, etc.), except the
  one whose method-path rewrite does not type-check; that site keeps its
  closure with an expect.
- Dead code: HvfVcpu::reject and HvfAddressSpace::executable_generation
  had no callers.
- `cancel_unbound_vcpu_creation` never failed; return () and drop the
  unreachable cleanup branches in its two callers.
- Smaller fixes: finish_non_exhaustive for Debug impls that omit
  internal fields, a used `_monitor_mapping` field renamed, typed
  truncations via TruncateExt, `Box::new_zeroed` for the 80 KiB probe
  buffer instead of a stack array, merged duplicate match arms, an item
  hoisted above statements.
- Per-site #[expect(..., reason)] where the lint flags a deliberate
  shape, as elsewhere in the repo: diagnostic report and state structs
  whose bools are independent properties, functions taking separately
  locked manager state, VM-scoped failure-injection hooks, and ratio
  math in a diagnostic report.
- Doc links from public items to private ones become code spans; a
  field link uses Self::.

Checked locally with the build script's xcrun step skipped:
`cargo clippy --all-targets --all-features --workspace` and
`cargo doc --document-private-items` for aarch64-apple-darwin pass with
-Dwarnings.
- Box the TLS stream in `Upstream`, which otherwise sized every
  upstream connection by the whole rustls state.
- as_chunks for the ICMP checksum, let-else for the recvfrom length, a
  char-array pattern for the authority end, and no redundant continue.
- Expect similar_names on the DNS header parser (qdcount/ancount/
  nscount/arcount are the protocol's field names) and the final cast of
  the already-folded checksum.
Bring in the two upstream commits on top of 4964033:

- df162a1 Relocate page management into dedicated modules (microsoft#1415)
- fcad93e Use bottom-up ELF placement policy (microsoft#1395)

Resolution:

- litebox::mm::linux is now litebox::mm::vmem; every path on our side
  (including the macOS platform and the shims) follows the rename.
- The Linux and Windows userland platforms move prot_flags and their
  PageManagementProvider impl, with helpers and page tests, into
  page_mgmt.rs. Our Windows additions (the virtual-memory mutation
  lock, RecommitCommitted, allocation rollback) move with them
  unchanged.
- The mm test backend gains upstream's Windows TASK_ADDR bounds next to
  our Apple ones.
- Bottom-up ELF placement is ported onto our loader: the main image is
  claimed from default_low_addr() (which honours the platform's
  TASK_ADDR_MIN) with MAP_FIXED_NOREPLACE and retried past racing
  mappings, the interpreter stays top-down, and do_mmap is pub(crate).
Vmem picks a move destination from its own bookkeeping, and the default
remap_pages claimed exactly that range with NoReplace. A hosted platform
can already hold part of it for the host -- on Windows even the unusable
tail of a host allocation's 64 KiB granule, which VirtualQuery reports
as free but MEM_RESERVE cannot claim -- so the claim failed and a guest
mremap(MREMAP_MAYMOVE) came back EFAULT. That is the intermittent
test_mremap failure on the Windows CI job (EFAULT at the MAYMOVE call,
passing on a re-run of the same commit).

When the NoReplace claim reports the range in use, retry with Hint and
let the platform place the pages, as Linux's own mremap without
MREMAP_FIXED does. Vmem::move_mappings already records the address
remap_pages returns; the permission restore after the copy now targets
where the pages landed too. The trait docs say the returned address need
not be new_range.start. Platforms that override remap_pages are
unchanged.

Tests cover both paths against a real-memory backend: a free target is
claimed as before, and a host-owned one falls back, carrying the data
and the final permissions to the placed range.
Bring the Windows guest crates over from main so master can run
Windows PE programs on the Linux and Windows userland hosts:

- litebox_common_windows: NT syscall numbers, PE loader types,
  NTSTATUS.
- litebox_shim_windows: the NT shim (x86_64 only).
- litebox_runner_windows_on_linux_userland and
  litebox_runner_windows_userland.
- litebox_syscall_rewriter gains PE rewriting (ntdll syscall stubs and
  GS->FS TEB accesses) and the --host flag.

Adapted to master:

- litebox::mm::linux paths follow the rename to litebox::mm::vmem.
- The event object implements IOPollable::unregister_observer by
  forwarding to its Pollee.
- read_utf16_string uses as_chunks, which clippy now asks for over
  chunks_exact with a constant size.

The workspace, lockfile, ratchet (the shim's test-only PLATFORM static)
and CI (the Windows job now builds, tests and documents the Windows
runner and the Windows shim; the no_std check skips the two std
runners but still covers the shim) are updated to match.
reserve_alignment_outputs_match_host_ntdll frees a host probe range and
then asks the guest to reserve inside it, retrying at a fresh address on
a conflict. It built a new test task on every attempt, between the host
free and the guest reserve. Building a task maps its CSR shared section
through a fresh page manager, which does not know the sections earlier
tasks left behind; its preferred spot is taken, so the Windows platform
lets the host place the section, and right after the free the host's
lowest free range is the probe itself. Every attempt therefore conflicted
(STATUS_CONFLICTING_ADDRESSES against the host's STATUS_SUCCESS) and the
retries ran out -- the Windows CI failure on this test.

Build the task once, before the warm-up and the probe loop, which is what
the test's own comment already requires: no shim-side allocation between
the probe's free and the guest's reserve.
litebox_shim_darwin runs x86-64 Mach-O executables that need no dynamic
linker. dyld and Apple's system libraries cannot be redistributed, so
an image that links a dylib, or uses chained fixups, is refused at load
time with a precise error rather than half-run.

- Loading: the image is mapped at its preferred address (so the
  LC_DYLD_INFO rebase opcodes never need to run), each segment gets its
  initprot, and `main` is entered the way dyld enters it --
  main(argc, argv, envp, apple) on an XNU-shaped stack, returning into a
  stub that calls exit(2) with its result.
- System calls: the `syscall` instructions in the image's instruction
  sections (not the strings and constants that share __TEXT) are
  redirected at load time with the runtime patcher the Linux shim uses,
  into a trampoline placed right above the image. A site the patcher
  cannot redirect becomes `icebp; hlt`, so it faults instead of reaching
  the host kernel.
- ABI: BSD-class calls (rax = 0x2000000 | n), results with the carry flag
  clear, failures with it set and Darwin's errno in rax. Implemented:
  exit, read, write, open, close, lseek, mmap (anonymous and private file
  mappings), munmap, mprotect, getpid, getppid, get[e]uid, get[e]gid,
  issetugid, getentropy and the _nocancel variants; the rest is ENOSYS.

Runners: litebox_runner_darwin_on_linux_userland and
litebox_runner_darwin_on_windows_userland, taking the program from a tar
archive like the other runners.

Tests: seven small Mach-O programs (assembly sources, a build script
using clang and ld64.lld, and the committed binaries under
litebox_shim_darwin/test-bins) run end to end under both runners --
stdout, exit status, the carry-flag errno convention, ENOSYS, reading a
file from the archive, argv, and an anonymous mmap round trip -- plus
parser unit tests against the same binaries. The Windows CI job builds,
tests and documents the Windows-host runner; the Linux job picks up the
rest as default members; the no_std check covers the shim and skips the
two std runners. The copyright-header check learns the binary .macho
extension.
WindowsUserland::new printed "System information." and the user address
bounds to stdout in debug builds, ahead of anything the guest writes. The
Darwin runner's end-to-end tests on the Windows CI job compare the
guest's stdout and failed on exactly that prefix; the guests themselves
ran correctly. Stdout belongs to the guest, so the diagnostic moves to
stderr.
7a1eeb3 removed the HEKI/HVCI service crate because the alternate
branch's main never had it, and the sync that followed replaced the LVBS
and OP-TEE crates with that branch's copies, which predate upstream's
split of HEKI out of the LVBS platform (microsoft#1093) and its removal of the
platform singletons (microsoft#1127). Bring the crate back, and with it the
upstream design it is written against.

The crate itself is restored byte-for-byte as it was before 7a1eeb3
(identical to upstream's). The crates it depends on or serves are
three-way merged against 6a03ec8, where the two histories diverged, so
the branch's own changes to them are kept:

- litebox_common_lvbs: the Vtl0Gate / Vtl0PrivilegedWrite /
  ReservationStatus interfaces heki needs.
- litebox_platform_lvbs: HEKI and memory integrity move out to the
  service (mshv/heki.rs goes away, mem_integrity.rs moves to the crate);
  the platform provides the VTL0 gates instead.
- litebox_runner_lvbs: owns the HEKI service, the session registry and
  the boot platform instead of reading them from singletons.
- litebox_shim_optee, litebox_common_optee and
  litebox_runner_optee_on_linux_userland: upstream's singleton-free
  session management and ldelf segment-padding fix, with its test TA.
  Conflicts were import lists (resolved to what compiles) and
  release_memory's callback, which keeps this branch's Option<Range>
  signature.
- litebox_common_linux: physical_pointers.rs, vmap.rs and loader.rs, and
  zerocopy's alloc feature, which the above build on.
- litebox core: userspace_pointers.rs gains copy_from_raw / copy_to_raw
  (upstream microsoft#1257).

litebox_platform_multiplex, which upstream deleted, is kept: nothing
uses it any more, but it carries this branch's macOS entry.

The workspace lists the crate again, the ratchet takes the new counts
(each checked against the statics it counts), and the no_std check
excludes it for the same crypto soft-float reason as the OP-TEE shim.
rust-cache's post-step cleanup walks target/ and treats any directory
named `tests` as a build profile. rustdoc writes one for every documented
module called `tests` (target/doc/litebox_runner_optee_on_linux_userland/
tests here), and for a profile named `tests` the cleanup probes nested
`tests/target` and `tests/trybuild` build directories without awaiting
the probe, so the ENOENT escapes its try block and lands as error
annotations on an otherwise green job. The docs are rebuilt on every run,
so each job that builds them now removes target/doc before the cache is
saved.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant