Skip to main content
The dominant cost of a cold start is not the sandbox policy but the macOS kernel validating code signatures page by page on freshly copied binaries. Once the cache is warm, a start drops to tens of milliseconds. Numbers below come from one Apple Silicon reference machine; the proportions matter, not the absolutes.

Executive Summary

One sample, measured on a single Apple Silicon machine (APFS, alpine:latest). Read the proportions, not the absolute numbers — they will differ on your hardware.
  • handle.start() is about ~2.1s with the jailer on, and about ~0.7s with the jailer off.
  • This ~1.4s difference is almost entirely not caused by sandbox-exec (the sandbox policy) — policy compilation measured ~5–10ms across runs.
  • The root cause is the macOS kernel’s per-page code-signature validation of freshly copied dylibs: an unavoidable kernel-level cost when executing a binary from a new inode.
  • Code-signature validation accounts for roughly 70% of the total startup time (~1450ms).

Measure it yourself

The code below directly measures the per-stage timing of one “cold start” and separately prints the kernel-code-signature-related stages. Note: metrics() is on the native Box and is async; the Boxlite runtime is entered with a synchronous with (and Boxlite.default() is synchronous), but its methods are async; Box is an async context manager.
On the first run (cold copy, empty kernel signature cache), total_create_duration_ms is large; running it again on the same machine drops the stage timings markedly because the kernel’s per-page validation cache hits by inode. That is exactly this page’s core finding.

Full Startup Timeline (jailer on)

Latency Breakdown

Root Cause: macOS Kernel Per-Page Code-Signature Validation

The mechanism

The jailer copies the shim plus dylibs (~36MB total) into each box’s own directory, producing a new inode (a new inode even with APFS reflink / copy-on-write). On macOS, when dyld loads an executable image via mmap(), the kernel validates its ad-hoc code signature per page:
  1. dyld maps each library’s __TEXT segment via mmap(MAP_PRIVATE).
  2. On the first page fault of each page, the kernel:
    • Reads the page contents (4KB on x86, 16KB on ARM)
    • Computes the SHA-256 hash
    • Compares it against the embedded CodeDirectory hash slot
  3. The result is cached by inode — subsequent executions of the same inode are therefore faster.
For 20.7MB of pre-main dylibs, that is about 1,300 per-page validations at ~0.77ms each = ~1000ms.

Comparison by scenario

The following scenarios all use the same shim binary (5.4MB) plus dylibs (libkrun 4.4MB, libgvproxy 10.9MB, 20.7MB total), varying only copy freshness and whether the sandbox is applied:

Key conclusions

  1. sandbox-exec is not the bottleneck — policy compilation measured ~5–10ms regardless of complexity (5 rules and 200 rules showed the same latency).
  2. cat pre-warm has no effect — file I/O populates the buffer cache, but dyld’s mmap() goes through the kernel code-signature pager, a different cache path.
  3. codesign --verify has no effect — it validates in userspace using its own file reads; the kernel maintains a separate validation cache that can only be populated via the mmap pager.
  4. Only actually executing the binary warms the cache — the kernel’s code-signature validation cache is populated only when dyld maps executable pages via mmap().

Jailer On vs Off

The measured 1371ms delta is dominated by two components, both code-signature validation: the spawn→main gap (990ms) and the krun FFI call (427ms). The sandbox policy itself is negligible by comparison. (The two components were timed in separate runs, so they do not sum exactly to the end-to-end delta.)
The jailer / sandbox is controlled by the security options: BoxOptions(advanced=AdvancedBoxOptions(security=SecurityOptions.maximum())). AdvancedBoxOptions is not exported at the top level, so use from boxlite.boxlite import AdvancedBoxOptions; the SecurityOptions presets are development() / standard() / maximum() (there is no .minimum()).

Pipeline Stage Metrics (from box.metrics())

The field names below are the readable attributes on the Python BoxMetrics (all with the _ms suffix; the stage_* fields are mostly Optional and are None when unavailable):
BoxMetrics also includes runtime fields such as cpu_percent, memory_bytes, commands_executed_total, exec_errors_total, and network_* (not startup-related). For runtime-level metrics use runtime.metrics(), with the fields num_running_boxes / boxes_created_total / boxes_failed_total / total_commands_executed / total_exec_errors.

What you can do about it

The kernel’s code-signature cache is keyed by inode and is populated only when a binary is actually executed. For callers that means one thing: Start one box before your batch begins. The first box pays the validation cost; every box after it on the same machine reuses the warm cache. Approaches that do not help, so they are not worth trying:
Methodology: host and shim wall-clock timestamps (chrono::Utc::now()) were correlated through the timing_profile.rs integration test (jailer ON/OFF), plus standalone benchmarks isolating one variable at a time — sandbox-exec policy compilation, FD cleanup, cold vs warm binary startup, and the pre-warm strategies above.

Reproduction

The repository contains a Rust timing baseline test (src/boxlite/tests/timing_profile.rs, targeting alpine:latest) that re-runs the data on this page:
The repository CLAUDE.md recommends preferring make targets (which encapsulate the correct build/cross-compilation flags). The cargo test commands above are run directly only for a local one-off timing experiment.

Troubleshooting