plgc: cache the extracted libplg_runtime.a instead of re-extracting ~21MB per run #4

Closed
opened 2026-06-05 00:42:11 +00:00 by navicore · 3 comments
Owner

Summary

Every plgc run/build extracts the embedded libplg_runtime.a (~21MB) to a
fresh temp dir before linking. For tools that invoke plgc at high frequency
(loglings runs plgc run <file> --query test on every editor save), this is
~21MB of extract+write per call, and the per-run temp dirs are what turn a
misbehaving caller into a disk-space incident.

Context

A loglings watcher bug briefly invoked plgc run ~7×/second. The side effect
was /tmp filling with 1696 plgc-* temp dirs (~36GB), each containing
only the freshly-extracted libplg_runtime.a — i.e. plgc had extracted the
runtime but the dir was never reclaimed.

On reproducibility

Normal cleanup looks robust — I could not reproduce the leak in isolation:
single run, 30× rapid-sequential, 30× concurrent, SIGINT-mid-run, and
SIGKILL-mid-run all clean up (temp-dir delta 0). The accumulation appears tied
to sustained high-frequency invocation and/or low-disk conditions (once /tmp
fills, cleanup that writes/renames can itself start failing, compounding). So
this is less "cleanup is broken" and more "the per-run extraction is a fragile,
expensive design under load."

Suggestion

Extract libplg_runtime.a once to a stable, content-addressed cache and
reuse it across runs, instead of a fresh per-run temp copy — e.g.
$XDG_CACHE_HOME/plgc/runtime-<version-or-hash>.a (fall back to
$TMPDIR/plgc-runtime-<version>/). Benefits:

  • removes ~21MB of extraction work per invocation (big win for plgc run
    callers),
  • shrinks the temp footprint so a runaway caller can't fill the disk,
  • sidesteps the cleanup-under-load fragility (a cache artifact is meant to
    persist, not be reclaimed each run).

The loglings-side trigger (a watcher feedback loop) is already fixed; filing
this because the per-run re-extraction is a real efficiency issue for any
frequent caller, and a cached runtime would have kept the incident from ever
reaching 36GB.


Reported against plgc 0.1.0.

## Summary Every `plgc run`/`build` extracts the embedded `libplg_runtime.a` (~21MB) to a fresh temp dir before linking. For tools that invoke `plgc` at high frequency (loglings runs `plgc run <file> --query test` on every editor save), this is ~21MB of extract+write per call, and the per-run temp dirs are what turn a misbehaving caller into a disk-space incident. ## Context A loglings watcher bug briefly invoked `plgc run` ~7×/second. The side effect was `/tmp` filling with **1696 `plgc-*` temp dirs (~36GB)**, each containing only the freshly-extracted `libplg_runtime.a` — i.e. plgc had extracted the runtime but the dir was never reclaimed. ## On reproducibility Normal cleanup looks robust — I could **not** reproduce the leak in isolation: single run, 30× rapid-sequential, 30× concurrent, SIGINT-mid-run, and SIGKILL-mid-run all clean up (temp-dir delta 0). The accumulation appears tied to *sustained* high-frequency invocation and/or low-disk conditions (once /tmp fills, cleanup that writes/renames can itself start failing, compounding). So this is less "cleanup is broken" and more "the per-run extraction is a fragile, expensive design under load." ## Suggestion Extract `libplg_runtime.a` **once** to a stable, content-addressed cache and reuse it across runs, instead of a fresh per-run temp copy — e.g. `$XDG_CACHE_HOME/plgc/runtime-<version-or-hash>.a` (fall back to `$TMPDIR/plgc-runtime-<version>/`). Benefits: - removes ~21MB of extraction work per invocation (big win for `plgc run` callers), - shrinks the temp footprint so a runaway caller can't fill the disk, - sidesteps the cleanup-under-load fragility (a cache artifact is meant to persist, not be reclaimed each run). The loglings-side trigger (a watcher feedback loop) is already fixed; filing this because the per-run re-extraction is a real efficiency issue for any frequent caller, and a cached runtime would have kept the incident from ever reaching 36GB. --- Reported against `plgc` 0.1.0.
Author
Owner

Root cause found — the leak is unconditional, not load-dependent.

crates/compiler/src/link.rs::extracted_runtime() extracts the embedded
runtime to a pid-keyed dir:

let dir = std::env::temp_dir().join(format!("plgc-{}", std::process::id()));

and nothing in the codebase ever removes it (remove_dir_all appears nowhere;
the tempfile::tempdir() cleanups in main.rs cover only the output binary).
So every plgc process that links leaks one ~21MB /tmp/plgc-<pid> dir
single runs included. loglings sees ~27 new dirs per watch start (one per
exercise compiled in its startup scan).

My earlier "cleanup looks robust / can't reproduce in isolation" was a
measurement artifact: with 1696 leaked dirs spanning a wrapped pid range, new
processes collided with existing dir names (create_dir_all is idempotent),
so the dir count didn't move.

Concrete fix (and it makes runs faster, too): key the dir by version
instead of pid, so every process reuses one stable extraction:

