Windows Build Image#
Building a large project inside this image and want it to be fast? See Windows Container Build Performance — measured results for incremental builds, plus the approaches that do not work (sccache on C++23 modules, named volumes as build directories).
Important (Antivirus): On Windows, exclude your development folder from antivirus scanning. Real-time protection can lock files during builds (especially during CMake FetchContent and cargo builds), causing intermittent failures with errors like “Failed to remove directory” or “(os error 32)”. Add your project directory to your antivirus exclusion list.
Source Patch Policy#
This repository applies a patch-first policy to upstream sources on the Windows lane. Default: extract upstream modifications into a reviewable .patch file under windows/scripts/patches/<component>/NNN-<slug>.patch, applied via the canonical idempotent helper Invoke-SourcePatch (windows/scripts/modules/WindowsSourceBuild.Common.psm1). Every .patch file:
Is a standard
git diff/ unified diff (a//b/prefix,-p1strip).Applies idempotently:
Invoke-SourcePatchrunsgit apply --reverse --checkfirst and skips if already applied; falls back topatch.exe -p1for non-git tarball extractions; throws loudly with the patch file’s first 40 lines on failure.Targets a pinned upstream version (e.g. the file header references the git tag in
linux/scripts/01-core/versions.env).
Exceptions (inline patches are intentional and documented):
Generated build files — patches targeting FFmpeg’s generated
ffbuild/*.mak,library.mak,subdir.mak,Makefile,ffbuild/config.mak(post-configure output; content varies per./configureinvocation) AND theUpdate-NinjaFilecalls inbuild-onnx-from-source.ps1/build-onnx-genai-from-source.ps1that strip MSVC-only flags from CMake-generatedbuild.ninja(same family — generated content varies per CMake configure). Inline-replaceon invariant sub-sequences (-showIncludes,EXTRALIBS-lib*=,/experimental:external,/Qspectre) is the canonical form for both.Fetched third-party deps whose pinned version floats —
Edit-CppKeywordAlternativeswalks CUTLASS headers fetched by ONNX Runtime’s ExternalProject at configure time, AND the companion_udiv128 → udiv128substitution oncutlass/uint128.h(clang-cl lacks the MSVC-only intrinsic). The CUTLASS fetched SHA varies with the provider’scutlass-srcExternalProject pointer; a static.patchagainst a pinned tag would silently rot. The helper form + the targeted inline regex are canonical.Multi-file conditional substitutions — LiteRT’s
proto/CMakeLists.txtdisable loop (build-litert-from-source.ps1) walks ~17 files under$tfliteSrcand skips files whose content already lacksprotobuf_generate|protoc. A static.patchagainst a pinned LiteRT tag cannot express the per-file predicate and would only cover a fraction of the proto directories. Similarly, the OpenCV mlas<cstring>prepend loop (build-opencv-from-source.ps1) walks every3rdparty/mlas/**/*.cppand skips files that already include<cstring>— same canonical-form rationale.Installed toolchain headers (not the upstream source tree) —
build-onnx-genai-from-source.ps1patches the installed MSVC STLyvals_core.h(wrapping the single_EMIT_STL_ERRORdefine in#ifdef __clang__, which no-ops every STL error code — STL1009/1010/1011, etc. — under clang-cl, so no per-header patch such as one for<experimental/coroutine>is needed). The MSVC toolset version floats (resolved viaGet-MsvcToolsRoot), so a static.patchagainst a pinned MSVC build would only work for one toolset version; the edit is guarded by a drift-assertion that fails the build loudly if a future toolset changes the macro’s format.Binary byte-filter edits —
onnxruntime.rcnon-ASCII byte stripping (-le 127) is a byte filter, not a textual diff. Not expressible as unified diff.Single-file regex edits on aggressively-changing generated-as-schema upstream files — The OpenCV
add_extra_compiler_option(-include cstring)removal (plus surrounding CMake add-to-flags lines oncmake/OpenCVCompilerOptions.cmake) is kept inline not because a.patchcouldn’t be authored today, but because the upstream context drifts enough between minor releases that a static.patchwould need re-generation on every tag bump:build-opencv-from-source.ps1—cmake/OpenCVCompilerOptions.cmake-include cstringremoval
Upstream export-gap bridges (LiteRT-LM v0.14.0) — FROZEN FALLBACK ONLY. The primary LiteRT-LM build is now Bazel (
build-litert-lm-bazel.ps1), which is the path Google CI-tests and does NOT need any of these bridges; everything in this item applies only to the retired CMake fallbackbuild-litert-lm-from-source.ps1. Google ships LiteRT-LM tags whose CMake layer lags the source restructure (v0.14.0’s was never buildable anywhere: it references the deletedconstrained_decodingcomponent, pins a LiteRT from before thesupport/tree its own shim headers#include " from @litert", and compiles none of the newlogits_processor/support subsystems).build-litert-lm-from-source.ps1bridges this with condition-gated blocks ([LiteRTLM-winfix export-stubs],[LiteRTLM-winfix support-graft], the v0.14-orphans + v0.14-deps blocks): stub CMakeLists are generated, thesupport/tree is sparse-cloned from LiteRT at the version this container already ships (LITERT_VERSION), and orphaned sources are injected into the engine lib. Static.patchfiles cannot express “graft a tree from another repo at a configurable tag” or “only when the referenced dir is missing” — and every block is gated on the breakage itself, so a future tag with a fixed export takes upstream’s files untouched and the bridge self-retires. The Gemma constraint provider is upstream’s prebuilt-only DLL component: its import lib is linked on the exe and the DLL staged besidelitert_lm_main.exe(withz.dll+kissfft-float.dll, found viallvm-objdump -pafter the exe died 0xC0000135 without them).
Every inline substitution in a build script carries a # Inline patch (kept inline, NOT a .patch file): block comment explaining the canonical-form rationale. The current .patch inventory:
Component |
Patch |
Upstream target |
Purpose |
|---|---|---|---|
FFmpeg |
|
|
Replace |
GStreamer |
|
|
|
ONNX Runtime |
|
|
Change the one real ISO-646 |
ONNX Runtime |
|
|
Disable CUDA EP |
ONNX Runtime |
|
DirectML EP (5 files under |
clang-cl + |
ONNX Runtime |
|
|
ORT 1.28.0 + CUDA 13.3: |
ONNX Runtime |
|
|
ORT 1.28.0 XQA (paged-attention) kernels: the host-pass include guard keys on the cmake define |
ONNX Runtime |
|
|
sccache’s nvcc decomposition crashes its server deterministically on the fused_moe_gemm generated launchers (two chain runs died at ~4910 s, |
OpenCV |
|
|
CMP0146/CMP0148 OLD→NEW + clang-cl/CUDA detection compat. REGENERATED against 5.0.0 on 2026-08-10 (5.0.0 dropped the |
OpenCV |
|
|
OpenCV 5.0.0’s bundled MLAS treats clang-cl as GNU-Clang and passes the GNU pair |
OpenCV |
|
|
Skip the vendored MLAS on Windows: its kernels are GAS/ELF-only ( |
OpenCV |
|
|
UPSTREAM BUG (5.0.0, run-13 find): dnn’s ORT |
OpenCV (contrib) |
|
|
Add |
ffmpeg/makedef is not a patch — it is a whole-file replacement script staged over FFmpeg’s makedef (a byte swap, not a diff), so it is not in the table above.
When bumping any upstream version, audit these .patch files before letting the orchestrator loose: run windows/scripts/tests/Test-PatchesApplyClean.ps1, which clones each pinned upstream and runs the exact git apply --check the build uses (see windows/scripts/patches/README.md). If a patch no longer applies, regenerate with git diff against the new tag and update the inventory above.
The Windows container build uses Stevedore (a Docker distribution for Windows Containers) and is split into staged images:
windows/Dockerfile.basebuilds the cached Windows toolchain base image (CMake 4.4.2, VS Build Tools 18, LLVM/Clang 22, Rust, Flutter, WiX 4).windows/Dockerfile.nvidia(optional GPU layer) layers CUDA 13.3 + cuDNN 9.25.0.15 + TensorRT 11.2.1.2 on top of the base image and is taggedwindows-sdk. If skipped, the base image is taggedwindows-sdkdirectly (docker tag; the former no-opDockerfile.sdkshim was removed) and downstream stages perform CPU-only builds (CUDA auto-detection falls back toCPU-only build).windows/build.ps1handles this automatically via its-Gpuswitch.The toolchain stage builds CPython 3.14 from source (matching the canonical versions.env) via
windows/Dockerfile.toolchain-builder+build-toolchain-all.ps1(run+commit for full cores; the former standaloneDockerfile.toolchainwas removed as dead code — it duplicated the builder without the nuget pre-seed fix).The media stage fans out into three branch images by
windows/build.ps1, built sequentially (media-core first — it alone gets the whole RAM budget, maximizing ONNX parallelism). All three branches share ONE multi-stage builder,windows/Dockerfile.media-builder, selected per branch via--target <name>; then the stage fans in:media-core (
--target media-core+build-media-core-all.ps1, run+commit) — the ONNX dependency chain, sequential: ONNX Runtime 1.28.0 (source build; CUDA EP enabled when the NVIDIA layer was used, DirectML EP always via the clang-cl patch) → ONNX GenAI 0.15.2 (CMake+clang-cl, bypassingbuild.py; built withUSE_DML=ON+USE_CUDA=ON, telemetry off) → OpenCV 5.x (CMake+Ninja+clang-cl, CUDA auto-detected, detects the source-built ONNX Runtime) → FFmpegn9.0(pinned release tag,FFMPEG_VERSIONin versions.env since 2026-08-04; MSVC toolchain via MSYS2 bash;--enable-libonnxruntimelinks FFmpeg’s DNN filters against the source-built ONNX Runtime — note there is no separate--enable-dnnflag; DNN filters come with the backend).media-litert (
--target media-litert+build-litert-all.ps1) — LiteRT 2.1.6 (CMake+Ninja; also builds the TFLite C-API libtensorflowlite_c) → LiteRT-LM 0.15.0 (independent of ONNX; built via Bazel withbuild-litert-lm-bazel.ps1→litert_lm_main.exe. The former CMake export-bridge path (build-litert-lm-from-source.ps1) is a frozen fallback, see § Source Patch Policy #7).media-tvm (
--target media-tvm+build-media-tvm-all.ps1) — TVM 0.25.0 → IREE (both LLVM-heavy ML compilers; each installs its Python wheels into the source-built CPython; IREE native tools land atC:\runtime\iree,IREE_ROOT/IREE_BIN).merge (
Dockerfile.media-merge-builder):COPY --fromfan-in of the three branch trees into oneC:\runtime+ canonical env layout, plus acuda-runtime-stage(viastage-cuda-runtime.ps1) that FLATTENS the CUDA/cuDNN runtime DLLs intoC:\runtime\cuda-runtime\binon PATH — the CUDA-linked libs (notably OpenCV, which hard-linkscudnn64_9.dll) otherwise fail to load in this non-nvidia-based image. Then GStreamer 1.29.2 is built viabuild-gstreamer-from-source.ps1in the run+commit step (Meson + clang-cl; auto-detects CUDA, OpenCV, ONNX and FFmpeg from the merged tree).
windows/Dockerfile.torchassembles the Orchestr-ANT-ion app env on the media image (media → torch → final; taglocal/kataglyphis:windows-torch), andwindows/Dockerfileproduces the final developer image FROM that torch image (VsDevCmd entrypoint).
Component Build Matrix#
The authoritative per-library build reference for the Windows lane (AGENTS.md § Windows Build Notes points here — update THIS table, never a copy). Versions are pinned in linux/scripts/01-core/versions.env.
Component |
Generator |
Compiler |
Notes |
|---|---|---|---|
CPython 3.14 |
|
ClangCL (v145→ClangCL via Directory.Build.props) |
Requires VS ClangCL toolset |
ONNX Runtime 1.28.0 |
Ninja |
clang-cl, lld-link |
DirectML EP enabled ( |
ONNX GenAI 0.15.2 |
CMake (Ninja) |
clang-cl, lld-link |
Source-built directly via CMake (bypasses |
OpenCV 5.x |
Ninja |
clang-cl, lld-link |
Global SIMD flags: AVX2, SSSE3, SSE4.1/4.2. CUDA auto-detected. Custom |
LiteRT 2.1.6 |
Ninja |
clang-cl, lld-link |
GPU delegate enabled (Vulkan + OpenCL backends). XNNPACK enabled. CUDA paths exposed for external delegate. Also builds the TFLite C-API shared lib |
LiteRT-LM 0.15.0 |
Bazel |
clang-cl, lld-link |
On-device LLM inference, built via |
TVM 0.25.0 |
Ninja |
clang-cl, lld-link |
Auto-detects CUDA/Vulkan/LLVM. Builds a Python wheel. VsDevCmd environment loaded for MSVC STL headers. |
FFmpeg |
MSYS2 |
clang-cl via |
Source build from the pinned release tag ( |
GStreamer 1.29.2 |
Meson |
clang-cl |
Downloaded as tarball + subproject wraps. CUDA auto-detected. |
Prerequisites#
Provisioning a FRESH machine? Follow the ordered checklist in Fresh Windows Host Bring-Up — it sequences everything on this page (Stevedore install, CNI conf, debug flags, GC policy, Defender exclusions, dufs/sccache, gate tooling) into one admin/non-admin-marked path with a verify command per step.
Install Stevedore:
# WinGet (recommended)
winget install stevedore
# WinGet — custom install directory (e.g. D: NVMe dev drive)
winget install stevedore --custom="INSTALLDIR=D:\Stevedore"
# or Chocolatey
choco install stevedore
If you used a custom INSTALLDIR, substitute D:\Stevedore\bin\docker.exe for "%ProgramFiles%\Stevedore\bin\docker.exe" in all commands below.
Reboot after installation. This enables the Windows Containers feature and adds your user to the docker-users group.
Tool roles on this host. Stevedore’s bundled docker.exe is the
classic-lane tool for builds, runs and publishing: Docker Engine provides NAT
networking natively, no CNI plugin needed. Since 2026-08-03 the CNI nat
conf (C:\Program Files\containerd\cni\conf\0-containerd-nat.conf; the
nat.exe binary always shipped in ...\cni\bin) is installed on this host —
see § Getting it going, step 2, including the subnet-drift trap — so
containerd-side networking works too, and nerdctl runs the bk-* images
fine. nerdctl needs an admin shell (containerd’s pipe is admin-only
upstream); the pre-conf state where nerdctl run failed with needs CNI plugin "nat" and nerdctl build had broken DNS is historical.
Tool |
Build |
Run |
|---|---|---|
|
✅ classic lane |
✅ Works (NAT + DNS + process isolation) |
|
✅ preferred lane |
n/a |
|
✅ Works (verified 2026-08-07) — but the chain still uses |
✅ Works — needs the CNI nat conflist, see § nerdctl lane |
Build Commands#
Preferred since 2026-08: the BuildKit/containerd lane —
.\windows\build-buildkit.ps1 -Gpubuilds the same Dockerfiles with process isolation (full host CPUs, no Hyper-V 2-CPU cap, no run+commit) and real per-stage layer caching. One-time host setup + launch: see § BuildKit/containerd lane below. Thebuild.ps1commands here are the docker-classic fallback lane (Hyper-V + run+commit) and remain fully supported.
Use the driver script from the repository root. It parses linux/scripts/01-core/versions.env
and passes every version as --build-arg (the Dockerfile ARG defaults are only
fallbacks), builds the stages in order, and applies the correct tags:
# CPU lane (default): base -> tag sdk -> toolchain -> media -> torch -> final
.\windows\build.ps1
# GPU lane: base -> nvidia (CUDA + cuDNN + TensorRT, tagged sdk) -> toolchain -> media -> torch -> final
# Requires a TensorRT zip in windows/downloads/ (see AGENTS.md § TensorRT Setup).
.\windows\build.ps1 -Gpu
# Iterate on a single stage (layer cache makes this cheap):
.\windows\build.ps1 -Gpu -Stages media,final
# Deliberate clean rebuild (only when you really need it — this discards ALL layer
# caching and rebuilds everything from scratch, which takes many hours):
.\windows\build.ps1 -Gpu -NoCache
# Orchestr-ANT-ion app stage (windows/Dockerfile.torch, mirror of linux/Dockerfile.torch):
# a chain stage between media and final (media -> torch -> final) — it assembles the
# app env at APP_REF on windows-media, and the final image builds FROM it. An APP_REF
# bump therefore rebuilds torch + the cheap final tail only (minutes, network-bound):
.\windows\build.ps1 -Stages torch,final # versions.env APP_REF pin
.\windows\build.ps1 -Stages torch,final -LatestApp # newest release tag
# On a host WITHOUT local chain images, iterate on the published image instead:
.\windows\build.ps1 -Stages torch,final -TorchBaseImage ghcr.io/kataglyphis/kataglyphis_beschleuniger:winamd64
# Tags: torch -> local/kataglyphis:windows-torch (-TorchTag overrides; final builds FROM it).
Docker layer caching is on by default: the Dockerfiles are ordered so that
editing one build script only rebuilds that script’s stage and later ones.
-Docker overrides the docker.exe path (default: $env:DOCKER_EXE, then the
Stevedore install locations, then docker on PATH). Set
KEEP_BUILD_ARTIFACTS=1 (e.g. via a temporary ENV line in a media
Dockerfile) to keep the C:\temp\*-src build trees for debugging; by default
each build script removes its source tree after installing so the trees don’t
bloat the image layers.
Mandatory GStreamer plugins (the contract)#
libav, opencv, onnx and tflite are required in a shipped image. They
were absent from the published winamd64 for months and nothing was red:
meson’s auto feature state means skip silently when the dependency is
missing, the build logged [INFO] not available, and the healthcheck printed
[PASS] for plugins that did not exist.
The set lives in one place — Get-RequiredGstPlugin
(windows/scripts/modules/WindowsScripts.Shared.psm1) — and is enforced at four
points that used to disagree:
Where |
What it does |
On failure |
|---|---|---|
pre-flight, |
emits the missing |
throws in seconds, before a ~1 h configure+compile |
meson setup |
|
configure fails loudly instead of skipping |
post-install gate |
|
throws — proves the plugin loads, not just that it configured |
smoke test |
same set, as assertions |
fails the suite |
Three unrelated root causes, diagnosed against gstreamer 1.29.2 sources:
opencv —
dependency('opencv4', '>= 4.0.0'). OpenCV installs no.pcunlessOPENCV_GENERATE_PKGCONFIGis set, and it would be namedopencv5.pcanyway. Upstream dropped the old< 4.xupper bound, so OpenCV 5 is version-acceptable — it just needs a file under the name meson looks up.onnx —
dependency('libonnxruntime', '>= 1.16.1')thensubdir_done(). ORT ships no.pcon any platform.libav — nothing to do with
.pcfiles.subprojects/FFmpeg.wrapprovides the fourlibav*modules pinned to FFmpeg 7.1.1, and-Dwrap_mode=forcefallbackforces meson to use it, so pkg-config was never consulted: the build was fetching and compiling a second, older FFmpeg instead of then9.0it had just built. Even succeeding would have shipped gst-libav linked against a different FFmpeg than the image’s ownffmpeg.exe. The wrap is now moved aside before configure.tflite — a fourth mechanism again: this plugin consults no pkg-config at all.
ext/tflite/meson.buildprobes the compiler directly withcc.find_library('tensorflowlite_c')(fallbacktensorflow-lite),cc.has_function('TfLiteInterpreterCreate')andcc.has_header('tensorflow/lite/c/c_api.h'). That header path is the pre-rename TensorFlow one, while LiteRT v2.x ships the post-rename layout —build-litert-from-source.ps1stages headers underinclude\tflite\, so upstream’s probe could never find them regardless of any.pcfile. It is a namespace mismatch, not a missing dependency, which is why it never looked like the opencv/onnx problem. The pre-flight mirrors the header tree toinclude\tensorflow\lite\, resolves the C API library by name (failing with the list of what is staged if neither candidate exists), and puts the LiteRT include/lib dirs onINCLUDE/LIB— the only mechanismcc.find_libraryandcc.has_headeractually consult — as well as intoc_args/cpp_argsand the link args so the plugin’s own compile and link succeed.
Both .pc files are authored by the merge stage, not by the OpenCV/ONNX
builds: those are the two most expensive layers in the chain (~30 and ~75
minutes) and emitting a text file is not worth invalidating them. The emitter
reads the canonical env contract (OPENCV_ROOT, OPENCV_LIB, ONNX_ROOT,
ONNX_VERSION) that the merge image already defines, finds the header root
rather than assuming it, and enumerates link names from the actual lib
directory so an OpenCV module-list change cannot rot into a link error.
tensorfilteris not a GStreamer plugin. It is an NNStreamer element and this repo does not build NNStreamer; it appeared in the old probe lists purely because the lying healthcheck “found” it. Requiring it would fail every build forever. Wanting it means adding an NNStreamer source-build stage.
PROVEN 2026-08-13: all four mandatory plugins (
libav,opencv,onnx,tflite) build AND load in the merge image — the post-installgst-inspectgate passes for all of them.gst-libavcompiles and loads against the image’s own FFmpegn9.0(the wrap is disabled so it links our FFmpeg, not upstream’s pinned 7.1.1). Getting there took an OpenCV-4→5 header port of the opencv plugin, atensorflowlite_cC-API lib for tflite, and deploying the CUDA/cuDNN runtime so opencv’scudnn64_9.dllresolves (see the merge-stage notes above and thegstreamer-merge-winfixbuild memory).-SkipPluginGatestill exists as the deliberate escape hatch; an image built with it is not shippable.
Toolchain pins and the provenance manifest#
Everything that produces or shapes compiled output is pinned in
versions.env and asserted at base-build time by verify-toolchain.ps1:
Pin |
Installs |
Why it is pinned |
|---|---|---|
|
scoop |
clang-cl + lld-link compile the entire media chain, and five patches under |
|
scoop |
build-graph executor for every CMake source build |
|
scoop |
assembles FFmpeg’s hand-written x86 SIMD — a bump changes shipped object code |
|
scoop / installer |
pre-existing pins, unchanged |
The LLVM pin landed 2026-08-07 and closed a real hole: the OS base is
digest-pinned (WINDOWS_BASE_DIGEST) for reproducibility, and the very next
layer then installed whatever clang-cl scoop served that day. A base rebuild
months later would swap the compiler silently, and the breakage surfaces ~2 h
into media-core with no way to reproduce the image that worked. It was pinned
to the version scoop was serving at the time, so it was a no-op for the next
rebuild and a guarantee for every one after. Bump deliberately, then re-run
windows\scripts\tests\Test-PatchesApplyClean.ps1 against the rebuilt base.
Everything else setup-scoop-tools.ps1 installs (7zip, nano, cppcheck,
sccache, nsis, uv, nuget, zlib, openssl, pkg-config, make, gawk) floats on
purpose — the build only invokes those. Move a package into the pinned block
the moment it starts linking into a shipped binary. Note LLVM_RELEASE is a
SEPARATE pin for the Linux lane; the two lanes move independently.
Two things still float by design and cannot be pinned the same way: the MSVC
toolset inside VS major 18 (setup-vs.ps1 uses the aka.ms/vs/18/release
channel, which refreshes within the major) and scoop’s floating block. That is
what the manifest is for:
# in any image built after 2026-08-07
nerdctl --namespace buildkit run --rm --entrypoint pwsh <image> `
-NoProfile -Command "Get-Content C:\toolchain-manifest.json"
finalize-container.ps1 writes C:\toolchain-manifest.json in the base tail
layer: pinned inputs as pin/resolved pairs (so a mismatch is visible, not
inferred), the floating ones as resolved values only, plus the OS base digest
and a UTC timestamp. It answers “which compiler built this 49 GB image” from
the artifact rather than from out\windows-build-logs\, and it turns
classic-vs-BuildKit lane parity into a diff of two files. The smoke test
asserts it exists and records a resolved clang-cl (SKIP on older images).
Build isolation and CPU parallelism#
Policy (build.ps1 -Isolation, default auto): process isolation is always
preferred and used automatically wherever the host can support it. auto
runs the ~10s commit probe (windows/diagnostics/test-process-isolation-commit.ps1)
once per (host build, docker version) — verdict cached in
out\windows-build-logs\isolation-probe-cache.json — and:
probe passes → every
docker buildanddocker rungets--isolation process: full host CPUs everywhere, no 2-CPU cap. (This is the normal state on a Windows Server host whose build matches the base image — the recommended build environment.)probe fails (the wcifs layer-commit bug, present on client-build hosts mismatched against the Server base image) → falls back to
hypervwith a loud warning, and everything below applies.
-Isolation process|hyperv forces either mode (forcing process on a host
where the probe fails will kill every stage at its first layer commit).
Under Hyper-V, build containers are given only 2 logical CPUs, so
Get-BuildJobCount — min(ProcessorCount, memGB / memPerJob) — pins every
in-container ninja -j to 2 no matter how many cores the host has. That is the
difference between a ~1-hour and a ~6-hour ONNX/CUDA compile, so the heavy
media-core stage does not use docker build at all.
Two properties of docker commit that the run+commit path has to correct for
(both fixed 2026-08-07):
commitcaptures the CONTAINER’s config, includingCmd— which here is the build-script argv the stage was launched with. Left alone,local/kataglyphis:windows-media(andwindows-torch, which inherits it) ship aCMDthat RE-RUNS the GStreamer build, so a debuggingdocker run -it local/kataglyphis:windows-mediastarts recompiling overC:\runtimeinstead of giving you a shell. The driver now commits with--change 'CMD ["pwsh"]'. The FINAL image was never affected — a DockerfileENTRYPOINTresets an inheritedCMD— which is exactly why it stayed invisible for so long.A committed layer cannot be shrunk later, so package-manager scratch has to be cleared INSIDE the container before the commit. The classic lane now passes
-ScrubAfterto the media branch and merge/GStreamer runs, matching what the BuildKit lane already did on every compile RUN (Clear-BuildScratch: pip cache,~\.nuget,%TEMP%, INetCache). Source trees were never the issue — each leaf build script removes its own viaRemove-SourceBuildTree. The toolchain stage is deliberately excluded on both lanes: its CPython tree atC:\temp\cpythonIS the deliverable.
BuildKit/containerd lane (PREFERRED, windows/build-buildkit.ps1)#
This is the lane to use from 2026-08 on — full host CPUs on every stage,
process-isolated layer commits, and real per-stage layer caching, with the
docker-classic run+commit lane kept as the always-working fallback. Status
2026-08-06: GREEN end-to-end and DE-WARMED — the host snapshotter defect
(ExportLayer 0x3) is fixed at the root by a patched runhcs shim, so the
lane runs DIRECT solves everywhere and the warm/materialize pattern is
retired (full writeup, proof and maintenance rule in the Roadmap section’s
entry; the shim is a LOCAL patch that every Stevedore update reverts).
bk-winamd64 builds in ~44 min hot, and heavy RUN steps bind-mount their
per-file script closures instead of inheriting COPY layers. Probes on
2026-08-03 established that BOTH docker-classic limits are absent on the
buildkitd+containerd path on this same host (and the chain was then rebuilt
from base on this lane the same day — VS2026, CUDA, CPython and the media
compiles all ran as plain process-isolated layers):
Probe |
Result |
|---|---|
process-isolated RUN + layer commit via buildctl |
works (the wcifs |
CPUs visible in a buildkit RUN step |
NPROC=32 (no 2-CPU cap) |
container networking |
none by default → works after installing the CNI nat conf ( |
stage handoff ( |
works with fully-qualified store names ( |
Consequences: every stage can be a plain build — the heavy compiles run as
*-built Dockerfile targets (toolchain-builder built, media-builder
media-<branch>-built, merge-builder built) with real per-stage layer
caching, and the run+commit machinery is unnecessary on this lane. The classic
lane is untouched: build.ps1 pins --target builder / --target merge, so
docker never executes those targets.
Getting it going — Stevedore + BuildKit host setup (from scratch)#
The end-to-end fresh-machine sequence (including the GC-policy deploy, the permanent debug flags, and the repo-gate tooling that this section does not cover) lives in Fresh Windows Host Bring-Up.
Stevedore ships the whole engine
family in one install: docker.exe/buildctl.exe under
C:\Program Files\Stevedore\bin\, plus the stevedore (dockerd), containerd
and buildkitd services. Everything below is one-time, admin unless noted.
Install Stevedore (MSI from the releases page) and put yourself in the
docker-userslocal group (log out/in afterwards) — dockerd’s and buildkitd’s named pipes are ACL’d to that group, which is what makes non-admin builds possible. containerd’s own pipe stays admin-only (onlynerdctlneeds it;docker/buildctldon’t).Services: all three must run; set them to delayed-auto so reboots self-heal:
Get-Service stevedore, containerd, buildkitd | Set-Service -StartupType AutomaticDelayedStart Start-Service stevedore, containerd, buildkitd
buildkitd’s service must carry
--group docker-usersin its ImagePath (Stevedore’s default registration does:buildkitd.exe --run-service --service-name buildkitd --group docker-users).Known dockerd boot-failure pitfall: a stale
C:\ProgramData\docker\config\daemon.jsonwhosehostsentry conflicts with the service’s--hostflags prevents thestevedoreservice from starting at all (took a debugging session to find, 2026-08-03). If dockerd won’t start, rename that file first.CNI networking (without it every RUN that downloads anything fails with “remote name could not be resolved”):
nat.exealready ships inC:\Program Files\containerd\cni\bin; install the conf (admin):Install BOTH forms — the two clients disagree and each one silently breaks without its own. Same content, two filenames:
File
Needed by
Symptom when missing
0-containerd-nat.conf(single-plugin)buildkitd
RUN steps get no network adapter at all — empty
ipconfig,Could not resolve host, and a raw TCP connect to a literal IP fails with unreachable network0-containerd-nat.conflist(plugin-LIST)nerdctl
panics: indexes
plugins[0]with no length check →index out of range [0] with length 0, innetwork createand again inrunCORRECTION (measured 2026-08-07). This guide previously claimed “containerd and BuildKit read either form”. That is false, and it cost a launched chain. Converting the
.confto a.confliston 2026-08-07 fixed nerdctl and silently killed buildkitd’s container networking; nobody noticed because no chain build ran in between. A probe container showed an emptyipconfigand unreachable network on a raw TCP connect, and the containerd debug log confirmed theHcsCreateComputeSystemspec forbuildkitsandboxcarried Storage, MappedDirectories and MappedPipes but no networking block. Restoring the.confand restarting buildkitd fixed it immediately (IPv4172.31.44.107, gateway172.31.32.1, DNS192.168.188.1,github.comresolved).build-buildkit.ps1now fail-fasts on this in milliseconds (Get-CniConfFormIssue). Note the subnet-drift guard does not catch it: it compares subnets of whichever file it finds and passed green throughout. Different failure, different check. When you edit one file, edit both.// C:\Program Files\containerd\cni\conf\0-containerd-nat.conflist { "cniVersion": "0.3.0", "name": "nat", "plugins": [ { "type": "nat", "master": "Ethernet", "ipam": { "subnet": "<subnet of the vEthernet (nat) adapter>", // DERIVE, don't copy (see below) "routes": [ { "GW": "<that adapter's IPv4>" } ] }, "capabilities": { "portMappings": true, "dns": true } } ] }
No magic subnets.
setup-new-host.ps1authors this file from the livevEthernet (nat)adapter (derived network/prefix + gateway) — the literals in older copies of these docs (172.31.32.0/20etc.) were snapshots of one host and are stale on any other. To derive by hand:Get-NetIPAddress | ? InterfaceAlias -eq 'vEthernet (nat)'→ adapter IP is the GW, andsubnet= network/prefix of that address.After writing it, verify with BOTH clients — a BuildKit RUN step that fetches something, and
nerdctl --namespace buildkit run --rm --network nat <image> cmd /c ipconfig(admin). The second is the picky one and therefore the better test of this file.Subnet drift warning: dockerd recreates the
natHNS network with a NEW subnet on service restarts, silently orphaning this conf (containers then get unroutable IPs).build-buildkit.ps1fail-fasts on the mismatch at preflight with the exact fix; re-sync the conf toipconfig’svEthernet (nat)values (setup-new-host.ps1 -ReportOnlyre-derives and shows any drift) andRestart-Service buildkitd -Force(plainRestart-Servicerefuses when dependent services exist).Windows Defender exclusions for
C:\ProgramData\containerd(and the buildkit state dir) — layer extraction races the scanner otherwise.REQUIRED for the compile stages: disable the per-step log limit. buildkitd clips each RUN step’s log at 2 MiB (
[output clipped, log limit 2MiB reached]) — and on Windows buildkitd v0.32 this is not cosmetic: after the clip the container’s stdio pipe stops being drained, every process blocks on its next write, and the step deadlocks silently (reproduced twice on 2026-08-03: media-core froze ~3 min in, right at the clip, with two zombie ninja processes at 0 % CPU). ONNX’s warning flood alone exceeds 2 MiB in minutes, so heavy stages cannot survive the default. One-time (admin; do it while no build is running — the restart kills in-flight solves, though buildkitd’s layer cache survives):Set-ItemProperty -Path HKLM:\SYSTEM\CurrentControlSet\Services\buildkitd ` -Name Environment -Type MultiString ` -Value @('BUILDKIT_STEP_LOG_MAX_SIZE=-1','BUILDKIT_STEP_LOG_MAX_SPEED=-1') Restart-Service buildkitd -Force
(
-1= unlimited; the driver tees everything to per-stage files underout\windows-build-logs\anyway, so disk is the only cost.)sccache (non-admin): serve a cache dir over WebDAV — e.g. dufs:
dufs C:\sccache-cache -p 5000 -A— and exportSCCACHE_WEBDAV_ENDPOINT=http://<host-LAN-IP>:5000; the compile scripts pick it up inside RUN steps (same endpoint serves both lanes, so the classic chain pre-warms BK builds and vice versa). dufs does NOT survive reboots (cost a failed run on 2026-08-04, and the warm/materialize handoff also rides this server — without it the BK media solves fail fast). Make it logon-persistent once:schtasks /Create /TN dufs-sccache /TR "\"%USERPROFILE%\scoop\shims\dufs.exe\" C:\sccache-cache -A -p 5000" /SC ONLOGON— or restart manually after a reboot and verify(Invoke-WebRequest http://<host-LAN-IP>:5000 -Method Head).StatusCodereturns 200. Verified: BK’s NAT’d containers reach the host’s LAN IP fine.Verify before the first long build (non-admin):
& "$env:ProgramFiles\Stevedore\bin\buildctl.exe" --addr npipe:////./pipe/buildkitd debug workers # worker: windows/amd64 # network smoke: any tiny Dockerfile whose RUN resolves a hostname; or just start # the chain - build-buildkit.ps1's preflight guards (buildkitd reachability + # CNI subnet drift) fail fast with the exact fix if something is off.
Launch:
$env:SCCACHE_WEBDAV_ENDPOINT = 'http://<host>:5000'
.\windows\build-buildkit.ps1 -Gpu # full chain from base
.\windows\build-buildkit.ps1 -Stages toolchain # one stage
.\windows\build-buildkit.ps1 -Gpu -FinalTar out\bk-winamd64.tar # + docker-loadable export
AMD RDNA4-GPU host (RX 9xxx)? An ENABLED RDNA4 dGPU makes every process-isolated RUN-layer finalize fail with
hcsshim::ActivateLayer 0x20(docker/for-win#14977; A/B-proven 2026-08-10 — seedocs/windows-host-setup.mdand AGENTS.md Common Failure Modes). The preflight gateAssert-NoActiveRdna4Gpurefuses to start while it is enabled. Build window: elevatedpwsh -File windows\scripts\toggle-rdna4-gpu.ps1 -Disable→ build (display falls back to the iGPU) → re-enable with the same script (default action). Two extra facts that save hours: failed finalizes WEDGE hcs state until a reboot (don’t A/B anything on a wedged host), and the severity moved with Windows updates (post-KB5101684 even tiny RUN layers trip — expect patch days to change behavior). After every Adrenalin/Windows update, re-check in ~2 min withwindows\diagnostics\test-rdna4-layer-lock.ps1(elevated) — its GONE verdict is the signal the workaround can be retired.
Remaining gotchas (why the classic lane still exists): images land in the
CONTAINERD store (docker.io/local/kataglyphis:bk-*) and are invisible to
docker’s windowsfilter store — running/pushing via docker needs the -FinalTar
export (or push straight from the BK lane with -PushRef <ref>, which needs a
prior docker login in the invoking shell). Inspecting, running and even
building them works via Stevedore’s nerdctl in an ELEVATED shell — the full
recipe set is § nerdctl lane below.
When validating lane parity, compare each bk-* image’s payload against the
classic tag (the same scripts and Dockerfile targets run in both lanes).
Housekeeping and sharing:
Never kill a solve mid-finalize — and if a snapshot is already poisoned,
-NoCachethe stage rather than editing the source (measured 2026-08-07). A chain was deliberately aborted (Stop-Process buildctl) at 23 GB free to escape the disk danger band. The abort was the right call — the documented alternative is a run that dies at 4.8 GB and leaves two poisoned snapshots — but the kill itself left a half-committed snapshot, and the next run died three times on it, deterministically, with identical IDs:failed to commit 3p059m2d68o… to o47dumb0ovs4… during finalize: failed to reimport snapshot: hcsshim::ImportLayer failed in Win32: cannot create a file when that file already exists ← 0xb7
What does NOT work: the transient-retry engine (the failure is deterministic, so it just burns all three attempts), and
buildctl prune(495 MB returned — this debris is not a reclaimable cache record, exactly as theCACHE-BUSTcomments insetup-scoop-tools.ps1already noted).What DOES work, and it is cheap:
.\windows\build-buildkit.ps1 -Gpu -Stages sdk -NoCache # the affected stage ONLY
That works for a top-level stage because
-Stages sdkalready narrows the run. It does NOT work insidemedia:-Stages media -NoCachere-does all four media-core sub-stages plus litert plus tvm plus merge, so a single poisonedmedia-core-built-opencvused to cost the whole fan-out. Use-NoCacheStage(added 2026-08-14, backlog #64) — substring-matched against the stage label shown in the build output and in the log filename:.\windows\build-buildkit.ps1 -Gpu -Stages media -NoCacheStage opencv .\windows\build-buildkit.ps1 -Gpu -NoCacheStage media-merge,torch # several
Chain-wide
-NoCachestill overrides it. Each matched stage announces itself (-NoCacheStage match -> --no-cache for THIS stage only) so a typo that matches nothing is visible rather than silently doing a fully-cached build.Re-running the RUN produces a new layer digest (its output is not bit-identical), so every chain ID beneath it is fresh and the poisoned snapshot is no longer in the path. The stage that had failed 3× exported cleanly:
[bk:Dockerfile.nvidia] OK,Done in 00:17:10,exporting layers 346.1s. Prefer this over the in-file cache-bust technique — identical effect, costs one stage re-run, and leaves no comment archaeology in the source. Reach for a source-level bust only when the debris sits in a layer-Stagescannot isolate.Corollary worth internalising: let a doomed solve fail cleanly instead of killing it. A finalize that fails on its own leaves nothing behind; a kill during finalize leaves this.
Store GC — treat as MANDATORY OPS, not housekeeping. buildkitd’s store grows unbounded by default; iterating on the chain stacks full image generations (30–40 GB each) in the containerd store on every rebuild cycle. On 2026-08-03 disk exhaustion sabotaged one day THREE ways, each wearing a different costume:
hcsshim::ExportLayer 0x3(“path not found”) at snapshot finalize, a process-spawn flake surfacing as'cmd.exe' is not recognized, and finally an honestExportLayer 0x70(disk full) — only the last one names the disease. If a Windows BK build fails in ANY weird hcsshim way, check free disk first. Cleanup levers, non-admin first:buildctl prune --all(build cache only),docker image prune -f(the classic lane’s dangling generations — 91 GB reclaimed that day); the bk-* image generations themselves need admin (nerdctl --namespace buildkit rmi, or stop buildkitd+containerd and delete their state dirs for a full reset — dockerd may stop with containerd:Start-Service stevedoreafterwards). WIRED 2026-08-04, ACTIVE ON THIS HOST since 2026-08-05 (service re-registered with--config, rules verified viabuildctl debug workers -v: reservedSpace ≈215 GB / minFree ≈27–32 GB / cachemount tier 21 GB/168h). Same admin session also added Defender exclusions forbuildkitd.exe,containerd.exe,C:\ProgramData\containerdandC:\ProgramData\buildkitd— the 2026-08-05 night grind traced a family of finalize/export sharing- violation flakes to something racing the hcs scratch dirs (see the BK retry bullet in the roadmap). Originally wired after GC evicted the VS Build Tools layer between two runs (root cause, frombuildctl debug workers -v: with no config file buildkitd runs computed defaults —maxUsedSpace 100GB,minFreeSpace 187GB; the warm chain’s cache is ~237GB on a 91%-full disk, so BOTH triggers fired on every GC pass and everything reclaimable — including the multi-hour VS layer — was evicted the moment a build’s references dropped). The policy lives in the repo atwindows/buildkitd.toml(three tiers; the load-bearing knob isreservedSpace = 200GB, below which GC never prunes — that is what protects the ~35GB VS-class layers; v0.32 key names arereservedSpace/maxUsedSpace/minFreeSpace, NOT the legacygckeepstorage). Deploy/refresh it withwindows\scripts\apply-buildkitd-gcpolicy.ps1from an admin pwsh 7 shell (pwsh -File …; the script carries#requires -Version 7.0, and under Windows PowerShell 5.1 it refuses with a#requiresmessage that is easy to read as “it ran and did nothing” — cost a round trip 2026-08-08, and matches the repo-wide rule that 5.1 appears only in the base bootstrap RUN) — it copies the toml toC:\ProgramData\buildkitd\, re-registers the service with--config(keeping--debug) and restarts buildkitd, so NEVER run it while a build is solving (it refuses when it sees a live buildctl unless-Force). Verify withbuildctl debug workers -v. Keep real disk headroom by pruning the classic docker lane (docker image prune -f), not by shrinkingreservedSpace. Manual fallback between chains:buildctl --addr npipe:////./pipe/buildkitd prune --keep-storage 200000. Unit trap (cost a command on 2026-08-06):--keep-storageis afloatin MB and buildctl v0.32 accepts NO unit suffix —200gb/250GBdie withinvalid value ... strconv.ParseFloat: invalid syntax. 200 GB is200000. Same for--keep-storage-minand--free-storage. Confirm withbuildctl prune --helpbefore scripting it.--keep-storageis the WRONG lever here — use--free-storage(measured 2026-08-06). On buildkitd v0.32/WCOW,prune --keep-storage 200000against a 445.61 GB store returnedTotal: 0B— nothing deleted at all, despitedureportingReclaimable: 445.61GBand every recordInUse: false. The flags map to the same knobs as the gcpolicy (--keep-storage→ maxUsedSpace,--keep-storage-min→ reservedSpace,--free-storage→ minFreeSpace) and only--free-storageactually drove a prune. Working invocation:buildctl --addr npipe:////./pipe/buildkitd prune --free-storage 240000 # MB
--free-storageis a MINIMUM-FREE TARGET, not an amount to delete (measured 2026-08-06/07 night). The daemon prunes until the host has that many MB free and then stops — so on a disk that ALREADY exceeds the target it deletes nothing, however much is reclaimable. Measured: at 198.5 GB free with 150.5 GBPrivatein the store,prune --free-storage 200000removed 77 MB; the identical command with900000(more than the disk can ever offer) removed the full 150.48 GB. This is also why the earlier runs looked like the flag “stops at the Private slice” — they were hitting their target, not a ceiling. Rule: to drain everything unpinned, ask for more free space than the disk physically has. It cannot over-delete:Sharedrecords stay pinned regardless (next bullet), so an absurd target is safe.A store that no prune lever can touch, with
dureportingReclaimable: 0B(measured 2026-08-08). Store at 207.63 GB againstreservedSpace = 200GB(= 214.75 GB; the toml takes GiB); all 37 records readReclaimable: falseand every lever returnedTotal: 0B:SETTLED 2026-08-08, and it is NOT
reservedSpace. The cause isShared: true— records pinned by containerd IMAGE TAGS, which prune can never take (see thePrivate/Sharedbullet below; that note was right all along and got overlooked twice in one day). Decisive measurement: store 109.06 GB reportingReclaimable: 109.06GBunder a 42.95 GB reserve — far ABOVE the reserve, everything nominally reclaimable — still pruned 0 B, withdu -vshowingShared: trueon every record.Reclaimablereports the LEASE state, not what prune will hand back.Why the reserve looked causal: lowering it to 150GB coincided with an admin
nerdctl rmiof eight stage tags, and that is what released 98.83 GB (C: 85.1 → 139.1 GB). Two changes, one observation, wrong one credited. Checkdu -vforSharedbefore touching the GC policy — the lever forSharedisnerdctl rmi/image prune -f, and it costs you the stage images, so decide deliberately.buildctl prune Total: 0B buildctl prune --free-storage 950000 Total: 0B # > disk size buildctl prune --all --keep-storage-min 0 ... Total: 0B buildctl prune-histories Total: 0B # listed, freed nothing
None of those is broken; the reserve simply forbade the work. Check
reservedSpaceagainstdu’s Total BEFORE reaching for a prune flag — if Total < reservedSpace there is nothing any flag can do, and the only levers arenerdctl rmi(frees the containerd image store, a separate store — it took 66.5 → 85.0 GB here while buildkit’s 207.63 GB did not move by a byte) or editing the policy and restarting buildkitd.Size
reservedSpaceagainst FREE space, not total disk (2026-08-08). The “~20-25 % of the disk” rule of thumb assumes the disk is mostly buildkit’s. On a host where it is not, it produces an arithmetically unsatisfiable policy:disk 930.8 GB - non-buildkit content ~637 GB = ~294 GB available to buildkit reservedSpace 214.75 GB => ~79 GB of working room highest stage disk floor (sdk) 60 GB a heavy media layer's scratch, which GC may not touch 6-10 GB
So the chain consumed room, GC was structurally unable to give any back, and the stage gate refused at 53.5 GB mid-media — read at the time as a disk problem, actually a policy one.
reservedSpaceis 150GB now (the floor this file andbuildkitd.tomlalready prescribed), which still exceeds the ~120-150 GB fresh chain spine it exists to protect and leaves ~144 GB of working room. Note the invariant:reservedSpace+ the highest stage disk floor must fit in the space actually available to buildkit.Prune can only ever take the
Privateslice —Sharedis pinned by the image tags. Same run: 445.61 GB → 371.77 GB, i.e. exactly the 73.84 GB thatducalledPrivate, and it stopped there (C: 31.6 → 93.3 GB free). The remaining 371.77 GB were allShared— held by the tenbk-*stage tags in the containerd namespace, not by each other. Freeing those meansnerdctl --namespace buildkit rmi(admin) FIRST, and that is not free disk: those tags are the hot chain, so deleting them buys GB at the price of a cold 5–6 h rebuild. Decide deliberately. Diagnose before pruning withbuildctl --addr npipe:////./pipe/buildkitd du --format '{{json .}}' # then sort by Size, read Shared/InUse
A healthy store looks like this one did:
InUse: 0everywhere (nothing pinned by a live solve) but most bytesShared: true(pinned by tags). Note also that a single chain generation is NOT waste — the “iterating stacks 30–40 GB generations” failure mode means DUPLICATE generations of the same stage tag; ten distinct stage tags of one chain are the asset.A SUPERSEDED lineage hides whole duplicate copies of your most expensive layers — the single biggest reclaim on this host (266 GB, 2026-08-06/07 night). After a cache-bust rebuilds
base/sdk/toolchain, the older stage tags downstream of the OLD base still exist and still pin their own full copy of every layer beneath them. They look innocent (distinct tag names, no duplicates innerdctl images) because the duplication is one level down, in the RECORDS. Measured with 10 tags and a 384 GB store:setup-cuda.ps1 109.5 GB in 3 copies setup-scoop-tools.ps1 88.5 GB in 3 copies setup-vs.ps1 69.1 GB in 2 copies
One copy per cache-bust — 267 GB of the 384 GB was the base spine held three times over. Diagnose by grouping the verbose record list by the script each record ran and reading
Last used: records from a superseded lineage carry an older date than the current chain’s rebuild.buildctl --addr npipe:////./pipe/buildkitd du -v # group by Description, read "Last used"
Fix: admin
nerdctl --namespace buildkit rmion the stage tags of the superseded lineage, wait ~30 s for the containerd GC, then prune. Identify them by lineage, not by age: a stage tag is dead when its ancestor stage was rebuilt after it (compare image IDs against the current chain, and the stage logs inout\windows-build-logs\for the rebuild times). Deleting them costs nothing that a failed chain was not going to rebuild anyway. Before deleting a tagged FINAL image, verify the registry copy —docker manifest inspect ghcr.io/kataglyphis/kataglyphis_beschleuniger:winamd64— so the local one is not the only one. Sequence that produced the 266 GB: prune (42.4 GB) → drop canary tags + prune (15.7 GB) → drop the 6 superseded stage tags + prune (109.7 GB) → prune with a target above disk capacity (150.5 GB). C: 4.8 → 271.3 GB free, with the current lineage untouched.VHDX-backed checkouts — the reclaim lever that is NOT the store. When the repo (or the store) lives on a dynamically-expanding VHDX, that file only ever grows: deleting data inside the guest leaves the blocks allocated in the host file. Measured on the reference host 2026-08-06: 270.1 GB physical for 16.1 GB of live data, i.e. ~254 GB of dead blocks that no
buildctl prunecan ever touch — while C: had silently fallen to 11.7 GB free, deep inside the “hcsshim gets weird before it admits disk-full” band. Lever (ADMIN, never while a build solves):pwsh -File windows\scripts\compact-host-vhdx.ps1 -VhdxPath C:\cataglyphis-EXTREME.vhdx -ReportOnly # look first pwsh -File windows\scripts\compact-host-vhdx.ps1 -VhdxPath C:\cataglyphis-EXTREME.vhdx # then act
ReFS caveat — measured, do not re-probe:
Optimize-VHD -Mode Fullran 42 s on that disk, reported success, and reclaimed 0.2 GB. Compaction can only release blocks the guest reports free via UNMAP/TRIM; NTFS guests do that reliably, ReFS guests essentially do not. The script detects the guest filesystem and warns BEFORE spending the downtime. On ReFS the only reliable reclaim is rebuilding the VHDX around its live data (12 GB copy on this host). The same run still freed 19.4 GB on C: — from killing a wedgedbuildctland stopping buildkitd/containerd, which released pinned scratch. That half works on any filesystem, which is why the script does both.When compaction returns ~nothing, rebuild instead:
windows\scripts\rebuild-host-vhdx.ps1creates a fresh disk, mirrors the live data into it, compares file count AND byte totals, and only then hands over the drive letter. It runs in two phases on purpose, because they have very different requirements:pwsh -File windows\scripts\rebuild-host-vhdx.ps1 -VhdxPath C:\my.vhdx -ReportOnly pwsh -File windows\scripts\rebuild-host-vhdx.ps1 -VhdxPath C:\my.vhdx -CopyOnly # safe with everything open pwsh -File windows\scripts\rebuild-host-vhdx.ps1 -VhdxPath C:\my.vhdx -SwapOnly ` -VerifyPath D:\GitHub\Kataglyphis-ContainerHub -LogPath C:\rebuild.log -RetireOld
The COPY phase touches nothing live. The SWAP phase detaches the volume and therefore requires that NOTHING holds a handle on it — no shell whose current directory is on it, no editor with the checkout open, no agent session. Run it from a shell on another drive, and give it a
-LogPathoff the volume. This is not hypothetical: an unattendedwsl --unmount/detach on this disk on 2026-08-06 pulled D: out from under a running session and killed it. The script therefore refuses rather than forces the detach, and keeps the verified copy for a later-SwapOnlyrun. The old disk is kept as.oldunless-RetireOldis passed — until it is deleted, NO space is reclaimed.Cross-host / CI cache:
build-buildkit.ps1 -ExportCacheRef <registry-ref>/-ImportCacheRef <ref>wire buildkit’s registry cache (mode=max) once registry auth works from buildkitd — a second machine then rebuilds the chain from cache instead of from source.
nerdctl lane (admin): run, inspect, build#
Both possibilities exist and both are supported. Verified end-to-end on the reference host 2026-08-07. Use whichever fits the job:
|
|
|
|---|---|---|
Shell |
non-admin |
admin, always |
Builds the chain |
✅ this is the production lane |
✅ works, but see “why the chain still uses buildctl” |
Run / exec into an image |
✗ |
✅ the reason to reach for it |
Image store admin ( |
✗ |
✅ only way to reach the containerd store |
One-time host requirements#
The CNI nat config must be a
.conflist— see host-setup § A5. With a bare.conf, nerdctl PANICS (index out of range [0] with length 0); it is the single thing that made nerdctl unusable here until 2026-08-07.Admin shell. Not negotiable and not a configuration mistake: nerdctl opens
\\.\pipe\containerd-containerd, which is Administrator-only.buildkitdships--group docker-users(which is exactly whybuildctlruns unelevated); containerd has no equivalent — verified against its full flag set and default config,--addressonly moves the pipe, it does not change who may open it. nerdctl opens that client for every subcommand, includingbuild --output type=tar, so no output mode avoids it. Do not attempt pipe-ACL hacks: the ACL is recreated on every containerd restart, and containerd access is effectively machine-admin. The legitimate route is an upstream containerd feature request.A fresh shell.
C:\Program Files\Stevedore\binis on the MACHINE PATH, so shells opened before Stevedore was installed will not findnerdctl. Reopen the window rather than patching$env:Path.--namespace buildkiton every command. Thebk-*images live in containerd’sbuildkitnamespace; the default namespace looks empty.
Recipes#
# --- inspect the store -------------------------------------------------------
nerdctl --namespace buildkit images
nerdctl --namespace buildkit ps -a
# --- interactive shell INSIDE a finished image (the main win) -----------------
# NOTE: no trailing command. The final image's ENTRYPOINT (entrypoint.cmd) loads
# VsDevCmd and then starts pwsh by itself.
nerdctl --namespace buildkit run --rm -it --network nat docker.io/local/kataglyphis:bk-winamd64
# once inside: verify what actually shipped
# python -c "import cv2, onnxruntime; print(cv2.__version__)"
# where.exe nvcc ; gst-inspect-1.0 --version
# --- one-shot command in an image WITHOUT an entrypoint ----------------------
nerdctl --namespace buildkit run --rm --network nat docker.io/local/kataglyphis:bk-windows-base cmd /c ipconfig
# --- one-shot command in an image WITH an entrypoint: override it -------------
nerdctl --namespace buildkit run --rm --entrypoint pwsh --network nat docker.io/local/kataglyphis:bk-winamd64 -NoProfile -Command "python -c 'import cv2; print(cv2.__version__)'"
# --- build (admin) -----------------------------------------------------------
# BUILDKIT_HOST is REQUIRED on Windows: nerdctl has no unix-socket default to
# fall back to, and without it the build fails to reach buildkitd.
$env:BUILDKIT_HOST = 'npipe:////./pipe/buildkitd'
nerdctl --namespace buildkit build -t local/kataglyphis:my-tag --progress plain <context-dir>
# --- housekeeping (the 266 GB lever) -----------------------------------------
nerdctl --namespace buildkit rmi docker.io/local/kataglyphis:<obsolete-tag>
Why the chain still uses buildctl#
nerdctl build is a wrapper that hands the solve to the same buildkitd. Using
it for the chain would cost, and gain nothing:
every build would need elevation — the background/unattended runs this project depends on are non-admin today;
--opt image-resolve-mode=localis load-bearing for stage handoff (thebk-*tags resolve from the containerd store instead of attempting a registry pull) and is not exposed bynerdctl build;the driver’s transient-retry engine, per-stage logs and preflight gates (
Assert-DiskHeadroom,Assert-ShimPatch) are keyed tobuildctl.
So: buildctl builds the chain, nerdctl inspects and runs its results.
Traps (each one cost time on 2026-08-07)#
Passing a command to an image that has an
ENTRYPOINTappends it as entrypoint ARGUMENTS. Onbk-winamd64that exits255immediately. Use no command, or--entrypoint.A killed
nerdctl runleaves a zombie, andnerdctl rm -fon it can then BLOCK for up to 45 minutes — the patched shim waits for teardown instead of force-terminating (correct for builds, painful interactively). Recovery:Get-Process containerd-shim-runhcs-v1,CExecSvc | Stop-Process -Force, thenrm -fagain. Safe only when the container did no real filesystem work.Exit code
3221225786(0xC000013A) means the container was Ctrl+C’d, not that the image is broken.Two harmless warnings on every run:
default network named "nat" does not have an internal nerdctl ID(true — containerd created it) andfailed to remove hosts fileat exit. Ignore both.Diagnosing a “hung” nerdctl: check
containerd-debug.logfor the task’s exit Span before assuming the container is stuck — it has usually exited already and only cleanup is blocked.
Roadmap (mounts PROBED WORKING on Windows buildkitd v0.32, 2026-08-03 —
both --mount=type=bind and --mount=type=cache execute correctly in RUN
steps; the remaining work is the Dockerfile surgery):
RUN --mount=type=bindfor build scripts: DONE 2026-08-04 (single-file mounts probed working on WCOW buildkitd v0.32). The BK lane’s*-bkstages in Dockerfile.media-builder + the merge builder’s warm/built stages carry NO script/patch COPY layers — every RUN bind-mounts exactly its transitive script closure atC:\bkmntand passes-ScriptDir C:\bkmnt. Editing a build script now re-runs ONLY the RUNs that mount it (an OpenCV fix no longer re-pays the 75-minute ONNX layer). Modules are mounted PER FILE too (2026-08-04): the in-container closure is exactly SourceBuild.Common + Shared + SourceBuild.Patches + SourceBuild.Cuda + Native.Common (plus Installer.Common for GStreamer) — the earlier whole-dirmodules/mount let edits to the 24 host-only modules (BuildDriver, BuildKit, Flutter, …) bust every compile RUN.load-versions.ps1is mounted into every build RUN so the freshly COPY’d versions.env is re-read instead of the base image’s baked (possibly stale) Machine env. The classic targets keep their baked COPYs (classic docker cannot--mount).Concurrent aux branch solves: available OPT-IN via
build-buildkit.ps1 -ConcurrentAux(2026-08-04) — media-core stays the sequential long pole, then litert + tvm build side by side via child drivers on half the media memory budget each. Measure host RAM headroom before making it the default. Two costs to know: (a) children run a single media branch, so the GStreamer merge is gated on all three branches being requested and runs only in the parent (children print[bk:merge] skipped); (b)MEMORY_LIMIT_GBis baked as ENV in the mediacommonstage, so TOGGLING -ConcurrentAux (which halves the aux budget) changes that ENV and invalidates the aux branches’ compile RUNs — pick a mode and stay in it.Registry push: available via
build-buildkit.ps1 -PushRef <ref>(2026-08-04) — re-solves the final image from cache with a push exporter; needs a priordocker loginin the invoking shell (buildctl forwards the client credential store).RUN --mount=type=cachefor a local sccache dir (WebDAV stays as the cross-lane L2): kills the HTTP round-trip on ~5000 compiles per stage. Probed working. CAUTION (2026-08-04): cache mounts get CLONED whenever the record is locked — fine for an L1 compile cache (worst case: cold clone, WebDAV L2 still hits), but never rely on two solves seeing the same instance.CORRECTED 2026-08-08 — the wiring is NOT just
SCCACHE_DIR. This entry used to say “wiring = set SCCACHE_DIR to the cache mount”, which alone does nothing: with a remote configured sccache runs in single-level legacy mode and the disk backend is simply not in the chain. Two tiers need the explicit chain variable (verified against mozilla/sccachedocs/Configuration.md):SCCACHE_MULTILEVEL_CHAIN = disk,webdav # left-to-right = fast-to-slow SCCACHE_DIR = <the cache mount target> SCCACHE_CACHE_SIZE = <cap for the L0 disk tier> SCCACHE_WEBDAV_ENDPOINT = <unchanged>
Read-through/write-through with automatic backfill; each level keeps its own variables.
SCCACHE_MULTILEVEL_WRITE_ERROR_POLICYdefaults tol0(a write failure on the local tier fails; remote-tier write errors are tolerated).Version dependency this creates: multi-tier landed in sccache v0.16.0 (2026-06-19; implemented 2026-04-17, PR #2581). The image installs sccache from the FLOATING scoop block — measured 0.17.0 in the 2026-08-08 chain, so it works today. But the moment this wiring lands, sccache stops being a tool the build merely invokes and becomes one whose VERSION gates a feature: on an older sccache the chain variable is ignored and the L1 silently does nothing, with no error. Pin
sccachealongside llvm/ninja/nasm if this is wired — the same argument that pinned those three.sccache for the merge/GStreamer builder: DONE 2026-08-04 — build-gstreamer-from-source.ps1 sets
CC/CXX='sccache clang-cl'for meson when the remote backend is configured (this build previously ran fully uncached, ~30 min hot, because the merge builder never wired the endpoint).Automatic transient retry in the BK driver: DONE 2026-08-04, extended 2026-08-05 — Invoke-BkStage retries once on
Activate/PrepareLayer 0x20/ ttrpc / shim-task /rpc Unavailablefailures AND on the hcs-temp finalize/export flake family discovered in the 2026-08-05 night grind:failed to reimport snapshot(GetFileAttributesEx not-found variant) andfailed to write compressed diff(SystemTemp\hcs* sharing violation — the retry saved the sdk export live that night). Two hard-won caveats: (a)ImportLayer 0xb7 "already exists"on IDENTICAL source/target chain-IDs across attempts is NOT transient — it is persistent snapshotter debris from an earlier low-disk finalize failure; non-admin remedy is a deliberate CACHE-BUST of the layer above it (any content change to the COPY’d/mounted file → new chain-IDs sidestep the debris; see setup-scoop-tools.ps1’s 2026-08-05 header comment for the live example). (b) disk-full also surfaces asfailed to write compressed diff— check free space before trusting the transient classification. Root causes addressed since: gcpolicy active + Defender exclusions for buildkitd/containerd (below) + ≥40 GB free-disk discipline.Per-library media-core split: DONE, and escalated on 2026-08-04 from 4 RUN layers to 4 chained SOLVES (targets
media-core-built-onnx→-opencv→-ffmpeg→media-core-built, image handoffs via theMEDIA_CORE_*_IMAGEARGs; build-buildkit.ps1 drives them in order). An FFmpeg-only change still recompiles nothing else — and each library’s export is now independent of the others’ finalize behavior.🎯 DEFECT SOLVED (2026-08-06, patched runhcs shim). ROOT CAUSE: the entire ExportLayer-0x3 family was hcsshim’s hardcoded
const tearDownTimeout = 30 * time.Secondincmd/containerd-shim-runhcs-v1/task_hcs.go(close(): shutdown wait + terminate wait; plus the 30 s “waiting for task to be closed” inDeleteExec). Heavy-churn WCOW silo teardown needs MINUTES — measured 117 s for the OpenCV specimen (HcsShutDownComputeSystem 01:16:08 → notification 01:18:05) — so the stock shim terminated mid-hive-flush and left the scratch vhdx permanently unexportable. FIX DEPLOYED: shim built from hcsshim@main (81e2e01) with the constants raised to 45 min/100 min (zero cost on the happy path — the timer only matters when it would have killed the build), installed toC:\Program Files\Stevedore\bin\ containerd-shim-runhcs-v1.exe(original preserved as.exe.orig; replacement needs admin + no running shim processes; containerd itself needs NO restart — the shim spawns per container). PROOF: first-ever direct OpenCV finalize+export on this host (bk-canary-shim-opencv, 28.6 s export, no 0x3), confirmed per the 3× OPENCV canary rule (bk-canary-shim-opencv{,2,3}all clean, –no-cache). The lane is DE-WARMED since 2026-08-06: direct solves everywhere, warm/materialize retired (payload scripts kept in tree as the rollback path, c9586c1^). MAINTENANCE: any Stevedore/containerd update overwrites the patched shim —build-buildkit.ps1’sAssert-ShimPatchpreflight catches it before the build starts. Since 2026-08-07 the check is a SHA256 comparison against the hashdeploy-shim-patch.ps1recorded when it installed the binary (C:\ProgramData\kataglyphis\shim-patch.json), which is exact and cannot rot as hcsshim moves; the older size table (patched 25 332 736 for the env-var build, 25 329 664 for the fixed-constant build, vs stock 23 279 616) survives only as the fallback for a host that has not run the deploy script since. Rundeploy-shim-patch.ps1once to record the hash — until then the gate warns that it is still guessing.-ReportOnlyshows the recorded hash, whether the live binary still matches, the backups and the service environment; the same script re-installs. Rebuild recipe: scoop go +git clone microsoft/hcsshim+ apply the in-tree patch +go build .\cmd\containerd-shim-runhcs-v1. Upstream submission is FILED as a DRAFT PR: microsoft/hcsshim#2855, materials in-tree atwindows/upstream/hcsshim-teardown-timeout/(issue text, PR description,git format-patch). It makes all four fixed 30 s limits in the binary configurable — the two intask_hcs.goplus the crash-recovery wait indelete.go— with defaults unchanged at 30 s.ENV VAR NAMES — get these exactly right:
CONTAINERD_SHIM_RUNHCS_V1_TEARDOWN_TIMEOUT e.g. 45m CONTAINERD_SHIM_RUNHCS_V1_TASK_CLOSE_TIMEOUT optional; defaults to 2x teardown + 30s
They follow the shim’s existing house convention (
..._WAIT_DEBUGGER). An earlier draft of this document named themHCSSHIM_TASK_*— those were INVENTED and never existed in any build. Setting a wrong name is silent: the shim falls back to 30 s and the defect returns with no error anywhere. Set them on the containerd SERVICE (the shim inherits its environment);deploy-shim-patch.ps1 -ServiceEnvironmentmerges them in. Note the upstream patch is NOT the same as a fixed-constant build: with the defaults it behaves exactly like stock, so a shim built from it and no env var set is a shim with the bug. Verify BEHAVIOURALLY with an OpenCV canary — the shim logs its effective timeout at Debug level, which does not reach containerd’s log, so a quiet log proves nothing. Getting the PR merged is what retires the binary-size check after every Stevedore update. The historical bullets below are preserved for diagnosis value.DEFECT PARTIALLY TAMED, NOT GONE (2026-08-05, de-warming attempted and ROLLED BACK same evening). Sequence of record: (1) with the Defender exclusions active, a fresh
--no-cacheheavy TVM→IREE canary FINALIZED AND EXPORTED CLEAN (bk-canary-0x3— a finalize class that used to fail); (2) on that evidence the lane was de-warmed to direct solves; (3) the FIRST direct OpenCV finalize failedExportLayer 0x3with the original signature, deterministic across retries → OpenCV/GenAI-class churn still trips the defect; TVM was the wrong canary specimen. The Defender exclusions remain load-bearing (they cured the hcs-temp finalize/export FLAKE family and evidently moved TVM-class finalizes to reliable) but do NOT cure the core defect. The warm/materialize pattern was RESTORED from git history within minutes — the preserved rollback path worked exactly as designed. LESSON: any future de-warming attempt must canary with OpenCV (the deterministic trigger), not TVM: same recipe as below but--opt target=media-core-warm-opencv+--opt build-arg:MEDIA_CORE_ONNX_IMAGE=<current onnx tag>; clean export three times in a row before touching the architecture. Canary recipe (after any AV/OS/hcsshim change):buildctl build ... --opt filename=Dockerfile.media-builder --opt target=media-core-warm-opencv --no-cache --output type=image,name=docker.io/local/kataglyphis:bk-canary-0x3 --opt build-arg:BASE_IMAGE=docker.io/local/kataglyphis:bk-windows-toolchain --opt build-arg:MEDIA_CORE_ONNX_IMAGE=docker.io/local/kataglyphis:bk-windows-media-core-onnx --opt build-arg:MEMORY_LIMIT_GB=16 --opt build-arg:SCCACHE_WEBDAV_ENDPOINT=<endpoint>(plus the standard –local/ –opt image-resolve-mode=local flags). Clean export = that class is safe;ExportLayer 0x3at “exporting layers” = defect present, keep warm/materialize. Historical writeup below preserved for diagnosis value.IN-CONTAINER MITIGATIONS EXHAUSTED (2026-08-05 late night, two more OpenCV canaries). The shim injects
WaitToKillServiceTimeout=2147483647into every container; overriding it to 5 s at payload start (probe R1) changed nothing — exit 0 is published instantly,HcsShutDownComputeSystemreturns in ms, and the shutdown AND terminate notifications are still lost (30 s + 30 s timeouts in the containerd debug log), thenExportLayer 0x3. Probe R2 additionally stopped/killed every non-baseline resident before exit (sccache server, msdtc, AggregatorHost, SysMain, DiagTrack, UsoSvc, WinRM + 7 more services — verified stopped in the exit dump): same loss, same 0x3. Together with the earlier settle falsification this proves the hang is HOST-side (silo/wcifs teardown of heavy-churn scratches), not anything running inside the container. Upstream fingerprint: microsoft/Windows-Containers#547 (ltsc2025 process isolation, ~10-min shutdown, resources stay locked, closed unresolved). NOTE (corrected 2026-08-06): Win11 24H2+ hosts running ltsc2025 images process-isolated is OFFICIALLY SUPPORTED per the version-compatibility doc (the strict build-match rule was relaxed for this combination) — so this is a reportable platform bug in a supported configuration, not an off-label artifact; #547 saw the same hang on a matched-build 26100 host. CONSEQUENCE: warm/materialize is the standing architecture on this class of host, not a temporary workaround. Do NOT burn more canaries on in-container theories; the only genuine escape hatches are a platform fix (Windows CU) or the containerd 2.x CimFS/UnionFS snapshotter lane (bypasses wcifs entirely — experimental for WCOW, unproven with the BuildKit worker). UPDATE 2026-08-06: the CimFS lane was TESTED AND FALSIFIED on containerd v2.3.3 (plugin+differ both “ok”): buildkitd with--containerd-worker-snapshotter=cimfsdies on the FIRST build step withscratch snapshot without any parents isn't supported— the cimfs snapshotter cannot create parentless scratch snapshots, which BuildKit needs even to load the Dockerfile context. CimFS is pull/run-only today; do not retry until a containerd release notes BuildKit/build support. The teardown probe remains inbk-warm.ps1(harmless, ~1.5 s, keeps exits quiet and preserves the diagnostic exit dump; removing it would cache-bust every warm layer for zero gain).HISTORICAL (2026-08-04, worked around via warm/materialize) — GenAI/OpenCV snapshot finalize (
ExportLayer 0x3, disk fine): those two layers deterministically fail BOTH finalize paths on buildkitd v0.32/containerd, on every fresh snapshot. A 17-probe bisection (2026-08-04) falsified: poisoned cache records, layer depth (14 stacked trivial layers export fine), defective ONNX parent (trivial layers on it export fine), file/dir content (a GenAI layer whose ENTIRE diff was deleted before step end still fails), lingering compiler daemons, vctip, bare .NET-Framework CLR (MSBuild -versionlayer exports clean), pending-delete zombies, and build-tree deletion (KEEP_BUILD_ARTIFACTS=1still fails). Clean under identical conditions: ONNX (ninja, 100-min layer), cpython (MSBuild), LiteRT (bazel), every trivial probe. TVM+IREE joined the failing set later the same morning (cmake+ninja like the clean ONNX — no build-system pattern survives).Root-cause finding (containerd debug log, 2026-08-04 07:59): the snapshot commit RACES a failing container teardown. Timeline: task exits → shim cleanup starts → an HCS operation inside that cleanup fails with
HCS_E_INVALID_STATE(0xC0370105, “Containervorgang ist im aktuellen Zustand ungültig”) → containerd logscommit snapshot70 ms after the cleanup began → 13 s laterExportLayerfails 0x3. The scratch VHDX is never cleanly released by the half-failed teardown, so the export finds no layer paths. Fits the clean/toxic split: layers whose containers exit with residual processes + heavy dirty IO (15–25-min compiles) hit the bad teardown state; calm exits don’t.How to capture the debug evidence again (admin): set the service ImagePaths via registry (sc.exe quoting mangles them in PowerShell):
Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\containerd' -Name ImagePath -Value '"C:\Program Files\Stevedore\bin\containerd.exe" --run-service --service-name containerd --log-level debug --log-file C:\ProgramData\containerd\containerd-debug.log'(analogbuildkitdwith--debugbefore--run-service),Restart-Service containerd -Force,Start-Service buildkitd, stevedore, andicacls <log> /grant "<user>:(R)"to read it non-elevated. Policy: debug logging stays PERMANENTLY ON on this host (owner decision 2026-08-04) so the next snapshotter incident carries its evidence immediately. The log grows unbounded — if it gets large, truncate it (admin:Clear-Content C:\ProgramData\containerd\containerd-debug.log) rather than disabling the flags.All host-level mitigations were exhausted (2026-08-04): quiesce tail, msdtc/WMI stops, and a full host reboot with a fresh container all hit the identical double-timeout + 0x3 signature. Host processes show the container’s processes DO die — the HCS shutdown notification is what never arrives (vmcompute → hcsshim callback), after which the scratch is never cleanly released. buildkitd v0.32 has no Hyper-V isolation option. Genuine platform defect (Win11 host 26200 + ltsc2025 + process isolation + heavy-churn layers; GenAI/OpenCV/TVM/GStreamer-class builds trip it — ONNX/cpython/LiteRT/torch never did). Worth reporting upstream: hcsshim (lost shutdown notification) + buildkit (commit proceeds into a known-failed teardown).
WORKING SOLUTION — the warm/materialize pattern (BK lane is GREEN end-to-end since 2026-08-04,
bk-winamd64built in 44 min hot): exploit BuildKit’s LAZY finalization — a snapshot is only finalized when a child step or an exporter needs it. Per heavy library:Warm solve (
media-core-warm-<lib>/ mergewarm; driver runs it viaInvoke-BkStage -NoOutput): the build runs normally on the scratch; the artifact delta (C:\runtime + cpython site-packages, CreationTime > step start) leaves as ONE tar over the sccache dufs server (Export-BuildHandoff). No exporter + no child step ⇒ the toxic snapshot is never finalized ⇒ the defect never fires.Materialize solve (
media-core-built-<lib>/ mergebuilt, exported as the handoff image): a calm seconds-long container downloadsextracts the tar (
Import-BuildHandoff) — clean teardown, clean finalize, clean image export. Hard-won transport constraints baked into the helpers (WindowsSourceBuild.Common.psm1): cache mounts are NOT usable as the handoff channel (BuildKit clones them under lock — warm and materialize are not guaranteed the same instance; also directory RENAMES fail on them); call System32 tar/curl by full path (scoop-git’s MSYS GNU tar resolves first and parsesC:\as a hostname); pre-create every parent directory before extracting (bsdtar’s long-path mode does not, and C:\runtime does not exist in a fresh materialize container); stage-localARG SCCACHE_WEBDAV_ENDPOINT+ ENV in every warm/materialize stage (ARGs do not cross FROM boundaries). ONNX/LiteRT keep their direct solves — they never trip the defect. Re-test the direct path after host OS or buildkitd/hcsshim upgrades: a 15-min tvm direct solve is the canary. Upstream issue: ready-to-file draft + preserved debug-log evidence indocs/upstream/hcsshim-lost-shutdown-notification-issue.md(+containerd-debug-evidence-2026-08-04.log).
Concurrent branch solves (litert + tvm in parallel buildctl calls) — RAM-gated; both branches are memory-bound, so measure before enabling.
The 125-layer budget (classic lane)#
Docker’s layer-chain depth is hard-capped at 125; exceeding it fails with
max depth exceeded when the FIRST container of the next stage is created —
i.e. the failure lands one stage after the image that overspent. The classic
builder emits a layer per instruction, metadata included (28 separate ENV
lines in the merge Dockerfile cost 28 layers; consolidating them into one big
ENV took the merge builder from 114 → 86 layers on 2026-08-03, and the final
image from a 125 cap-hit to ~108).
Rules of thumb:
One consolidated
ENVper Dockerfile stage (same-instruction${}refs don’t resolve — write derived paths as literals).Batch flat-file
COPYs with multi-source form when the destination matches.After adding instructions anywhere in the chain, audit headroom:
docker inspect <tag> --format '{{len .RootFS.Layers}}' # per chain image
The BuildKit lane is far less exposed (metadata instructions are config-only there), but the exported images still obey the cap when loaded into docker.
Why docker build can’t be fixed on this host. The classic Windows builder
offers no working CPU lever, all verified with a ~6-second repro (a Dockerfile
that writes a dummy layer):
Attempt |
Result |
|---|---|
|
rejected — “unknown flag” |
|
build fails |
|
container sees all 32 CPUs but cannot commit any layer — |
The ActivateLayer failure is not Windows Defender, Windows Search, or
SysMain — all were ruled out by disabling each and re-running the repro. It is a
container-filter / Docker-Engine level defect with process isolation on this
host, so --isolation process is unusable for building (every build dies at the
first commit). Hyper-V isolation commits reliably but is stuck at 2 CPUs. Do
not add --isolation process to any docker build.
The run+commit path (how media-core gets its cores). docker run — unlike
docker build — does honor --cpu-count under Hyper-V (verified: docker run --isolation hyperv --cpu-count 16 → NUMBER_OF_PROCESSORS=16), and a Hyper-V
container commits fine via docker commit. So build.ps1 builds media-core as:
docker builda thin builder image (Dockerfile.media-builder --target media-core) — toolchain + all media-core scripts/patches, no heavy RUN, so its cheap COPY layers commit fine under Hyper-V.docker run --isolation hyperv --cpu-count $MediaCoreCpus --memory ${MediaMemoryGb}g <builder> pwsh -File build-media-core-all.ps1— runs the whole ONNX → GenAI → OpenCV → FFmpeg chain in one container at the full CPU count.Get-BuildJobCountsees--cpu-countasProcessorCount, so ONNX compiles atmin(cpu-count, memGB/4)(e.g.-j14at-MediaCoreCpus 16 -MediaMemoryGb 56).docker committhe container tolocal/kataglyphis:windows-media-core— a drop-in replacement for the oldDockerfile.media-coreoutput.
Invoke-MediaBranchRunCommit in build.ps1 implements this via the generic
Invoke-RunCommitStage helper; tune it with -MediaCoreCpus (default: the host’s
logical processor count, [Environment]::ProcessorCount) and -MediaMemoryGb
(default 0 = auto-detect from host RAM minus -HostReserveGb).
Which stages use run+commit. The same Invoke-RunCommitStage path is used for
every CPU-bound stage, so they all build at -MediaCoreCpus cores instead of
the 2-CPU docker build cap:
Stage |
Builder Dockerfile |
Run step (the heavy compile) |
|---|---|---|
toolchain |
|
|
media-core |
|
|
media-litert |
|
|
media-tvm |
|
|
media merge |
|
|
The merge stage splits: the fan-in (COPY --from of the three branch trees)
must be a docker build because docker run can’t COPY --from, but it is only
IO so 2 CPUs is fine; the CPU-bound GStreamer compile then runs via run+commit.
docker commit preserves the builder image’s ENV, so each result image is a
drop-in replacement for the old single-Dockerfile output.
Diagnostic / partial-alternative on hosts where build-COPY is broken.
Measured 2026-08-09 — root cause RESOLVED 2026-08-10: the ENABLED AMD RDNA4
dGPU locks fresh container layers (see AGENTS.md Common Failure Modes “AMD
Radeon host” row; build with the dGPU disabled via toggle-rdna4-gpu.ps1 —
the earlier “Adrenaline reinstall fixes it, GPU-disable does not” verdict is
SUPERSEDED):
on a host where every docker build/buildctl build COPY commits fail
(hcsshim::ActivateLayer 0x20 on buildkit, mkdir \\?\Volume{<GUID>}\C:. on the
docker legacy builder — while FROM+RUN layers commit fine), the CommitLayer
path via docker run + docker commit still works and is a 30-second probe:
docker run --name probe-rc mcr.microsoft.com/windows/servercore:ltsc2025 cmd /c echo hi
docker commit probe-rc local/test:probe-rc # rc 0 = CommitLayer OK; only ApplyDiff (build COPY) is broken
docker rm -f probe-rc
Committed version of the build probe: pwsh -File windows\scripts\probe-build-copy.ps1 -Heavy (assets in
windows/diagnostics/probe-build-copy/; only a -Heavy-green verdict counts
— the light lanes stay green on hosts whose heavyweight RUN-layer finalize is
broken).
So the classic lane’s CPU-bound run+commit stages remain viable on such a host.
Caveat: the chain cannot bootstrap end-to-end there, because the FROM images
(base/sdk/merge fan-in) themselves contain COPY steps that still break — every
repo Dockerfile has at least one COPY. Use the healthiest host for a full chain;
the run+commit path only rescues the heavy compile stages once a starting image
exists.
2026-08-09 follow-up (SUPERSEDED 2026-08-10 — kept as history; the same-boot A/B proved the enabled RDNA4 dGPU is the holder and the “cures” below coincided with patch/reboot changes):
A faulty AMD Adrenaline installation (GPU + chipset) was blamed for the general
0x20family; reinstalling Adrenaline (not GPU-disable) appeared to fix it.The buildkit-snapshotter residual — “any layer writing into an existing parent dir” refused, identical on buildkit 0.32.0 and 0.32.2, on all snapshotter names (
windows/native/windows-uvm; see the[worker.containerd]note inwindows/buildkitd.toml) — was cleared by a Windows in-place repair upgrade (official ISO, same build, keep files+apps): after it every layer commits.Residual on that host: only the final export (reimport of the committed snapshot) still trips
0x20, where the Defender engine (MsMpEng) is unkillable by design and the identical Stevedore+OS stack builds the BK lane fine on the working machine. ⇒ host-residual; use the classic lane there or the healthy host.
The litert/tvm aux branches also run+commit at -MediaCoreCpus cores (via
their Dockerfile.media-builder targets): media-core is already committed when they
run, so the whole CPU/RAM budget is free — e.g. ~j19 at 32 CPU / 39 g on this host
(still memory-bound per the note below). base/sdk are the only stages that never
exceed 2 CPUs — they’re network/install-bound (no benefit from more).
NOTE — parallelism is memory-bound, not core-bound.
Get-BuildJobCount = min(cpu-count, MEMORY_LIMIT_GB / per-job-GB). ONNX is ~4 GB/job, so at 48 GB it runs~j12whether you give it 16 or 32 cores; extra cores only speed the lighter TUs (FFmpeg, CPython, GStreamer). Truej32on ONNX needs ~128 GB RAM, which this host does not have — so on the ONNX long pole, RAM is the ceiling, not cores.
Trade-off: a single docker run has no per-stage layer cache, so a mid-chain
failure used to re-run the whole chain (unlike a multi-RUN docker build, where
each completed step is cached). The persistent sccache remote (below) covers
recompilation, so in practice only uncached objects rebuild. Regression symptom
for the whole mechanism: ninja -j2 in out\windows-build-logs\media-core.log,
or an ActivateLayer error on any commit.
Resume after a mid-chain failure: on a non-transient run failure, build.ps1
now PRESERVES the container (it holds every completed stage’s output in
C:\runtime) and prints the recovery recipe:
docker commit <container> <result-tag>-partial
docker container rm -f <container>
docker run --isolation hyperv --cpu-count <N> --memory <M>g --name <container> `
<result-tag>-partial pwsh -NoProfile -ExecutionPolicy Bypass `
-File C:\temp\scripts\<payload>.ps1 -ResumeFrom '<failed stage>'
docker commit <container> <result-tag> ; docker container rm -f <container>
-ResumeFrom (all three build-*-all.ps1 payloads → Invoke-SourceBuildChain -StartAt) skips the stages before the named one; an unknown name throws instead
of silently rebuilding from scratch. Pick the stage from the last
=== <label> stage: ... === banner in the run log. Do NOT docker start the
failed container — that re-runs the original chain command from the beginning.
Root cause (fully diagnosed). The commit failure is the wcifs minifilter
(Windows Container Isolation FS) refusing to detach the process-isolation layer on
container teardown — dockerd’s panic.log shows
hcsshim::UnprepareLayer failed ... ERROR_FLT_DO_NOT_DETACH (0x801f0010), which
leaves the layer files locked so the subsequent commit’s ActivateLayer fails
0x20. The trigger is an OS-class mismatch: the host is a Windows client
build (26200 / 25H2) but the only base image is Windows Server servercore:ltsc2025
(build 26100). A client-kernel wcifs will not detach a Server layer. This is
below Docker and below containerd — it reproduces identically for docker build,
docker run+commit, the containerd snapshotter, and nerdctl commit, on the
newest stack (Docker 29.5.3 / containerd 2.3.1 / hcsshim 1.2.1). It is not
contradicted by Microsoft’s host≥image compatibility matrix: the matrix governs
whether the image can run under process isolation (it does — the RUN step
executes), not layer commit/teardown. The only real fixes are environmental:
build on a matching build-26100 host (Windows 11 24H2 or Windows Server 2025),
or wait for a Windows/hcsshim fix.
Re-testing process isolation on new versions (is the bug gone yet?)#
After any Docker Engine / containerd / hcsshim / Windows / base-image upgrade,
re-check whether docker build --isolation process can commit a layer again — if
it can, the entire Windows build (not just media-core) could run at full CPU
count and the run+commit workaround could be retired. A durable, self-contained
probe lives under windows/diagnostics/:
.\windows\diagnostics\test-process-isolation-commit.ps1
It records the current Docker/containerd/host build numbers, runs a docker run --isolation process control (expected: PASS), then builds a tiny ~100 MB probe
layer with docker build --isolation process (Dockerfile.isolation-probe) and
prints a clear verdict:
BUG GONE(exit 0): the commit succeeded — process isolation is usable fordocker build. Follow the on-screen next steps (switch heavy stages to process isolation, re-run the full build to confirm parity, then retireInvoke-RunCommitStageand update this doc + the host-quirks notes).BUG PRESENT(exit 1): the knownwcifs/ActivateLayer 0x20failure still occurs — keep the run+commit workaround.exit 2: the build failed with a different signature — investigate; do not assume it is fixed.
To test a hypothetical newer matching-build base image, pass -Base <image>.
Baseline as of Docker 29.5.3 / containerd 2.3.1 / host build 26200 with
servercore:ltsc2025: BUG PRESENT.
Run-side wcifs symptoms (process isolation)#
The same wcifs filter that breaks layer commits on this host/base skew also
breaks runtime file operations inside image-layer directories of
process-isolated containers (surfaced 2026-07 building
Kataglyphis-Inference-Engine inside the image):
Create-then-rename of fresh files fails
ERROR_PATH_NOT_FOUND(deterministic in hot paths). This breaksgit init/clone/checkout(“could not write config file”, “unable to write new index file”) and Dart’sFile.renameSync(e.g. the sqlite3 package’s native-asset hook).Plain copies and tar extractions in the same directories succeed.
Directories created fresh in the sandbox (e.g.
C:\foo) are unaffected.Bind mounts avoid the layer FS but are NOT a full fix (verified 2026-07-16): on mounted paths, plain writes and cmd
copy/renwork, but Dart’scopySync/renameSyncfail with errno 3 (bindFltrejects the Dart runtime’s two-path file operations on this skewed host). Consumer recipe: bind-mount the sources, then junction the Dart/Flutter write dirs (.dart_tool,build) from the mounted workspace to container-local dirs (mklink /J, run inside the container) — Dart ops work in fresh sandbox dirs. A Dev Drive source additionally needs the container filters allow-listed once (elevated), then a remount:fsutil devdrv setFiltersAllowed /volume D: "bindFlt,wcifs". The filter list is ONE quoted argument — the unquotedbindFlt, wcifsform previously written here is parsed as two arguments and fails with a bare syntax dump, which is the very trapwindows-container-build-performance.md§ Transport B documents. That page owns the full setup, verification and revert steps (including the reboot and the “allowed vs attached” distinction); do not restate them.The bind-mount target must NOT already exist in the image (verified 2026-07-16):
--mount target=C:\workspace(a baked image dir) fails at container creation withhcs::CreateComputeSystem ... Die Anforderung wird nicht unterstützt, while the same source mounted to a fresh target (target=C:\ws-mnt) works. Version-matched CI runners mount over existing dirs fine — consider not pre-creatingC:\workspacein the image, or adopt a fresh-target convention on skewed hosts.docker cpinto a running Windows container silently copies nothing, and against a stopped container it triggers the ActivateLayer lock. Usetar -cf - . | docker exec -i <container> tar -xf - -C <dir>instead.
The run-side variant has its own “is the bug gone yet?” probe, mirroring the commit-side one — re-run it after any Docker / containerd / hcsshim / Windows / base-image upgrade:
.\windows\diagnostics\test-layer-rename.ps1
It renames files in a fresh sandbox dir (CONTROL, expected PASS) and in an
image-layer dir (C:\Windows\Temp; VERDICT, expected FAIL today) and prints
BUG GONE (exit 0) / BUG PRESENT (exit 1) / unexpected-signature (exit 2).
Pass -Base <image> to probe the built developer image’s own layers.
GPU acceleration in containers (DirectML on the host GPU)#
On Windows, GPU acceleration in containers is DirectX-only — Direct3D 12 and
everything layered on it, which includes DirectML (the ONNX DmlExecutionProvider
and onnxruntime-genai’s DML path). CUDA/TensorRT cannot be GPU-accelerated in a
Windows container. On this host that is exactly the point: the machine has an AMD
Radeon RX 9070 XT (+ iGPU) and no NVIDIA GPU, so DirectML — being
vendor-agnostic — is the only GPU path. (The CUDA/TensorRT EPs are still built and
smoke-checked for availability, but they have no device to run on here.)
Running DirectML on the physical GPU inside a container requires all of:
Process isolation — Hyper-V-isolated containers get no GPU. (The default isolation on this host is
hyperv, so you must pass--isolation process.)The DirectX GPU device, attached with the exact device interface class GUID:
--device class/5B45201D-F2F2-4F3B-85BB-30FF1F953599. A wrong variant is silently accepted bydocker runbut matches no device, so the container falls back to the WARP software renderer with no error.A base-image OS build that matches the host build. Basic process isolation tolerates skew (a
26100image runs on a26200host), but GPU driver-store injection does not —hcs::CreateComputeSystemfails with “The system cannot find the path specified” when the builds differ. This is the same client-host (26200/ 25H2) vs Server base image (servercore:ltsc2025=26100) skew that breaksdocker build --isolation processlayer commits.
Current status on this host: BLOCKED by the build skew. The GPUs are GPU-PV
partitionable (Get-VMHostPartitionableGpu lists both AMD adapters), process
isolation works, and the DirectML runtime is built correctly — but GPU device
assignment fails at CreateComputeSystem because the ltsc2025 (26100) base does
not match the host (26200), and no public client 26200 base image exists to
rebuild against. A DXGI enumeration inside the (correctly-flagged) container therefore
sees only Microsoft Basic Render Driver (WARP), zero hardware adapters.
To retire the block: rebuild the base on a servercore/nanoserver tag whose build
equals the host, or run the image on a host whose build equals the image
(26100, e.g. a Windows Server 2025 host). Until then, DirectML on the AMD GPU
still works fine outside containers — run the source-built ORT / GenAI binaries
directly on the bare host and the DmlExecutionProvider selects the RX 9070 XT.
Re-check after any Docker / containerd / hcsshim / Windows / base-image / GPU-driver
upgrade with the self-contained probe under windows/diagnostics/:
.\windows\diagnostics\test-gpu-passthrough.ps1
It prints host/image builds and partitionable GPUs, runs a process-isolation control,
attaches the GPU device, compiles + runs a DXGI adapter enumerator inside the
container, and gives a verdict: PASSTHROUGH WORKS (a HARDWARE adapter is visible),
BLOCKED (build-skew CreateComputeSystem failure), or DEVICE-NOT-INJECTED
(started but only WARP). Note the DML probes in smoke-test-container.ps1 validate
that the provider is built and registered (GetAvailableProviders → dml=1, plus
the x64 D3D12Core.dll PE-machine check); they do not create a device, so they
pass under either isolation regardless of whether a hardware adapter is present.
Rust toolchain (rustup WITH a default toolchain — never toolchain-less rustup)#
Rust is provisioned exclusively via rustup (setup-rust-toolchain.ps1 runs
rustup-init.exe -y --default-toolchain stable --profile minimal), and
flutter_rust_bridge_codegen is baked alongside so Flutter+Rust consumers skip a
minutes-long cold cargo install per fresh container.
rustup is required, not merely tolerated: Flutter’s Cargokit (the build
glue used by flutter_rust_bridge-style plugins, e.g. rust_builder/cargokit in
Kataglyphis-Inference-Engine) enumerates toolchains/targets via rustup and aborts
with “rustup not found in PATH.” otherwise — a scoop-only Rust (the previous
setup) failed every Flutter+Rust consumer build at the CMake install step.
The failure mode the old “never rustup” rule guarded against is real but
narrower than the rule: a toolchain-less rustup (rustup-init --default-toolchain none) drops proxy shims (cargo.exe, rustc.exe, …) into
CARGO_BIN that resolve no toolchain and fail with “rustup could not choose
a version of cargo … no default is configured”. A rustup installed with a
default toolchain resolves fine — and because Dockerfile.base points
CARGO_HOME/CARGO_BIN at C:\Users\ContainerAdministrator\.cargo, which sits
ahead of scoop’s shim dir on PATH, the proxies winning is now the correct
outcome. Keep exactly one Rust provider: no scoop install main/rust alongside.
Rust is DELIBERATELY unpinned on this lane (stable at build time;
versions.env’s RUST_VERSION pins only the Linux lane). The smoke test asserts a
well-formed rustc version, the Cargokit probe shape (rustup show active-toolchain, rustup which cargo), flutter_rust_bridge_codegen --version, and a compile/link/run probe — never the versions.env value.
Media fan-out and memory budgeting#
Media scheduling is sequential (media branch logs land in
out\windows-build-logs\media-core.log / media-litert.log / media-tvm.log,
plus gstreamer.log for the merge). Sequential gives media-core the whole host
RAM budget — and since its parallelism is memory-bound, more RAM = more ONNX jobs,
which matters more than overlapping the small aux branches (a former
-ConcurrentMedia overlap mode was removed for exactly that reason).
-MediaMemoryGb auto-detects from host RAM (default 0 = auto). It resolves
to usable_physical_GB − HostReserveGb. -HostReserveGb (default 22 — see the
learned-the-hard-way note below) is the RAM left for Windows + dockerd + Defender;
lower it to push closer to the metal (riskier — under memory pressure the hcsshim
ttrpc wedge is more likely). Pass an explicit -MediaMemoryGb N to override
auto-detection. The cap is forwarded as MEMORY_LIMIT_GB so the build scripts
scale their job count to the container’s cap (BUILD_JOBS overrides the heuristic
outright).
Worked example (this 64 GB host, Windows reports 61.4 GB usable → floor 61,
default -HostReserveGb 22): auto -MediaMemoryGb = 61 − 22 = 39 g →
ONNX runs ~j10 (mem/4, cores=32).
media-core, toolchain, and the merge/GStreamer stage all build via the run+commit
path (see § Build isolation and CPU parallelism) at -MediaCoreCpus CPUs. The
litert/tvm aux branches run+commit at -MediaCoreCpus too — the full budget is
free once media-core has committed.
Maximum resource envelope (verified 2026-07-12)#
The defaults ARE the maximum for this 64 GB / 32-thread host — there is no faster configuration to unlock, and the full-chain rebuild of 2026-07-12 (base → sdk → toolchain → media → final, phase-tagged resource CSV) is the proof:
Phase |
Minutes |
AvgCpuPct |
MaxCpuPct |
MinFreeGB |
|---|---|---|---|---|
media-core |
111 |
37 |
100 |
0.2 |
media-litert |
18 |
38 |
100 |
24.9 |
media-tvm |
~25 |
42 |
100 |
41.8 |
CPUs: 32/32 on every heavy stage.
docker run --cpu-count 32(run+commit) is the only >2-CPU path on this host; every compile stage uses it.docker buildstages are pinned at 2 CPUs by the host defect — that is why they carry only cheap COPY/clone layers.RAM: 39 GB is the measured optimum, not a conservative default. During media-core the host bottomed out at 0.2 GB free — the 22 GB reserve was consumed almost exactly. Raising
-MediaMemoryGb(or cutting-HostReserveGb) does not add jobs fast enough to beat the starvation cliff: the 53 GB experiment deadlocked media-core at 0 % CPU (see the hard-way note below).Average CPU of ~35–45 % during compiles is CORRECT and expected — it is the memory-bound signature (
jobs = min(32, 39 GB / ~4 GB-per-ONNX-job) ≈ 10), not a tuning failure. Do not chase 100 % average CPU on this host.The only real “go faster” levers are infrastructural: ~128 GB RAM (true
j32on ONNX), or a populated sccache remote (-SccacheEndpoint/SCCACHE_WEBDAV_ENDPOINT) to make rebuilds warm — cold full-chain is ~5–6 h with ~2.5 h of that in the media fan-out.
Per-run resource log. Every build.ps1 run samples host CPU / free RAM /
commit charge / container-VM (vmmem) size every 20 s into
out\windows-build-logs\resources-<timestamp>.csv, tagged with the current build
phase (build:<dockerfile>, run:<stage>, commit:<stage>), and prints a
per-phase exhaustion summary at the end — including on failure. Re-analyze any
run later with
pwsh -File windows/scripts/build-resource-sampler.ps1 -Summarize -CsvPath <csv>;
MinFreeGB per phase shows which step pushed the host hardest, and an
AvgCpuPct far below 100 during a compile phase means the step was memory-bound
(jobs = min(cores, MEMORY_LIMIT_GB/perJob)), not CPU-bound. Disable with
-NoResourceLog.
Why the reserve is 22 GB, not ~8 (learned the hard way). An earlier default of
-HostReserveGb 8auto-sized media-core to 53 GB, which hung the build: during a GPU build dockerd + containerd juggling the ~50 GB CUDA image layers, plussvchost/Defender, hold ~16–18 GB steady — so 53 GB container + ~17 GB host exceeded the 61 GB physical, the Hyper-V VM starved at ~43 GB, and media-core deadlocked at 0 % CPU with the host at 0.3 GB free (log frozen mid-ONNX for 2 h). The--memorycap is real RAM committed to the utility VM, socontainer_cap + host_footprintmust fit physical RAM with margin. 22 GB reserve (→ ~39 GB container, ~56 GB peak) is the verified-safe budget here. The heavy CUDA TUs (FlashAttention, MoE kernels) use more than the ~4 GB/job estimate, so do not shrink the reserve without watchingdocker stats+ host free RAM.
Persistent compile cache (sccache)#
Without BuildKit cache mounts a container-local sccache cache dies with the
layer, so the WebDAV remote is the only compile cache that survives a
container. sccache is therefore REQUIRED by default for the media stages:
build.ps1 fails fast when a media stage is requested and no reachable endpoint
is configured (-NoSccache opts into a deliberate cache-less build). The
gate is media-only (Assert-SccacheEndpoint, $compileStages = @('media') in
WindowsBuildDriver.Common.psm1) — the toolchain stage (MSBuild/ClangCL
CPython) has no sccache wiring, so toolchain-only builds are never blocked on
an endpoint they would not use. One-time
host setup:
# one-time host setup (any WebDAV-capable server works; dufs is a single binary)
scoop install dufs
mkdir C:\sccache-cache
dufs C:\sccache-cache -A -p 5000
# then build with the endpoint (use an IP reachable from inside containers,
# e.g. the host's LAN IP — not localhost)
.\windows\build.ps1 -Gpu -SccacheEndpoint http://192.168.1.10:5000
CMake-based builds (ONNX, GenAI, OpenCV, LiteRT, LiteRT-LM, TVM) then route
clang-cl through sccache, and since 2026-08-04 GStreamer (Meson) is cached too
(build-gstreamer-from-source.ps1 sets CC/CXX to 'sccache clang-cl'
when the remote backend is configured). FFmpeg (MSVC/make) remains uncached.
The first build populates the cache; subsequent --no-cache rebuilds and
version bumps reuse unchanged object files.
Note (.dockerignore): The repo
.dockerignoremust NOT contain awindows/exclusion — the Windows Dockerfiles COPY from thewindows/scripts/directory within the build context. Ifwindows/is added to.dockerignore, the COPY steps will fail with “file not found in build context”. This exclusion is safe for Linux builds (which uselinux/context) but breaks Windows builds.
Stevedore Setup Fixes#
After installing Stevedore, apply these post-install fixes. They are the canonical source and are maintained in lockstep with the project’s CI requirements.
Fix 1: Remove stale Docker Desktop daemon.json#
If Docker Desktop was previously installed, its daemon config at C:\ProgramData\docker\config\daemon.json may specify a hosts pipe (docker_engine_windows) that conflicts with Stevedore’s docker_engine pipe. Remove it:
if (Test-Path "C:\ProgramData\docker\config\daemon.json") { Remove-Item "C:\ProgramData\docker\config\daemon.json" }
Fix 2: Change default runtime from hcsshim to runhcs#
Stevedore’s service defaults to the com.docker.hcsshim.v1 runtime, but only the io.containerd.runhcs.v1 shim binary (containerd-shim-runhcs-v1.exe) ships with Stevedore. Update the service binary path:
sc config stevedore binPath="\"C:\Program Files\Stevedore\dockerd.exe\" --run-service --service-name stevedore --group docker-users --host npipe:////./pipe/dockerDesktopWindowsEngine --host npipe:////./pipe/docker_engine --containerd=npipe:////./pipe/containerd-containerd --default-runtime=io.containerd.runhcs.v1"
Then restart:
net stop stevedore /y
net start stevedore
Fix 3: Windows Defender exclusions for containerd data#
Add exclusions for containerd’s snapshot directories (prevents hcsshim layer commit errors — hcsshim::ActivateLayer failed (0x20)):
Add-MpPreference -ExclusionPath "C:\ProgramData\containerd"
Add-MpPreference -ExclusionPath "C:\ProgramData\nerdctl"
Add-MpPreference -ExclusionPath "C:\temp"
Fix 4: docker.exe vs nerdctl (historical — pre-CNI-conf state)#
Before the CNI nat conf was installed (2026-08-03), nerdctl build lacked
DNS resolution and nerdctl run failed outright on this host (failed to create default network: needs CNI plugin "nat" to be installed in CNI_PATH —
the conf, not the binary, was missing), so docker.exe was the only working
tool. Current state: with 0-containerd-nat.conf installed (see § Getting it
going, step 2) nerdctl works from admin shells; builds go through
build-buildkit.ps1/buildctl on the preferred lane. Stevedore’s docker.exe
remains the classic-lane tool and needs no CNI plugin:
"D:\Stevedore\bin\docker.exe" build --platform windows/amd64 --no-cache -t local/kataglyphis:windows-base -f windows/Dockerfile.base .
Running the Image#
Run with process isolation to get the host’s full CPU count (Hyper-V
isolation, the Windows default, exposes only 2 logical CPUs). Process isolation
is allowed here because the host build (26200) is ≥ the container base build
(servercore:ltsc2025, 26100):
& "D:\Stevedore\bin\docker.exe" run --memory 48g -it --rm --isolation process `
ghcr.io/kataglyphis/kataglyphis_beschleuniger:winamd64
Drop --isolation process to fall back to Hyper-V isolation (stronger boundary,
but capped at 2 CPUs on this host). NAT networking and DNS work in both modes.
Smoke Testing#
After building, run the container smoke test to verify all components:
# Run smoke tests inside the built container. On a GPU (nvidia-lane) image,
# ALWAYS pass -ExpectGpu: without it a broken/missing CUDA_ROOT env silently
# SKIPS the whole CUDA section instead of failing it (the gate otherwise
# cannot distinguish a legitimate CPU-only image from a damaged GPU image).
& "C:\Program Files\Stevedore\bin\docker.exe" run --memory 48g -it --rm --isolation process `
ghcr.io/kataglyphis/kataglyphis_beschleuniger:winamd64 `
pwsh -File C:\temp\scripts\smoke-test-container.ps1 -ExpectGpu
The smoke test validates 22 categories including CUDA Toolkit 13.3, ONNX Runtime with CUDA, ONNX GenAI with CUDA, LiteRT with GPU delegate, LiteRT-LM with CUDA, OpenCV with CUDA, GStreamer with CUDA, TVM (source-built), IREE (source-built; native MLIR→vmfb compile + local-task execution, a CUDA-target compile-only assert on the GPU lane, and a python iree.compiler→iree.runtime end-to-end), FFmpeg (source-built with DNN/ONNX integration), compiler integration, environment-pointer integrity, and Python bindings. Current baseline (2026-07-14, GPU lane): 167 passed / 0 failed / 1 skipped — the single skip is GPU device passthrough, blocked by the host/base OS-build skew. This number is known-stale and WILL rise: the mandatory-plugin assertions (2026-08-07) and the SCOOP_GLOBAL_SHIMS checks (2026-08-08) were added after it, and no full run has been made since. Record the new figure here from the next green run rather than treating a higher count as a regression. Growth over the 153 baseline: the PyAV asserts (staged av-*.whl + an in-memory mpeg4 encode through the container-built FFmpeg) and the IREE suite (section 22 native compile+run incl. a CUDA-target compile-only assert, wheel-pin + --version asserts, section 20 staged-wheel + python end-to-end asserts, section 19 IREE_ROOT/IREE_BIN pointers).
What is verified: native vs. Python#
Native (C++/CLI) functionality is verified end-to-end. The suite does not stop
at existence checks: it compiles, links, and runs probe programs against the
source-built libraries — ONNX Runtime (C API ABI + a real inference session over
an embedded 63-byte Identity model on the CPU EP), OpenCV (core API call), TVM
(full dependent-DLL chain load), LiteRT-LM (its litert_lm_main.exe smoke-run is
a hard gate of the media build itself), FFmpeg (a real lavfi→null filter graph),
GStreamer (a live videotestsrc ! videoconvert pipeline), plus clang-cl /
CMake+Ninja / MSBuild integration builds. Version pins (cmake, python, gstreamer)
are asserted against versions.env to catch stale baked layers.
The toolchain pins are asserted one layer earlier instead — clang-cl, ninja
and nasm are checked against versions.env by verify-toolchain.ps1 during the
BASE build, where a mismatch costs seconds rather than surfacing two hours into
media-core. This suite deliberately keeps only a well-formedness check on
clang-cl (plus a non-fatal warning when the image’s baked pin disagrees), because
it also runs against PUBLISHED and older images whose compiler legitimately
predates the current pin — failing those would make it useless as a regression
gate. It does assert that C:\toolchain-manifest.json exists and records a
resolved compiler, skipping on images built before the manifest existed.
Python bindings are built, shipped, and functionally verified (since
2026-07-13). The media branches build python bindings for every source-built
library that supports them and stage the wheels centrally at
C:\runtime\wheels (PYTHON_WHEELS env): onnxruntime (CUDA+TRT+DML EPs,
ENABLE_PYTHON=ON), onnxruntime-genai-cuda (BUILD_WHEEL=ON),
apache-tvm (scikit-build-core), iree-base-compiler + iree-base-runtime
(built from the IREE ninja tree’s synthesized compiler/+runtime pip dirs
with --no-build-isolation so the wheels pack the existing LLVM objects
instead of rebuilding them), and av (PyAV compiled from sdist against
the source-built FFmpeg via setup.py --ffmpeg-dir — PyPI’s own av wheel is
structurally unloadable on Server Core because its bundled avdevice imports
the desktop-only AVICAP32.dll; note the generic h264 encoder alias
resolves to h264_d3d12va, so headless code should request software codecs
like mpeg4/libx264 by name). FFMPEG_VERSION is pinned to the release tag
n9.0 since 2026-08-04 (it previously tracked master, which is when an
upstream drop moved avformat.lib et al. from lib\ to bin\ overnight —
2026-07-13, PyAV died with LNK1181). build-ffmpeg-from-source.ps1 still
normalizes the import-lib layout after make install as a guard across tag
bumps: every .lib/.def is
harvested into lib\, missing import libs are regenerated from their .def
via lib.exe, and the PyAV step logs the lib inventory up front so the next
layout drift fails loudly with data. cv2 ships installed into CPython’s
site-packages (the opencv repo has no wheel machinery — opencv-python is a
separate upstream project); LiteRT has no python bindings on this lane
(bazel-only python package). All bindings are pre-installed with their PyPI
deps, so python -c "import onnxruntime, onnxruntime_genai, cv2, tvm, av"
works out of the box. Smoke section 20 verifies wheels + win_amd64 tags, real
python-side ONNX inference, a cv2 PNG round-trip, and genai/tvm imports.
Load-bearing plumbing (do not remove): the sitecustomize.py shim fixes the
clang-built CPython’s win32 platform misreport AND registers the image’s
native DLL homes via os.add_dll_directory (CUDA 13/cuDNN 9 keep their
runtime DLLs in bin\x64; python 3.8+ ignores PATH for pyd dependencies);
OpenCV builds with WITH_MSMF=OFF and WITH_OBSENSOR=OFF because both
hard-import Media Foundation, which Server Core does not ship.
The torch step (Orchestr-ANT-ion app environment)#
The final image bakes the runtime orchestrator at
C:\opt\Kataglyphis-Orchestr-ANT-ion (TORCH_APP_DIR), assembled by
windows/scripts/assemble-torch-app.ps1 (mirror of the linux
assemble-torch-app.sh stage) during the final docker build:
Ref:
build.ps1uses versions.env’sAPP_REFpin by default (the same commit always builds the same final image); pass-LatestAppto opt into resolving the app repo’s newest release tag at build time via a livegit ls-remote(the old always-on behavior). The resolved ref reaches the Dockerfile as theAPP_REFbuild-arg, so moving the app busts exactly the torch-step layer.Environment:
uv syncon the source-built CPython (extrasml-ai,docs,pytorch-cpu,test; the wxPython GUI extra excluded, like linux), then a reconcile so this lane’s wheels always win: PyPI onnx/genai/opencv families are uninstalled,C:\runtime\wheelsforce-installed--no-deps(genai-cuda’s metadata namesonnxruntime-gpu, which our combined wheel replaces), andcv2+tvm_ffi+ the sitecustomize shim staged from base site-packages into the venv.Known limitation:
ai-edge-litertis skipped (--no-install-package) — its pinned version ships no cp314 wheel and the LiteRT python package is bazel-only on Windows, so the app’s LiteRT code path is unavailable in this venv.Gates: the docker build itself fails unless the venv passes the import battery (numpy/cv2/torch/onnxruntime with a CUDA-EP build assert/genai/tvm) and the app’s own wheel-smoke suite (
python -m orchestr_ant_ion.smoke— real torch/torchvision/ORT-inference/OpenCV work). The check inventory is the app’s per-tag choice, so the expected pass count moves withAPP_REF; the rule on this lane is: all checks pass except a single WARN for the litert skip (theai-edge-litertlimitation above), plus any checks the pinned app tag does not yet ship (e.g. an iree check counts only once a tag includes it). Smoke section 21 re-runs the same verification offline on every suite run.Usage:
C:\opt\Kataglyphis-Orchestr-ANT-ion\.venv\Scripts\python.exe(oruv runfromTORCH_APP_DIR) is a ready environment whereimport onnxruntime, onnxruntime_genai, cv2, tvm, torchall resolve to the source-built wheels plus the app’s locked PyPI dependency set.
Windows Script Reference#
The authoritative per-script table for the Windows lane (AGENTS.md § Windows Build Notes points here — update THIS table, never a copy). Rows marked HOST maintenance run on the build host, need the stated elevation, and must never run while a build solves.
Script |
Location |
Purpose |
|---|---|---|
|
|
Ninja+clang-cl build with build.ninja patching and VsDevCmd wrapper |
|
|
Source-built directly via CMake+clang-cl (bypasses |
|
|
Ninja+clang-cl with global SIMD flags and mlas |
|
|
Ninja+clang-cl; GPU delegate (Vulkan+OpenCL), XNNPACK, external CUDA delegate. Injects + builds the TFLite C-API |
|
|
PRIMARY LiteRT-LM builder. Self-installs bazelisk + Temurin JDK; |
|
|
FROZEN FALLBACK (superseded by the Bazel builder above). Ninja+clang-cl; carries the v0.14.0 export-bridge patch stack ( |
|
|
Runs in the merge’s |
|
|
Ninja+clang-cl; auto-detects CUDA/Vulkan/LLVM; builds Python wheel; VsDevCmd for MSVC STL headers |
|
|
MSYS2 |
|
|
Meson+clang-cl with wrap pre-extraction; loads |
|
|
Reusable build helpers: |
|
|
Installs VS Build Tools 18 with ClangCL toolset |
|
|
Installs Git (installer) + WiX 4 (dotnet tool), then via Scoop: 7zip, Vulkan SDK, Flutter, LLVM, ninja, sccache, cppcheck, nano, nsis, uv, nuget, zlib, nasm, openssl, pkg-config, CMake. Installs no Rust (rustup via |
|
|
Bootstraps vcpkg for Windows |
|
|
Installs Rust via rustup WITH a stable default toolchain (sole provider; local |
|
|
Installs CUDA 13.3 + cuDNN; includes post-install verification (headers/libs/DLLs) |
|
|
Auto-detects a TensorRT zip in |
|
|
Reads |
|
|
Enables git long paths and sets |
|
|
Verifies clang-cl, lld-link, WiX, Flutter are present after base setup, and ASSERTS the pinned versions (clang-cl/ninja/nasm/CMake vs |
|
|
Docker |
|
|
Comprehensive container validation — 22 test categories (an earlier AGENTS.md copy of this row said 18 until 2026-08-08; this doc had the right count all along). Runs INSIDE the final image, which |
|
|
Smoke-test assertion harness, extracted 2026-08-08: counters plus |
|
|
The mandatory GStreamer plugin CONTRACT (see § Mandatory GStreamer plugins and AGENTS.md § Windows Build Invariants): |
|
|
Counts compiler warnings in a build log grouped by diagnostic family; |
|
|
HOST maintenance (admin, never while a build solves): installs a locally built |
|
|
HOST bring-up (admin, run |
|
|
HOST maintenance (admin): enable/disable the RDNA4 dGPU in Device Manager ( |
|
|
The committed build probe (assets |
|
|
RDNA4 layer-lock A/B (ELEVATED): probes RUN-layer finalize with the dGPU enabled, then disabled (auto re-enables in a finally). Verdicts: GONE / PRESENT / INCONCLUSIVE. Re-run after every Adrenalin or Windows update — a GONE verdict is the signal to retire the toggle workflow + |
|
|
CUDA-cache probe (non-admin, ~2 min, safe beside a live build): tiny buildctl solve FROM the local toolchain image compiles one |
|
|
Cross-machine forensics for “works there, fails here”: dumps OS build, optional features (DISM API health - reports “Klasse nicht registriert” when broken), filter drivers, services, engine versions, docker info, HNS. Writes |
|
|
HOST maintenance (admin, never while a build solves): full container-store reset - stops the services, RENAMES |
|
|
HOST maintenance (admin): prints, then applies if missing, the FULL Defender exclusion set for Windows-container builds - paths ( |
|
|
HOST maintenance (admin, long-running 10-40 min): |
|
|
The machine-checkable form of |
|
|
HOST config (admin, never while a build solves — applying restarts containerd and kills in-flight solves). The containerd counterpart to |
|
|
HOST maintenance (admin, never while a build solves): reclaims disk when the checkout/store sits on a dynamically-expanding VHDX. Kills stale |
|
|
Installs PowerShell 7 as the FIRST RUN of |
|
|
HOST maintenance (admin, never while a build solves): reclaims a dynamically-expanding VHDX by REBUILDING it around its live data — the only reliable reclaim on ReFS guests, where |
Refactor Backlog (Windows container chain)#
Cross-lane / Linux-side items live in docs/refactoring-backlog.md. OPEN items only. Everything completed — 0a, 0c, items 1-26, 29, 30, 32, 33, 36 and 37 (both closed 2026-08-13 when
:winamd64went green end-to-end), the whole W1/W2/W3 batches, the review history and all DONE evidence notes — is archived verbatim in windows-backlog-archive-2026-08-11.md. Standing rules that survive the close-out: check the cache-tier map before touching anything (media-closure edits cost one ONNX-vertex rebuild — batch them); never remove the deliberate media-merge version-ARG mirrors; and never trust a patch helper whose success message prints unconditionally (litert-lm’spatch_file_contentno-oped silently for two runs, 2026-08-11 — verify by the SYMPTOM disappearing, or use structurally-immune ops likefile(REMOVE_RECURSE)with a self-check); and NEVER setrewrite-timestamp=trueon the Windows image exporter — it crashes mid-finalize and POISONS the layer chain (the #34 side-finding).
HOW TO WORK THIS BACKLOG (execution batches — read this first)#
The P-sections below are ordered by SEVERITY, which is how to read the backlog. This section is how to execute it: grouped by what shares a rebuild, a file, or a decision. The numbers are discovery order, not work order — never work top-to-bottom by number. Batches A-C are independent of each other; D-H can follow in any order.
Batch A — CAPTURE FIRST (gates the accuracy of everything in P0b). #71’s
SCCACHE_ERROR_LOGis the one decisive artifact still missing, and the entire 49-run corpus behind P0b was CLIPPED (step-log env only became correct on 2026-08-13). Do this before theorising about causes, and re-run the forensics against the first fully-captured chain — it will confirm or revise #72’s export numbers and #74’s-j19. Nothing else in Batch A.Batch B — DONE 2026-08-14. (#39, #40, #41, #42, #43, #62, #63, #64 — landed, lint 152/0, tests 484/484, entries moved to the archive addendum.) The pattern is worth reusing: eight fixes in one sitting because none of them touched a Dockerfile, so nothing invalidated any cache and the whole batch was verifiable without a single container build. Group future work this way.
Batch C — DONE 2026-08-14 (the TensorRT chain, #53 then #38 — see the archive addendum). UNVERIFIED BY A BUILD: the logic was proven against synthetic trees on the host (normalize + two fail-closed cases + the zip-less graceful case), but the Dockerfile itself has not been rebuilt. The next sdk build re-pays CUDA + cuDNN once regardless, because #53 reordered the instructions above that RUN.
Batch D — the “nothing verifies the artifact” epic. #44, #46, #57, #67 are ONE piece of work, not four: the smoke test is never invoked, its skips are not fatal, it load-tests 1 of ~30 OpenCV DLLs, and it never checks the LiteRT exports. Fixing them separately means touching
smoke-test-container.ps1four times. Do #44 first (make it run and make SKIP fatal) — the other three are assertions inside the harness it enables.Batch E — base-tier. MUST be batched; never land alone. #50 + #81. One base rebuild (~30 min + full downstream invalidation) pays for both. Add any future base-tier item to this batch rather than landing it on its own — this is the same rule that governed #27.
Batch F — media cache tiering, NO base rebuild. #49, #51, #52, #54. Each is self-contained; #49 has the largest payoff (a PyAV bump currently re-runs the 75-min ONNX build).
Batch G — the test net. #55, #56, #58, #59, #60. #55 (
.gitattributes) and #59 (branch protection) are minutes of work and both currently fail open; do them first in this batch.Batch H — needs a measurement or a decision before any code. #72 is a genuine trade-off (export round-trips vs. resume granularity), #70 needs a configure-only probe, #74 needs one cold media-core to confirm, #31 needs your registry choice. Do NOT start these as coding tasks.
Cross-cutting note: #71 (sccache), #74 (
-j9) and #75 (the silent-jdowngrade ladder) are three views of ONE root cause. Fixing #71 may retire #75 outright — re-check it before working it.
P0 — LIVE DEFECTS (not refactors; the chain is green and wrong)#
Found by the 2026-08-14 deep audit (static sweep of 151 scripts + 6 Dockerfiles + 102 build logs). Each was verified against the tree/logs, not inferred. These ship broken today — do them before any refactor below.
71 [M·★★★, none] sccache has NEVER produced a single cache hit — it is a 100 % no-op, and every write has failed. Aggregated over 94 stat blocks spanning the whole log corpus:
Cache hits= 0 in 94/94, andCache misses=Cache write errors= 189,861 — exactly equal. Read errors are 0, so the read path is healthy; the defect is write-side only. Per stage the signature is stable (ONNX 1498, OpenCV 1862, LiteRT/XNNPACK 5033). CONSEQUENCE: no run has ever been able to seed the next, which is what makes 18.42 h of ONNX Runtime rebuilds across 24 executions possible — the step clusters at 78-82 min with <6 % run-over-run variance, the signature of zero reuse. BuildKit’s layer cache works (the step shows CACHED in the green run); it is only the compiler cache that is dead. This is the single highest-leverage item in the entire backlog: fixing it should collapse runs 2..N of any iteration cycle from ~78 min to minutes. NOTE the decisive artifact is still missing —SCCACHE_ERROR_LOGappears in NO log in the corpus (it lives inside the cache mount and was never captured), so capture it first (see the diagnostics queue).
P0b — Confirmed by log forensics (49 runs, 185 MB; 2026-08-14)#
The corpus predates the step-log fix, so 28 of these logs are CLIPPED (the green reference run is 49 % blind in its merge step; historical ONNX steps are 83 % blind). Findings below survive that caveat — they rest on end-of-step stat blocks and timestamps, not on clipped body text. Re-run the forensics once a full chain has been captured with the env now in place.
72 [M·★★★, none] Image export/unpack costs MORE than the build it wraps — 4.33 h across the corpus, 339 operations. The chain is split into 9+ separate
buildctlinvocations and each pays a full Windows-image export AND unpack. Torch: 358.4 s export vs 172.6 s build (2.08×). LiteRT: 1401.8 s export vs 1117 s build (1.25×). Today’s base build: 605.6 s export/unpack = 33.5 % of the whole stage. In the green 41:30 run, export/unpack is 23 % of the entire chain — more than every COPY, every fan-in and the torch build combined. No static reading of the Dockerfiles reveals this. FIX: collapse the fourmedia-core-built-*checkpoints into one invocation → removes 3 export/unpack round-trips per run. CONFLICT — decide before doing: those four checkpoints are exactly what gives media-core its per-library BuildKit-native resume (the reason litert/tvm, which lack them, re-pay a whole branch on any failure). Collapsing them buys ~3 export round-trips and costs that resume granularity. Measure both before choosing; this is a trade-off, not a free win.73 [S·★★★, none] Latent defect in the SHIPPED ONNX CUDA provider: infinite recursion in CUTLASS
udiv128. 225 occurrences ofuint128.h(96,90): warning: all paths through this function will call itself [-Winfinite-recursion], reached viaflash_api.h:36while compilingonnxruntime_providers_cudaTUs (attention.cc,paged_attention.cc,packed_multihead_attention.cc). CUTLASS selects an MSVC_udiv128intrinsic path that clang-cl does not resolve, so the function calls itself unconditionally → stack overflow if that path is taken at runtime. It sits in the flash-/paged-attention code of a shipped provider. Static analysis cannot see this — it exists only in the clang-cl port’s compiler output. FIX: runtime smoke test of flash-attention, then a clang-cludiv128patch alongside the existingpatches/onnxruntimeset.74 [S·★★★, none] ONNX builds at
ninja -j9while sccache is provisioned for 32 jobs. Corpus:ninja -j9×90,ninja -j19×1 (the single run after backlog #28 landed). Job count derives from the 39 GB memory cap, not core count, and the two subsystems disagree by 3.5×. 1498 TUs at -j9 is the direct cause of the 78-minute figure. #28 (MemGBPerJob 2) already addresses this for onnx/opencv but has only executed once — verify the -j19 figure holds on the next cold media-core, and extend the treatment to genai/tvm/litert (still 4).75 [S·★★★, none] The
-jdowngrade ladder is a SILENT self-heal that converts a failure into an hours-long serial rebuild. One run (bk-chain-20260810-nogpu) burned 11 h 17 m re-running the same ONNX build 11 times, each cycle endingninja -j9 failed (exit 2) - retrying incrementally with -j2...at 4911.5 s / 4909.2 s — a ±2 s determinism that identifies the sccache-CUDA server crash rather than an env flake. 12 occurrences corpus-wide. FIX: make the downgrade loud and bounded; abort instead of grinding serially.76 [S·★★, none] The ~120-min ffmpeg stall (old #35) is CONFIRMED as a one-off and DENIED as recurring — and it is a TIMEOUT, not jitter. Exact gap: 7200.9 s (≈ exactly 2 h) of zero output between
WARNING: vswhere returned no installation; using filesystem fallbackandReplaced MSYS2 awk with gawk. In 10 other runs that marker lands at t = 11.5-17.9 s. Actual ffmpeg work in that step was only ~162 s. The two-hour boundary reads as a network timeout in the MSYS2/gawk provisioning call. Not recurred in 9 subsequent runs → latent, not active. FIX: bound that step with an explicit timeout + heartbeat. Supersedes the old #35 observe entry, which can now be closed.77 [S·★★, none] GStreamer’s GES
_commitconflict is patched REACTIVELY after a failed compile — deterministic, 3/3 runs, ~20 min discarded each time.Compile attempt 1 failed; patching _commit conflict in GES and retrying...at 1236.8 s / 1392.2 s / 1391.3 s in three separate runs. Tight clustering + 100 % reproduction = this belongs inpatches/gstreamerapplied up-front, not as a post-failure repair.78 [S·★★, none] The VS major-version pin is NOT being honoured — the toolchain is pinned by luck. Today’s base build:
WARNING: major-pinned VS alias unavailable — used floating 'stable' channel (currently VS 18…). Plusvswhere returned no installation; using filesystem fallback×100 — so the build depends on a literal path string rather than on discovery. The day Microsoft promotes VS 19, the pin floats AND the fallback path breaks simultaneously, re-opening the documented vcpkg/VS-toolset rejection class.79 [S·★★, none]
aka.msserves HTML instead of the VS bootstrapper binary — the same failure family as the known nuget trap. Today’s base build:expected a MZ-signature file but got first bytes 60,33 (likely an HTML error page)—60,33is<!. Also 3 consecutive failures againstapi.adoptium.netfor JDK 21. The MZ-signature guard is excellent defence, but the retry budget is 2 and it self-heals only because a fallback URL exists. The pre-seed fix already applied to nuget was never extended to the VS bootstrapper or Adoptium.80 [S·★★, none] 96 % of the warning stream is 5 noise classes, hiding 1,055 genuine signals. Corpus totals:
-Wunused-parameter68,502,-Wdocumentation-unknown-command18,144,-Wdeprecated-copy…17,887,-Wundef17,056,-Wmissing-field-initializers12,294 — vs the signal classes-Winconsistent-missing-override9,449 (vtable/ABI),-Wundefined-var-template552 (ODR/link),-Winconsistent-dllimport252 (Windows linkage),-Winfinite-recursion225 (#73),C471526 (UB — all in the vendoredtvm-ffi/.../creator.h(112), falling off the end of a value-returning function). Also-Wunused-command-line-argument7,200:/Zc:preprocessoris passed but ignored by clang-cl — a config smell worth removing. FIX: suppress the top-5 noise classes at build-script level so CI can see the rest.
P2 — Fail-open gates & silent degradation (green build, crippled image)#
44 [S·★★★, none] Nothing verifies the artifact: the smoke test is never run, and SKIP is not fatal.
grep -i smoke windows/build.ps1 windows/build-buildkit.ps1→ 0 hits in both; no CI reference; the final Dockerfile has zero RUN steps.smoke-test-container.ps1:1376exits 0 onFailed -eq 0whileSkippedis printed and never consulted — with 24Skip-Testsites and seven env-gated sections, a misconfigured run prints “All smoke tests passed!” having asserted nothing. Last recorded baseline: 2026-07-14. FIX:-MinPassed/-MaxSkippedfloors + invoke it from both drivers against the produced tag.45 [S·★★★, none] A mis-plumbed CUDA path yields a fully green, CPU-ONLY media chain — discovered hours later.
WindowsSourceBuild.Cuda.psm1:47gates onTest-Path $cudaRoot; every consumer then takes a quiet else-branch (build-onnx:307“CPU-only build”,build-opencv:277WITH_CUDA=OFF,build-tvm:39silently).GPU_TYPE=nvidiais baked atDockerfile.nvidia:93, so “lane says nvidia but no CUDA” is never legitimate. Cost: ~75 min ONNX + ~30 min OpenCV + ~45 min GenAI all green and all useless. The explicit opt-outs (ONNX_FORCE_CPU,GENAI_FORCE_CPU) already exist, so athrowis safe. FIX: fail closed whenGpuType -eq 'nvidia' -and -not $CudaRoot.46 [S·★★, none] DirectML can vanish from the image with zero red at either end — and on the reference AMD host it is the ONLY working GPU path. Staging warns instead of failing (
WindowsSourceBuild.Common.psm1:1103,1117) and the smoke test skips itself when the artifact is absent (smoke-test-container.ps1:572), i.e. the gate is keyed on the very thing it should verify.USE_DML=ONis unconditional, so absence is never legitimate.47 [S·★★, none] TVM silently drops LLVM / Vulkan / cuDNN.
build-tvm-from-source.ps1:76-82(and :68-73, :53-64) print on the ON path and print NOTHING on the OFF path.USE_LLVM=OFFremoves TVM’s CPU codegen entirely: build green,import tvmgreen, and everytvm.buildfor an LLVM target fails at runtime in the shipped image. An LLVM/scoop bump that dropsllvm-config.exeoff PATH is a plausible one-line regression.48 [S·★★, none] Two host gates fail OPEN.
Assert-ShimPatchwarns and returns green when the shim is missing (WindowsBuildDriver.Common.psm1:590) — including on aD:\Stevedorehost, a layout the same driver explicitly supports for buildctl (build-buildkit.ps1:132); the gate exists because a stock shim meansExportLayer 0x3after the compile is paid for. And the per-stage disk gate only ever checksC:(:719default, neither caller passes-Drive) although the launch gate learned the opposite lesson the hard way — the repo lives on the D: VHDX that “fell to 11.7 GB free while a C:-only gate reported everything fine”.
P2b — Per-component build-script gaps (sibling scripts that drifted apart)#
65 [S·★★★, none] GStreamer compiles with NO job budget, NO retry ladder and NO sccache stall guard — while using sccache. Verified: 0 hits for
Start-SccacheStallGuard/Get-BuildJobCount/MemGBPerJobinbuild-gstreamer-from-source.ps1. It sets$env:CC = 'sccache clang-cl'(:205) then runsmeson compile(:879) with no-j, so ninja’s default (cores+2) ignoresMEMORY_LIMIT_GBentirely — exactly the OOM shapeMemGBPerJobexists to prevent. It is also the ONE compile stage using sccache without the watchdog written for the documented sccache-server deadlock; a wedge there hangs the merge stage indefinitely with no kill/resume. FIX:meson compile -j (Get-BuildJobCount -MemGBPerJob 2)+ the stall guard.66 [S·★★★, none] GStreamer’s “must resolve NOW” pre-flight runs AFTER the tarball, ~20 wrap downloads and five patch loops. The gate’s own comment reads “Everything the required set needs must resolve NOW, not after an hour” (:676) — but the block starts at :522 while the downloads run at :242-:323 and patching at :404-:485, and the things it checks (OpenCV headers,
onnxruntime.lib, LiteRT headers,tensorflowlite_c.lib) depend on NONE of that work. Hoisting it above :228 turns a missing media fan-in from “full download+patch phase, then fail” into a ~5-second failure.67 [S·★★★, none] LiteRT gates on
tensorflowlite_c.libbut never on the DLL or its EXPORTS (build-litert-from-source.ps1:180-187). The documented failure was an import lib that existed while the DLL exported ZERO C-API symbols — the lib-only assert is structurally blind to that recurrence, and the just-shipped/EXPORT:+WINDOWS_EXPORT_ALL_SYMBOLSwork therefore has no regression gate. FIX:dumpbin /exports(orllvm-nm) on the produced DLL for the three XNNPack symbols the link options force.68 [M·★★★, none] FFmpeg’s prebuilt fallback ships a MIXED install, and the skip-if-present early return bypasses every gate on re-entry. On a missing
ffmpeg.exeit downloads BtbN’s zip and copies*.exe/*.dllover whatever a partialmake installleft (:441-459), while OUR import libs and.pcfiles stay — so gst-libav links a version mismatch, announced by oneWrite-Warning. Separately:110returns early whenffmpeg.exeexists, so a-ResumeFrom FFmpegafter such a failure skipsAssert-FfmpegPkgConfig, the import-lib assert and PyAV — the resumed run cannot detect the broken install it inherited.69 [S·★★, none] Live pin drift that the parity gate structurally cannot see.
build-ffmpeg-from-source.ps1:241hardcodeselse { 'n13.0.19.0' }againstversions.env:184 NV_CODEC_HEADERS_REF=n13.1.15.0— verified drift.SourceBuild.PinParity.Tests.ps1:80scans onlyGet-SourceBuildVersioncall sites, so theif ($env:X) {…} else {<literal>}idiom is invisible to it (~13 such sites; four more version literals bypass the gate the same way). versions.env:180-183 records that a wrong nv-codec-headers ref once “404’d and NVENC was silently skipped on both lanes” — this is that incident’s seed, re-planted. FIX: route the literals throughGet-SourceBuildVersion; teach the AST scanner the second idiom.70 [S·★★, none] FFmpeg is the only compile stage with NO sccache wiring at all — verified: 0
Write-SccacheStatscalls, and the script never sets the sccache endpoint. The precedent is its sibling, which documents that GStreamer “ran completely uncached (~30 min hot)” until 2026-08-04 because “the merge builder simply never wired the endpoint through”. A 30-60 min stage recompiles cold every attempt and is not even MEASURABLE. Emitting the stats is unconditionally safe; whether FFmpeg’sconfiguretolerates--cc="sccache clang-cl"needs a configure-only probe first.
P3 — Cache tiering (pure rebuild-time cost; no correctness change)#
49 [M·★★★, media-core once] Nine version ARGs share ONE ENV layer directly above the ~75-min ONNX compile.
Dockerfile.media-builder:142-168declares ONNX/GENAI/OPENCV/FFMPEG/PYAV/NV_CODEC/CUDA_ARCH/PYTHON in a singlemedia-core-env, and opencv/ffmpeg/genai chainFROMONNX’s output. So a PyAV bump re-runs the full ONNX build and cascades through the whole branch (hours). The 2026-08-07 versions.env-COPY removal fixed this at BRANCH granularity and never reached COMPONENT granularity. FIX: move each ARG+ENV into the stage that consumes it.50 [M·★★★, base once]
versions.envis COPY’d above scoop + vcpkg + the ~30-min rust/sccache-from-source layer.Dockerfile.base:87-89, then:114-120, then:156. versions.env is shared by BOTH lanes, so editing a purely Linux key (PANDOC_VERSION,ROCM_VERSION,UBUNTU_DIGEST) re-pays GB-scale scoop + vcpkg + the 30-min rust layer on the next base build. The file already proves it knows the pattern —setup-vs.ps1was deliberately hoisted above this COPY for exactly this reason (:71-76). Only 8 keys are needed below the COPY; promote those to ARGs and move the COPY down. (The sibling ARG-below-the-expensive-RUN fix for TensorRT shipped 2026-08-14 — same pattern, see the archive addendum.)51 [M·★★★, media once]
MEMORY_LIMIT_GB— a scheduling knob — is an image ENV and therefore a CACHE KEY (Dockerfile.media-builder:29,67). The driver halves it for-ConcurrentAux(build-buildkit.ps1:378), so merely TOGGLING that flag changes the layer digest and invalidates every litert/tvm compile. Same on any host with different RAM.Dockerfile.torch:57-60already states the principle (“Build-time state belongs in the build step, not in the artifact”). FIX: derive in-container, or bind-mount it.52 [M·★★, toolchain] The toolchain builder never got the bind-mount treatment.
Dockerfile.toolchain-builder:38-43COPYs the shared module + versions.env + the build script into the stage whose child RUNs the CPython compile — so editing any of them (incl. a module ~30 scripts share) re-pays the full CPython build, and toolchain is the parent of every media branch.Dockerfile.media-builder:243-259documents the exact solution.81 [S·★, base — RIDE WITH #50, never alone] The base SHELL sets a variable that does not exist.
Dockerfile.base:58sets$PSNativeCommandErrorActionPreference = $false; the real pwsh variable isPSNativeCommandUseErrorActionPreference(verified against pwsh 7.6.4, exactlyPWSH_VERSION:Get-Variable PSNative*returns onlyPSNativeCommandArgumentPassingandPSNativeCommandUseErrorActionPreference). The assignment creates an unrelated variable and does nothing — the base believes it has a guard it does not have. Harmless today only because the real variable already defaults toFalse; the day pwsh flips that default (its stated direction), every native non-zero exit inside a base RUN starts throwing. Note the repo spells it correctly elsewhere (WindowsFormatting.Common.psm1:279). Also: all six derived Dockerfiles re-declareSHELLand drop the clause —SHELLIS inherited via image config, so those are redundant layers against the 125-cap.54 [S·★★, merge]
cuda-runtime-stageships a SECOND, flattened copy of the CUDA + cuDNN runtime DLLs (Dockerfile.media-merge-builder:138); cuDNN’s set alone is 0.52 GB uncompressed, plus CUDA 13’s cublas/cufft/cusolver/nvrtc. The originals are still in the image (merge descends from the nvidia stage) andDockerfile.nvidia:97already PATHs them. One extra PATH entry for cuDNN’s nested layout likely replaces the whole stage. NOTE: verify the actual cuDNN 9 nesting against the installed tree before removing the stage — the flatten fix was load-bearing for OpenCV’scudnn64_9.dll.
P4 — Missing regression tests (each maps to a bug that already cost hours)#
55 [S·★★★, none]
.gitattributesdoes not cover*.cmake/*.cc/*.cmd— only ps1/psm1/env/patch/sh/bash. All three are COPY’d into images,core.autocrlf=trueon this host, and the worktree is ALREADY inconsistent (one litert-lm.cmakeis CRLF while five siblings are LF). A fresh clone on anyautocrlf=truehost (incl.windows-latest) flips bytes on 5 of 6 patchers → busts media-litert and everything downstream. FIX: a test that enumerates every COPY-reachable path and assertsgit check-attr textis set. It fails today.56 [S·★★★, none] The CMake source-patchers have ZERO no-op detection and log “Patched” unconditionally. 22 replace-ops across 6 files, 11 unconditional success messages, one
FATAL_ERROR-shaped guard — and it is inside a comment.Test-PatchesApplyClean.ps1:95globs*.patchonly, so all 8 of these are outside the CIpatch-driftjob. This is exactly the sentencepiece duplicate-ABSL_FLAG(minloglevel)ODR bug that madelitert_lm_main.exelink-clean but abort on EVERY run: if upstream reformats the statement the regex no-ops, the log still says “fixes abseil flag ODR abort”, and the defect returns after a full media-litert build.57 [M·★★★, none] No DLL-LOAD enumeration — 1 of ~30 OpenCV DLLs is load-tested. The primitives exist and are good (
Assert-DllLoadsviaLoadLibraryW,Assert-NativeLinkRuncompile+link+run) but there are only 10 call sites, every one a hardcoded name. This is the OPENGL32 bug verbatim — existence checks passed, only a LOAD test caught it. FIX: enumerateC:\runtime\**\*.dll, LoadLibrary each, assert no0xC0000135, with an explicit allowlist. It is a loop over machinery that already exists.58 [S·★★, none] The
CUDA_ARCHITECTURESdirective is protected only by a fallback the real build path never reaches.SourceBuild.Resolve.Tests.ps1:34asserts80;86;89;90with the env var CLEARED; the next test proves the env value overrides it. Trimmingversions.env:261keeps the whole suite green andsync_versions.py --writewould then propagate the trim into the Dockerfile ARG. Given the standing never-trim directive, assert the PIN.59 [S·★★, none] Lint/tests are advisory, not gating.
mainis not branch-protected (gh api …/protection→ 404);windows-scripts.yml:50runs the linter WITHOUT-FailOnAnalyzer;.githooks/pre-commitruns the Linux preflight but neitherInvoke-Lint.ps1norInvoke-Tests.ps1. The gate is currently human discipline plus a post-hoc notification.60 [S·★★, none] Merge-builder’s 11 hardcoded version ARGs have no parity test.
BuildKit.TwinParity.Tests.ps1:25hardcodes the media-builder path; merge-builder is opened by no test, and PinParity never reads any Dockerfile. All 11 match versions.env today — the exposure is procedural, in exactly the stage where the “~8 versions.env-bump breaks” landed.
P5 — Observability (makes everything above measurable)#
61 [M·★★★, none] No per-stage timing, no run manifest, and stage logs are OVERWRITTEN every run.
build-buildkit.ps1:284names logs by label only — no run id, no timestamp — so run N truncates run N-1, andLimit-DiagnosticLogs -Keep 80never fires because there are only ~10 distinct names. On failure the BK lane prints no elapsed time at all (the total is past the throw; thefinallyonly pops the location). The Linux orchestrator already emitschain-status.jsonper stage — Windows has no equivalent, so run-over-run comparison is done by hand in CHANGELOG prose. FIX: stamp logs with a run id; emitrun-<id>.json(stage, tag, attempts, seconds, exit, disk before/after); print the table at the end AND in afinallyon failure.
Open items (effort·impact; ordered by leverage)#
31 [S·★★, owner decision] Auto-push green stage images (or export- cache) once a chain goes green — driver params exist; needs the registry choice + a
docker login. Until then a host loss costs every stage.0b human half [policy] versions.env bumps ride the Windows lane: one local full-chain build before trust. (CI half shipped: the
patch-driftjob re-verifies every .patch against its pins on each trigger.)~~35 [observe·★] Transient ~120-min ffmpeg stall~~ — CLOSED 2026-08-14, superseded by #76. The log forensics measured it exactly (7200.9 s ≈ a 2-hour timeout, not jitter), located it in the MSYS2/gawk provisioning call, and confirmed it has not recurred in 9 subsequent runs. It is now an actionable item (bound the step with a timeout), not an observation.
Pending host/upstream actions (not refactors — do not let these evaporate)#
The elevated between-runs window (buildkitd step-log env restore, GC-budget deploy = #34, poisoned probe-chain prune, diagnostic tag cleanup) and the dufs SYSTEM-service migration were APPLIED by the owner 2026-08-13 — see the archive addendum. Sanity-check the GC deploy with
buildctl debug workers -v(reservedSpace must read 200GB).
Post the upstream issues — POSTED 2026-08-13: mozilla/sccache → https://github.com/mozilla/sccache/issues/2808 (nvcc deadlock + miscompile), google-ai-edge/LiteRT-LM → https://github.com/google-ai-edge/LiteRT-LM/issues/3245 (CMake-lane staleness, four findings). STILL TO POST: opencv/opencv (out/upstream-issue-opencv-ort-wchar.md — dnn/ORT
char*vswchar_t).Post-run diagnostics queue — PROMOTED 2026-08-14, this is now the #1 investigation. The log forensics upgraded the sccache picture from “1498 write errors in one witness” to 0 cache hits and 189,861 failed writes across 94 stat blocks spanning the entire corpus (see #71) — sccache has never worked, on any run. Read errors are 0, so this is write-side only. Steps, in order: (1) capture
SCCACHE_ERROR_LOGfrom the cache mount — it is confirmed absent from ALL 102 logs (it lives inside the mount and was never tee’d out), so it is the one decisive artifact still missing; (2) the exact-TU replay (bias_softmax_impl.cu) for the miscompile mechanism; (3) oneprobe-build-copy.ps1 -Heavysmoke after the poisoned-chain prune. VERIFIED 2026-08-14: the buildkitd service env now really does carryBUILDKIT_STEP_LOG_MAX_SIZE=-1+..._MAX_SPEED=-1(checked at the service registry key, and today’s base build no longer emits the clip warning) — so the next full chain will be the FIRST fully-captured one. The 49-run corpus analysed above predates this: 28 of those logs contain real clip events, the green reference run is 49 % blind in its merge step, and historical ONNX steps are 83 % blind. Re-run the forensics against a full captured chain.