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.
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, whendyld loads an executable image via mmap(), the kernel validates its ad-hoc code signature per page:
dyldmaps each library’s__TEXTsegment viammap(MAP_PRIVATE).- 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
CodeDirectoryhash slot
- The result is cached by inode — subsequent executions of the same inode are therefore faster.
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
- sandbox-exec is not the bottleneck — policy compilation measured ~5–10ms regardless of complexity (5 rules and 200 rules showed the same latency).
catpre-warm has no effect — file I/O populates the buffer cache, but dyld’smmap()goes through the kernel code-signature pager, a different cache path.codesign --verifyhas 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.- 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())).AdvancedBoxOptionsis not exported at the top level, so usefrom boxlite.boxlite import AdvancedBoxOptions; theSecurityOptionspresets aredevelopment() / 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):
BoxMetricsalso includes runtime fields such ascpu_percent,memory_bytes,commands_executed_total,exec_errors_total, andnetwork_*(not startup-related). For runtime-level metrics useruntime.metrics(), with the fieldsnum_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 thetiming_profile.rsintegration 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 repositoryCLAUDE.mdrecommends preferringmaketargets (which encapsulate the correct build/cross-compilation flags). Thecargo testcommands above are run directly only for a local one-off timing experiment.