let dir = std::env::temp_dir().join(format!("plgc-runtime-{}", env!("CARGO_PKG_VERSION")));
  • if libplg_runtime.a already exists with the right length, use it (skip the
    21MB write entirely);
  • otherwise write to a temp name in the same dir and rename into place
    (atomic — safe under concurrent plgc processes);
  • the per-process OnceLock already prevents repeat work within a process.

The pid-keyed comment says it avoids races between concurrent processes — the
write-then-rename gives the same safety with a single reusable path and zero
accumulation.

**Root cause found — the leak is unconditional, not load-dependent.** `crates/compiler/src/link.rs::extracted_runtime()` extracts the embedded runtime to a **pid-keyed** dir: ```rust let dir = std::env::temp_dir().join(format!("plgc-{}", std::process::id())); ``` and nothing in the codebase ever removes it (`remove_dir_all` appears nowhere; the `tempfile::tempdir()` cleanups in main.rs cover only the output binary). So **every plgc process that links leaks one ~21MB `/tmp/plgc-<pid>` dir** — single runs included. loglings sees ~27 new dirs per watch start (one per exercise compiled in its startup scan). My earlier "cleanup looks robust / can't reproduce in isolation" was a measurement artifact: with 1696 leaked dirs spanning a wrapped pid range, new processes collided with existing dir names (`create_dir_all` is idempotent), so the dir count didn't move. **Concrete fix** (and it makes runs faster, too): key the dir by version instead of pid, so every process reuses one stable extraction: ```rust let dir = std::env::temp_dir().join(format!("plgc-runtime-{}", env!("CARGO_PKG_VERSION"))); ``` - if `libplg_runtime.a` already exists with the right length, use it (skip the 21MB write entirely); - otherwise write to a temp name in the same dir and `rename` into place (atomic — safe under concurrent plgc processes); - the per-process `OnceLock` already prevents repeat work within a process. The pid-keyed comment says it avoids races between concurrent processes — the write-then-rename gives the same safety with a single reusable path and zero accumulation.
Author
Owner

(duplicate of the comment above — posted twice by tooling, please ignore)

(duplicate of the comment above — posted twice by tooling, please ignore)
Author
Owner

Implemented (working tree, pending commit). Final design — refined from the
discussion: the cargo version is not a valid cache key (dev rebuilds embed
different bytes under the same version), and a binary hash / build timestamp
over- invalidate. The exact key is a build-time FNV-1a hash of the archive
bytes themselves
.

  • build.rs: hashes libplg_runtime.a while locating it for include_bytes!
    and emits cargo:rustc-env=PLG_RUNTIME_HASH=<16-hex>. Deterministic,
    dependency-free, zero runtime cost; rerun-if-changed already covers it.
  • link.rs::extracted_runtime(): materializes the archive at
    $XDG_CACHE_HOME/plgc/runtime-<hash>/libplg_runtime.a (→ ~/.cache/plgc,
    → temp-dir fallback for HOME-less environments). Fast path reuses an
    existing extraction (length-checked); slow path writes a process-unique
    temp name and atomically renames into place, then sweeps stale
    runtime-* siblings. The archive keeps its canonical name because clang
    links via -L <dir> -lplg_runtime; the hash lives in the directory name.
    The per-process OnceLock is retained for parallel in-process compiles.

Verified:

  • 20 sequential runs → 1 cache dir, 0 new /tmp/plgc-* dirs
  • 20 concurrent cold-cache runs → 20/20 succeed, 1 dir, 0 temp leftovers
  • stale-sweep: planted runtime-deadbeefdeadbeef removed on next link
  • full just ci green

Net effect: nothing accumulates anywhere, and warm runs skip the 21MB write
entirely (loglings' per-save checks get faster as a side effect).

**Implemented** (working tree, pending commit). Final design — refined from the discussion: the cargo version is *not* a valid cache key (dev rebuilds embed different bytes under the same version), and a binary hash / build timestamp over- invalidate. The exact key is a **build-time FNV-1a hash of the archive bytes themselves**. - `build.rs`: hashes `libplg_runtime.a` while locating it for `include_bytes!` and emits `cargo:rustc-env=PLG_RUNTIME_HASH=<16-hex>`. Deterministic, dependency-free, zero runtime cost; `rerun-if-changed` already covers it. - `link.rs::extracted_runtime()`: materializes the archive at `$XDG_CACHE_HOME/plgc/runtime-<hash>/libplg_runtime.a` (→ `~/.cache/plgc`, → temp-dir fallback for HOME-less environments). Fast path reuses an existing extraction (length-checked); slow path writes a process-unique temp name and atomically `rename`s into place, then sweeps stale `runtime-*` siblings. The archive keeps its canonical name because clang links via `-L <dir> -lplg_runtime`; the hash lives in the directory name. The per-process `OnceLock` is retained for parallel in-process compiles. Verified: - 20 sequential runs → **1** cache dir, **0** new `/tmp/plgc-*` dirs - 20 **concurrent cold-cache** runs → 20/20 succeed, 1 dir, 0 temp leftovers - stale-sweep: planted `runtime-deadbeefdeadbeef` removed on next link - full `just ci` green Net effect: nothing accumulates anywhere, and warm runs skip the 21MB write entirely (loglings' per-save checks get faster as a side effect).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
navicore/patch-prolog#4
No description provided.