diff --git a/AGENTS.md b/AGENTS.md index cd676b1..8d83632 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -280,8 +280,10 @@ shipped the same image bytes); preventatively fixed for `PI_VERSION` + image. Verifies binaries, repo clones, runtime deployment (waits for keybindings + mempalace bridge + ≥4 extensions before sampling — fixes the parallel-build-load race documented in opencode-devbox c6f9d11 -2026-06-08), and image size threshold (3500 MB; revisit after a few -releases as actuals settle). +2026-06-08), build-time leftovers (see below), and image size threshold +(3800 MB in `SIZE_THRESHOLD_MB`; revisit after a few releases as actuals +settle — this doc said 3500 until 2026-09-11, after the bar had already +moved twice). If smoke fails on size threshold but build is otherwise fine: bump `SIZE_THRESHOLD_MB` in scripts/smoke-test.sh in a follow-up commit and @@ -289,6 +291,17 @@ re-run. The threshold exists to catch *runaway* growth (an accidental texlive bake-in, a forgotten chrome dependency), not to block ordinary upstream bumps. +**The size gate is not a substitute for naming the residue.** It carries +~225 MB of deliberate margin, so v1.9.1 shipped +131 MB of pure build +residue — 110 MB of it npm's own download cache under `/root/.npm`, the +rest foreign platform packages — and stayed green. Two named assertions +now cover exactly that: no foreign npm-11 platform packages beyond the +host arch (`@esbuild/*`, `@mariozechner/clipboard-*`), and no +`/root/.npm` in the image. Note the second one refuses to run as +non-root: `test ! -d /root/.npm` on mode-700 `/root` would otherwise +pass for the wrong reason, which is the failure shape to watch for in +any assertion about a path you may not be able to read. + ## Build pipeline notes - **Two-phase**: base + variant. Base is rebuilt only when diff --git a/CHANGELOG.md b/CHANGELOG.md index b6abd44..f3ed983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,79 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`). --- +## Unreleased + +**The v1.9.1 residual is attributed and fixed: it was mostly npm's own download +cache, not the platform binaries it looked like.** v1.9.1 pruned the 26 foreign +`@esbuild/` directories that npm 11 installs, which fixed the 431 MB +size-gate failure — but the image still shipped **+131 MB compressed over +v1.8.14**, nearly all of it in the single pi/extensions install layer (87 MB → +206 MB). v1.9.1's notes recorded the leftover as an open item with an explicit +hypothesis (the `@mariozechner/clipboard-*` family, same npm 11 behaviour, a +different package) and an explicit warning that the hypothesis was **not** a +measured cause. It was measured on 2026-09-11, after recreating onto v1.9.1, and +the hypothesis accounted for only a sixth of it: + +| item | v1.8.14 | v1.9.1 | delta | +|---|---|---|---| +| `/root/.npm/_cacache` — the build's npm download cache | 35.2 MB | 145.3 MB | **+110 MB** | +| `@mariozechner/clipboard-*` foreign platform packages (2 sites) | 0 MB | 21.1 MB | **+21 MB** | +| variant install layer, uncompressed total | 270.8 MB | 401.9 MB | +131 MB | + +That is the whole delta with no unexplained remainder. Both items are now +deleted in the **same layer** that creates them, in both the main install RUN and +the studio RUN: + +- **`purge_build_caches`** — `npm cache clean --force` plus `rm -rf /root/.npm`. + npm 11 caches every platform tarball it downloads, including the ones the + prune then deletes, so the cache grew far faster than the installed tree. + Nothing at runtime reads it: the build runs as root, the container runs as + `developer` with its own cache under `$HOME`. +- **`prune_foreign_esbuild` → `prune_foreign_natives`** — now covers both + measured families. For clipboard the keep-set is `clipboard-linux-$arch-gnu` + **and** `-musl`, because its napi-rs loader chooses between them at runtime + from its own `isMusl()` probe; the musl package is a 420-byte stub, so keeping + it is free insurance. The bare `@mariozechner/clipboard` wrapper has no + hyphen suffix and cannot match the pattern. + +**Verified on arm64 before writing the patch, which is why the order was +update-then-patch:** a widened `rm -rf` glob is the worst possible change to +write against a tree you cannot inspect, and there is no docker CLI inside the +container — but after a recreate the container *is* the image. The prune was +exercised against a copy of the real trees with foreign directories fabricated +back in (aix-ppc64, android-arm64, darwin-arm64, win32-x64, linux-x64): all +removed, host `linux-arm64` kept at both sites, 21 MB freed, and +`require('@mariozechner/clipboard')` still loads and exports all 18 functions. +`esbuild.transformSync` still compiles TS at both sites. v1.9.1's own arm64 +validation of the esbuild prune also passed here — CI could only smoke-test +amd64. + +**Two sentinel assertions in `smoke-test.sh`, because the size gate did not +catch this.** The gate has ~225 MB of deliberate margin, so 131 MB of pure +build residue stayed green. There are now named PASS/FAIL checks for *foreign +platform packages beyond the host arch* and for *`/root/.npm` being shipped* — +the latter deliberately refuses to run as non-root, because `test ! -d +/root/.npm` on mode-700 `/root` would otherwise pass for the wrong reason. The +size-failure diagnostics now also list cache paths: they previously enumerated +only `node_modules` and `/opt`, where these bytes were not. + +**Also fixed: the prune's own progress line was mangled.** `-printf '%f\\n'` +reaches the shell with both backslashes (confirmed from the published image's +recorded `created_by`), so `find` emitted a literal backslash and `tr` then ate +the `n` out of the name — v1.9.1 printed `esbuild platform dirs kept: li +ux-arm64`. Single backslash now. + +Deliberately **not** changed: `/tmp/node-compile-cache` (1.3 MB). The manifest +RUN at the end of `Dockerfile.variant` calls `pi --version` again, so deleting +it earlier only relocates those bytes into that layer — today's manifest layer +is 128 kB precisely because it finds the cache warm. + +Touches `Dockerfile.variant` and `scripts/smoke-test.sh` only: `Dockerfile.base` +is unchanged, so this needs no base rebuild and should **ride the next release** +rather than burn a cycle of its own. + +--- + ## v1.9.1 — 2026-09-10 **v1.9.0 was tagged but never published: its own smoke gate stopped it, and it diff --git a/Dockerfile.variant b/Dockerfile.variant index 89d5b8b..f606e9c 100644 --- a/Dockerfile.variant +++ b/Dockerfile.variant @@ -196,24 +196,70 @@ RUN set -e && \ done; \ return 1; \ } && \ - # prune_foreign_esbuild: npm 11 (shipped with Node 24) installs EVERY - # @esbuild/ optional binary instead of only the one matching the - # host — 26 platform dirs, 284 MB, for aix-ppc64/android/darwin/freebsd/ - # netbsd/openbsd/win32/s390x/riscv64/... that this image can never execute. - # Measured on pi-fork's tree: npm 10.9.8 -> 165 MB, npm 11.19.0 -> 449 MB, - # and the 165 MB figure reproduces what v1.9.0's predecessor actually shipped. + # prune_foreign_natives: npm 11 (shipped with Node 24) installs EVERY optional + # platform package of a native dependency, not just the one matching the host. + # TWO families are affected in this image, and BOTH have been measured — add a + # family here only after measuring it, never by widening the pattern on a hunch: + # + # @esbuild/ 26 dirs, 284 MB (found first, v1.9.1) + # @mariozechner/clipboard- 11 dirs, 12 MB per site, 10 MB foreign + # # esbuild declares those with os/cpu constraints, but npm 11 ignores them and # ALSO ignores --os/--cpu and an npmrc carrying os=/cpu= (all three measured). - # So prune explicitly, keeping only linux-$(node -p process.arch) so the same - # line is correct on amd64 and arm64. Verified after pruning that esbuild still - # works (transformSync compiles TS), i.e. this removes dead weight, not function. + # So prune explicitly, keeping only the host platform, computed from + # `node -p process.arch` so one line stays correct on amd64 and arm64. + # Measured on pi-fork's tree: npm 10.9.8 -> 165 MB, npm 11.19.0 -> 449 MB, + # and the 165 MB figure reproduces what v1.9.0's predecessor actually shipped. + # + # WHY THE CLIPBOARD FAMILY WAS ADDED (2026-09-11): v1.9.1 pruned @esbuild only + # and still shipped +131 MB compressed over v1.8.14. That residual was + # attributed by listing the PUBLISHED arm64 layer tarballs straight from the + # registry (there is no docker CLI inside the container, so `docker history` + # was not available): +110 MB /root/.npm/_cacache (purged below) and +21 MB of + # clipboard platform packages across the two install sites — 131 MB total, so + # the delta is now fully accounted for with no unexplained remainder. + # + # Keeping linux-$arch-{gnu,musl} is deliberate: clipboard's napi-rs loader + # tries ./.node then the platform package, per platform in try/catch, and + # chooses gnu vs musl at runtime from its own isMusl() probe — so both host-arch + # branches must survive. The musl package is a 420-byte stub, i.e. free. The + # bare wrapper `@mariozechner/clipboard` has no hyphen suffix and therefore + # cannot match the regex below. Verified on arm64 against a copy of the real + # tree before this was written: after pruning to those two, + # require('@mariozechner/clipboard') still loads and exports all 18 functions. + # esbuild likewise still compiles TS via transformSync at both install sites. + # This removes dead weight, not function. + # # MUST run in the SAME layer as the npm installs above: deleting in a later RUN # leaves the bytes in this layer and shrinks the image by nothing. - prune_foreign_esbuild() { \ - keep="linux-$(node -p process.arch)"; \ + # NOTE the single backslash in -printf '%f\n': Docker passes '\\n' through + # verbatim, so v1.9.1's doubled version printed a mangled "li ux-arm64" + # (find emitted a literal backslash, then `tr` translated the n out of the + # name). Confirmed from the published image's own recorded created_by. + prune_foreign_natives() { \ + arch="$(node -p process.arch)"; \ find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \ - ! -name "$keep" -prune -exec rm -rf {} + ; \ - echo "esbuild platform dirs kept: $(find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' -printf '%f\\n' 2>/dev/null | sort -u | tr '\\n' ' ')"; \ + ! -name "linux-$arch" -prune -exec rm -rf {} + ; \ + find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \ + ! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \ + -prune -exec rm -rf {} + ; \ + echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \ + } && \ + # purge_build_caches: the build's own download caches are NOT free — they land + # in whichever layer created them. Measured on the published v1.9.1 arm64 + # variant layer: root/.npm/_cacache was 145.2 MB of a 401.9 MB layer (35.2 MB + # in v1.8.14), the single biggest item in the +131 MB residual, because npm 11 + # caches every platform tarball it fetched — including the ones just pruned. + # Nothing at runtime reads it: the build runs as root, the container runs as + # `developer` with its own cache under $HOME (and $HOME/.pi is a volume). + # DELIBERATELY NOT purged here: /tmp/node-compile-cache (1.3 MB, written by + # `pi --version` below). The manifest RUN at the end of this file calls + # `pi --version` again, so deleting it here only relocates those bytes into + # that layer instead of removing them from the image — measured, not assumed: + # today the manifest layer is 128 kB precisely because it finds the cache warm. + purge_build_caches() { \ + npm cache clean --force >/dev/null 2>&1 || true; \ + rm -rf /root/.npm; \ } && \ if [ "${PI_VERSION}" = "latest" ]; then \ NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \ @@ -228,7 +274,8 @@ RUN set -e && \ git_fetch_ref "${PI_ATELIER_REPO}" "${PI_ATELIER_REF}" /opt/pi-atelier && \ (cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \ (cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \ - prune_foreign_esbuild && \ + prune_foreign_natives && \ + purge_build_caches && \ echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \ echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \ echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \ @@ -329,17 +376,25 @@ ARG PI_STUDIO_REF=main ARG PI_STUDIO_VERSION=none RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \ set -e; \ - # Same esbuild prune as the main install RUN — see the comment there. It has - # to be redefined because shell functions do not survive across layers, and - # it has to run in THIS layer because pi-studio's npm install happens here: - # deleting in a later RUN would leave the bytes in this layer and shrink - # nothing. pi-studio pulls its own pi-coding-agent copy, so it is a third - # ~274 MB site on top of the two in the non-studio variant. - prune_foreign_esbuild() { \ - keep="linux-$(node -p process.arch)"; \ + # Same prune + cache purge as the main install RUN — see the comments there. + # They have to be redefined because shell functions do not survive across + # layers, and they have to run in THIS layer because pi-studio's npm install + # happens here: deleting in a later RUN would leave the bytes in this layer + # and shrink nothing. pi-studio pulls its own pi-coding-agent copy, so it is + # a third ~274 MB site on top of the two in the non-studio variant — and its + # npm install refills /root/.npm, which the main RUN emptied in ITS layer. + prune_foreign_natives() { \ + arch="$(node -p process.arch)"; \ find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \ - ! -name "$keep" -prune -exec rm -rf {} + ; \ - echo "esbuild platform dirs kept: $(find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' -printf '%f\\n' 2>/dev/null | sort -u | tr '\\n' ' ')"; \ + ! -name "linux-$arch" -prune -exec rm -rf {} + ; \ + find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \ + ! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \ + -prune -exec rm -rf {} + ; \ + echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \ + }; \ + purge_build_caches() { \ + npm cache clean --force >/dev/null 2>&1 || true; \ + rm -rf /root/.npm; \ }; \ rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \ git -C /opt/pi-studio init -q && \ @@ -352,7 +407,8 @@ RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \ done; \ [ "$ok" = "1" ] && \ (cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \ - prune_foreign_esbuild && \ + prune_foreign_natives && \ + purge_build_caches && \ echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \ fi diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 5f76de8..3298322 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -28,6 +28,8 @@ # (human, --json, --quiet) # - (studio variant only, auto-detected) pi-studio cloned + prebuilt # client bundle present + registered via `pi install` +# - no foreign npm-11 platform packages (@esbuild, clipboard) beyond the host +# - no build-time npm cache (/root/.npm) shipped in the image # - image size within threshold set -euo pipefail @@ -879,6 +881,34 @@ exec_test "pi-fork extensions floor is [] (forks cannot write to the palace)" \ exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \ 'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok' +# ── Build-time leftovers (npm 11 bloat sentinels) ───────────────────── +# Both of these are worth a PASS/FAIL assertion rather than a size-gate +# diagnostic, because the size gate has ~225 MB of deliberate margin: v1.9.1 +# shipped +131 MB of pure build residue and stayed green. These name the +# residue directly, so a regression is legible instead of merely "bigger". +echo "" +echo "── Build-time leftovers ──" + +# npm 11 installs EVERY optional platform package of a native dependency, not +# just the host's (it ignores os/cpu, --os/--cpu and npmrc os=/cpu=). Two +# families are affected and pruned in Dockerfile.variant: @esbuild/ +# and @mariozechner/clipboard-. Keep-set is the host arch only, plus +# clipboard's gnu AND musl (its napi loader picks between them at runtime). +# Runs as root because the image declares no USER; that is also what lets the +# cache assertion below read /root. +run "no foreign platform packages (npm 11 sentinel)" \ + 'arch=$(node -p process.arch); bad=$(find /usr/lib/node_modules /opt -type d \( -regex ".*/@esbuild/[^/]+" -o -regex ".*/@mariozechner/clipboard-[^/]+" \) ! -name "linux-$arch" ! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" -prune -print 2>/dev/null); if [ -n "$bad" ]; then echo "foreign platform dirs shipped:" >&2; echo "$bad" >&2; du -sm $bad 2>/dev/null | sort -rn | head -5 >&2; exit 1; fi; echo ok' + +# The build's own npm download cache is not free: it lands in the layer that +# created it. v1.9.1 shipped 145 MB of /root/.npm/_cacache (35 MB in v1.8.14) +# — the largest single item in its +131 MB residual, and invisible to the +# size-gate diagnostics because those only looked under node_modules and /opt. +# Nothing at runtime reads it: root's cache, while the container runs as +# `developer`. NOTE the assertion must run as root or a permission error on +# mode-700 /root would make `test ! -d` pass for the wrong reason. +run "no build-time npm cache shipped (/root/.npm)" \ + 'test "$(id -u)" = "0" || { echo "assertion needs root to read /root" >&2; exit 1; }; if [ -e /root/.npm ]; then echo "/root/.npm shipped: $(du -sm /root/.npm | cut -f1) MB" >&2; exit 1; fi; echo ok' + # ── Image size ──────────────────────────────────────────────────────── echo "" echo "── Image size ──" @@ -920,9 +950,17 @@ else docker run --rm --entrypoint sh "$IMAGE" -c \ 'du -sm /usr/lib/node_modules/* /opt/* /usr/local/share/ms-playwright 2>/dev/null | sort -rn | head -12' \ 2>/dev/null | sed 's/^/ /' || echo " (could not inspect directories)" - echo " ── @esbuild platform dirs (npm 11 regression sentinel) ──" + echo " ── build caches that should not be in the image ──" + # v1.9.1's residual was 145 MB of npm cache under /root, and the du list + # above cannot see it: it enumerates node_modules and /opt only. A gate whose + # diagnostic looks only where the bytes were LAST time sends the next reader + # spelunking again, so name the cache paths explicitly. docker run --rm --entrypoint sh "$IMAGE" -c \ - 'find /usr/lib/node_modules /opt -type d -regex ".*/@esbuild/[^/]+" -printf "%f\n" 2>/dev/null | sort | uniq -c | sort -rn | head' \ + 'du -sm /root/.npm /root/.cache /tmp/node-compile-cache /home/developer/.npm 2>/dev/null | sort -rn' \ + 2>/dev/null | sed 's/^/ /' || true + echo " ── foreign platform dirs (npm 11 regression sentinel) ──" + docker run --rm --entrypoint sh "$IMAGE" -c \ + 'find /usr/lib/node_modules /opt -type d \( -regex ".*/@esbuild/[^/]+" -o -regex ".*/@mariozechner/clipboard-[^/]+" \) -printf "%f\n" 2>/dev/null | sort | uniq -c | sort -rn | head' \ 2>/dev/null | sed 's/^/ /' || true fi