Runtime¶
This page describes what happens inside a .pyz produced by moonlit build — when an end user runs python app.pyz, what files appear on disk, and how the bootstrap maps environment variables to behavior.
What runs first¶
Every .pyz ships with three things at the zip root:
__main__.py— a 3-line shim that imports and invokesbootstrap()._bootstrap/— a stdlib-only package with the runtime logic.env.json— the build-time descriptor (name, build_id, entry_point, …).
When you run python app.pyz, Python's zipapp machinery prepends the archive to sys.path and executes __main__.py:
The bootstrap then:
- Resolves the archive path via
os.path.abspath(sys.argv[0]). - Reads and validates
env.json. - Python version check. If
env.json.python_versionis present, compare it againstf"{sys.version_info.major}.{sys.version_info.minor}". On mismatch, exit 1 with amoonlit: this archive was built for Python X.Y, but you are running Python A.B …line — see Python version check. - Computes a cache key from the
[project].nameand the build_id. - Resolves the cache root (see below).
- Either takes the fast path (cache hit, no lock) or the slow path (acquire lock, extract to tempdir, atomically install, release lock).
- Calls
site.addsitedir(<cache>/<key>/site-packages)so the staged tree reachessys.pathand.pthfiles are processed. - Resolves the entry point string (or its
MOONLIT_ENTRY_POINToverride), imports the module, walks the attribute path, callsobj(). - Coerces the return value:
None→ 0,int→ masked& 0xFF, otherwiseint(result) & 0xFF(or exit 2 if uncoercible).
Cache layout¶
The cache root is resolved once per invocation:
| Platform | Default cache root |
|---|---|
| Windows | %LOCALAPPDATA%\moonlit (or ~/.moonlit if LOCALAPPDATA is unset) |
| POSIX (Linux, macOS) | ~/.moonlit |
| Anywhere | MOONLIT_ROOT overrides if set |
Inside the cache root:
<cache_root>/
├── <cache_key>/ # Populated, atomic-replaced site-packages parent.
│ └── site-packages/ # Contents of the .pyz's site-packages/.
├── <cache_key>.lock # Persistent lock file (see "Fast path and slow path").
└── .<cache_key>.tmp.<pid>/ # Staging dir for one in-progress extraction.
The cache_key is <normalized_name>_<build_id>, where:
<normalized_name>is the PEP 503 normalization ofenv.json.name(lowercase; runs of[-_.]collapsed to-). So a project authored asMy_App.Nameproducesmy-app-namehere.<build_id>is a 64-character lowercase hex SHA-256 digest of every file under the stagedsite-packages/(excluding__pycache__/segments and.pycfiles), interleaved with their forward-slash relative paths and separated by\0bytes.
Two builds with the same uv version, Python interpreter (major.minor.patch), uv.lock, and pyproject.toml produce the same build_id — and therefore the same cache key, so they share a cache.
Safe to delete¶
Anything under the cache root is safe to delete. The next invocation will re-extract.
To clear the entire cache:
To clear just one build's cache:
Fast path and slow path¶
The cache hit fast path is unsynchronized — readers of a populated cache do not contend with each other and do not acquire the lock. The bootstrap proceeds directly to site.addsitedir().
The slow path is the only mutator. It opens <cache_root>/<cache_key>.lock with O_CREAT | O_RDWR (no O_EXCL — the file is shared) and acquires an exclusive OS-managed advisory lock on the open file: fcntl.flock(LOCK_EX | LOCK_NB) on POSIX, msvcrt.locking(LK_NBLCK, 1) on Windows. Acquisition polls every 50 ms with a 60-second wall-clock timeout. After acquiring, it re-checks the cache (a sibling may have just won the race), extracts to a per-pid tempdir, and atomically installs via:
- Rename the existing
<cache_key>/aside to<cache_key>.old.<pid>/. os.replace(<tmp_dir>, <cache_key>/).shutil.rmtree(<cache_key>.old.<pid>/), best-effort.
This protocol is correct on POSIX and Windows since Python 3.3.
The lock file is persistent by design — closing the fd releases the OS lock, and the kernel releases it on process death, so the lock file itself doesn't need to be unlinked (and unlinking would race a concurrent opener, since flock is per open file description). A leftover <cache_key>.lock on disk is normal and does not indicate a stuck cache; only an unreleased OS lock would.
If extraction fails between rename and replace, the original <cache_key>.old.<pid>/ is renamed back. If the process is hard-killed during extraction, the OS releases the lock automatically; the per-pid tempdir may leak (it's safe to delete).
After a successful install, still under the lock, the slow path runs the automatic cache cleanup — pruning this app's older entries best-effort. The fast path never reaches this step.
Environment variables¶
The bootstrap recognizes these MOONLIT_* variables:
| Variable | Effect |
|---|---|
MOONLIT_ROOT |
Override the cache root. The path is Path(value).expanduser().resolve(). |
MOONLIT_FORCE_EXTRACT |
Force re-extraction even on a cache hit. Does not bypass the lock; only the existence-skip is suppressed. |
MOONLIT_ENTRY_POINT |
Override env.json.entry_point. Useful for testing. Same module:attr syntax. |
MOONLIT_DEBUG |
On a bootstrap-internal error, print the Python traceback after the moonlit: line. Also surfaces the automatic-cleanup diagnostics. Does not affect user-code traceback printing (Python's default excepthook handles those unconditionally). |
MOONLIT_NO_GC |
Disable the automatic cache cleanup (below) regardless of what the artifact was built with. |
MOONLIT_GC_KEEP_LATEST |
Override how many of this app's newest cache entries to keep (int ≥ 1). A malformed value is ignored, falling back to the artifact's baked-in value. |
MOONLIT_GC_GRACE |
Override the cleanup age grace, in seconds (int ≥ 0). Malformed → fall back. |
"Truthy" means present and non-empty after os.environ.get(name, ""). The empty string is treated as unset; MOONLIT_FORCE_EXTRACT=0 is non-empty hence truthy (surprising but consistent — the policy never special-cases "0", "false", or "no").
Names beginning with MOONLIT_ other than those above are reserved for future versions and ignored today. MOONLIT_BUNDLED_PYTHON was used by moonlit 0.3.0's runtime-extraction launcher; the v0.4.0 folder-bundle redesign retired it.
Automatic cache cleanup¶
By default, a built artifact prunes its own older cache entries automatically, so a recipient's cache root doesn't grow by one extracted site-packages on every rebuild. Recipients usually don't have the moonlit CLI, so this is the only cleanup they get for free. (Build with --no-gc to opt out.)
- When. Only on the slow path — right after a new build extracts (cold first run, or
MOONLIT_FORCE_EXTRACT), while the extraction lock is held. A warm cache hit does nothing extra and prints nothing. - What. Among cache entries for the same app (matched by normalized name), it keeps the newest
keep_latest(default 2) and deletes the rest — but only those older than the grace window (default 24h), and never the entry it just created. A different app's cache, unknown directories, and.tmp/.old/.lockorphans are never touched. - How safely. Each entry is deleted only after a non-blocking lock acquisition, so it never races a concurrent extractor of that entry. The whole pass is best-effort: any failure is swallowed and your app still runs. It is silent unless
MOONLIT_DEBUGis set. - Caveat. A cache-hit reader of an older build holds no lock and can't be detected. The keep-2 default, the 24h grace, and the same-app scope make reaping an in-use build unlikely but not impossible. If you run many builds of one app concurrently, raise
--gc-keep-latest/--gc-graceat build time, or setMOONLIT_NO_GCon the affected machine.
The policy is baked in at build time via --gc / --no-gc, --gc-keep-latest, and --gc-grace, recorded in env.json.gc, and overridable at runtime via the MOONLIT_NO_GC / MOONLIT_GC_KEEP_LATEST / MOONLIT_GC_GRACE variables above.
Runtime exit codes¶
The runtime exit-code namespace is independent from the build-time CLI's. Different process, different concerns.
| Code | Meaning |
|---|---|
| 0 | Success (entry point returned None, an int in [0, 255], or anything coercible to one). |
| 1 | Generic bootstrap-internal error: env.json missing or fails validation, archive unreadable, extraction I/O failure, _bootstrap collision in the staged tree, empty sys.argv[0], runtime Python's major.minor differs from env.json.python_version. |
| 2 | Entry-point resolution or return-value coercion failure: malformed entry point, module not importable, attribute not found on module, return value can't be coerced to an int. |
| 3 | Lock acquisition timed out (60 seconds). |
Other non-zero codes originate from user code via its own sys.exit() or the masked int() of its return value.
User-code exceptions propagate normally — Python's default sys.excepthook runs, the traceback prints unconditionally, and the process exits 1 from the unhandled exception.
Lock-timeout recovery¶
Because the lock is OS-managed, a hard-killed extractor does not wedge the cache — the kernel releases the lock on process death and the next invocation acquires it immediately. The persistent <cache_root>/<cache_key>.lock file on disk is expected and is not itself a sign of trouble.
A real timeout means another process is actively holding the lock for longer than 60 seconds (e.g. a very slow extraction on a contended volume, or a paused/stopped extractor process). In that case the next invocation exits 3 with:
moonlit: lock acquisition timed out (60.0s) at <path>; remove this file or set MOONLIT_FORCE_EXTRACT=1
To recover:
- Find and resume/terminate the process holding the lock — once it dies or releases, the next run proceeds normally.
- Or delete the lock file (
rm <cache_root>/<cache_key>.lock). This is safe only if you're confident no live process holds the lock; doing so while a sibling is mid-extraction races a concurrent opener.
MOONLIT_FORCE_EXTRACT=1 does not bypass the lock; it only suppresses the existence-skip after the lock is acquired. Two concurrent forced runs serialize correctly: the second sees the first's installed tree, replaces it via the atomic protocol, and the first reader is unaffected because it already holds an open addsitedir reference.
Python version check¶
Native-extension wheels (.pyd on Windows, .so elsewhere) are tagged with a cp<X><Y> ABI tag. Python's import machinery silently skips files whose tag doesn't match the running interpreter, surfacing as ModuleNotFoundError: No module named '<pkg>._core' rather than a clear "wrong Python" error. To turn that confusing failure into an actionable one, moonlit build stamps the target Python's major.minor into env.json.python_version, and the bootstrap rejects any mismatch up-front:
moonlit: this archive was built for Python 3.12, but you are running Python 3.13;
install a Python 3.12 interpreter or rebuild with `moonlit build --python <python-3.12>`
This check fires before cache resolution and extraction, so a wrong-Python invocation never touches the cache. By default the stamped value is the build host's sys.version_info.major.minor; pass --python-version <X.Y> to moonlit build to target a different ABI (cross-interpreter builds — see CLI reference and Getting started → Cross-interpreter builds).
Archives produced by older moonlit versions that predate this field omit python_version; the bootstrap skips the check in that case (forward-compatible — older .pyz files keep working under newer bootstraps and vice versa).
Threat model¶
env.json is not authenticated. A modified .pyz could ship a forged env.json and the bootstrap would trust it. Integrity verification is the --no-modify feature deferred to v0.2. The bootstrap does not auto-execute privileged behavior keyed solely on name.
env.json schema¶
For reference; the env.json produced by moonlit build looks like:
{
"build_id": "<64 hex chars>",
"built_at": "2026-05-09T15:23:01Z",
"entry_point": "myapp.cli:main",
"moonlit_version": "0.3.0",
"name": "myapp",
"python_shebang": "/usr/bin/env python3",
"python_version": "3.13",
"schema_version": 1
}
env.json is byte-identical between bundle and non-bundle builds (modulo the built_at timestamp). When the archive was built with --bundle-python, the bundled-Python state is observable from the folder layout sitting next to the inner .pyz (sibling <basename>.exe launcher and _python\python.exe interpreter) — env.json does not duplicate that signal. The v0.3.0 bundled_python sub-object inside env.json was retired in v0.4.0; a bundled_python field appearing in an older archive is silently ignored under the standard "unknown fields" forward-compatibility rule.
Validation is ordered (the first failure decides the error message): existence in archive, UTF-8 decode, JSON parse, top-level dict, schema_version is an integer (not bool) equal to 1, all required fields present, types correct, format checks (PEP 508 name regex, lowercase 64-hex build_id, module:attr entry point, %Y-%m-%dT%H:%M:%SZ built_at, non-empty moonlit_version, non-empty python_shebang with no embedded newline and no leading #!). The optional python_version field, when present, must match ^\d+\.\d+$ (major.minor only); when absent the runtime version check is skipped — see Python version check.