feat(proxies): cargo/crates pull-through cache, and fix the Go module proxy - #147
Merged
Conversation
… proxy The Go module proxy has never once served a request. It binds to the CNI bridge gateway (10.88.0.1), but that address does not exist when the daemon starts: the bridge is created lazily by CNI on the first job container, and both cleanup() and cleanStaleBridge() delete it outright. So Listen fails with EADDRNOTAVAIL on every boot, the error is logged as a warning and swallowed, and GOPROXY is never injected into any container. Start() runs MkdirAll before the failing Listen, which is why cache/gomod exists on every node, empty, at exactly 4 KB. Three further defects would have kept it useless even with the bind fixed: EnvVars() advertised the bound address rather than the configured one; the ",direct" fallback only applies on 404/410, so a wedged proxy hard-failed builds instead of failing open; and the cleanup knob was dead code that wiped the cache on every shutdown regardless of configuration. - proxies: add Listen() with a wildcard fallback, so the gateway address becomes reachable as soon as CNI brings the bridge up. A real error (port in use) is still returned rather than masked. - go: advertise the configured address; use "|direct" so any proxy error falls through to upstream; honour cleanup=false. - cargo: new pull-through cache for the sparse index, .crate tarballs and rustup dist. The index is revalidated on a TTL with conditional GETs; tarballs are immutable and cached permanently. config.json is rewritten so dl points at the proxy while api still reaches the real registry. Fails open in three stages: not started, serve-stale, then a redirect to the origin. - runtime: support bind-mounting proxy-generated files into job containers. Cargo ignores CARGO_SOURCE_* and CARGO_REGISTRIES_* environment variables for source replacement — verified empirically, not assumed. Only a config file works, so the proxy generates .cargo/config.toml and mounts it read-only at the container root, relying on Cargo's ancestor-directory config search. That applies to any checkout path, leaves CARGO_HOME writable, and lets a repository's own config still win. No workflow changes are required. [cargo_proxy].cleanup defaults to false: wiping a pull-through cache on every restart is the bug diagnosed above. Also adds docs/arch/sccache-evaluation.md, which recommends a local-disk sccache scoped to cargo build (not docker build, to keep it disjoint from the BuildKit layer cache) with a hard size cap, sequenced after the disk-pressure GC lands.
luthermonson
marked this pull request as ready for review
August 12, 2026 00:52
Every request in cargoproxy_test.go goes through a get() helper that reads the body and closes it in a defer. bodyclose tracks the *http.Response the helper returns rather than what the helper does with it, so it reported all 30-odd call sites. The bodies are closed; restructuring the helper to avoid returning a response would churn 60 call sites to satisfy a false positive. Scoped to the test files only, matching the existing localtunnel exclusion.
luthermonson
added a commit
that referenced
this pull request
Aug 16, 2026
#147) # Conflicts: # pkg/config/config.go # pkg/runtime/runtime.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Go module proxy has never served a request
It binds to the CNI bridge gateway (
10.88.0.1:8082), but that address does not exist when the daemon starts. The bridge is created lazily by CNI on the first job container, and bothcleanup()andcleanStaleBridge()delete it outright (pkg/networking/network_linux.go). SoListenfails withEADDRNOTAVAILon every boot, the error is logged as a warning and swallowed, andGOPROXYis never injected into any container.Start()runsMkdirAllbefore the failingListen, which is exactly whycache/gomodexists on every node, empty, at 4 KB. That empty directory was the clue.Three further defects would have kept it useless even with the bind fixed:
EnvVars()advertised the bound address rather than the configured one — meaningless inside a container after any fallback.GOPROXY=…,directis not fail-open: the comma separator only falls through on 404/410, so a wedged proxy hard-failed builds. Now|direct.cleanupwas dead code (if !cleanup { cleanup = true }) — the cache was wiped on every shutdown regardless of configuration.Re-checked against current
mainafter the L2Bridge work landed:Manager.GatewayIP()now returns the host adapter address on the Windows L2Bridge path, where a direct bind succeeds and the fallback never fires. On Linux the CNI bridge is still created with the first job container, so the wildcard fallback inproxies.Listenis still what makes the proxy work at all. The fix holds.Changes
pkg/proxies/listen.go(new) —Listen()with a wildcard fallback, so the gateway address becomes reachable as soon as CNI brings the bridge up. A genuine error (port in use) is still returned rather than masked.pkg/proxies/server.go(new) — the HTTP server lifecycle shared by every cache proxy. See "Shutdown" below.pkg/proxies/go— advertise the configured address;|directso any proxy error falls through to upstream; honourcleanup = false; use the shared server.pkg/proxies/cargo(new) — pull-through cache for the sparse index,.cratetarballs and rustup dist. The index is revalidated on a TTL with conditional GETs (If-None-Match/If-Modified-Since); tarballs are immutable and cached permanently.config.jsonis rewritten sodlpoints at the proxy whileapistill reaches the real registry, socargo publish/searchkeep working. Concurrent misses are collapsed by a per-path mutex.pkg/runtime— support bind-mounting proxy-generated files into job containers.Why a mounted config file rather than environment variables
Cargo ignores
CARGO_SOURCE_*andCARGO_REGISTRIES_*for source replacement. This was verified empirically against a dead endpoint rather than assumed: with the env vars set, cargo reported "Updating crates.io index" and went straight to the real registry; the same settings in a config file produced "Updatingephemerdindex" and the expected connection error.So the proxy generates a
.cargo/config.tomland mounts it read-only at the container root, relying on Cargo's ancestor-directory config search. That applies to any checkout path, needs no knowledge of the workdir or the image'sCARGO_HOME(left writable), and a repository's own.cargo/config.tomlstill wins. No workflow changes required. rustup takesRUSTUP_DIST_SERVER, where an env var does work.Had this been built on the env-var assumption, the proxy would have been silently bypassed — the same class of failure as the Go proxy above.
The mount destination is
/.cargoon Linux andC:\.cargoon Windows, both of which are the filesystem root Cargo's ancestor search terminates at. Linux jobs on Windows and macOS hosts run inside the Linux VM sidecar, which runs its own ephemerd and generates its own config, so the host GOOS is the right default for the container OS.Shutdown: the test hang
TestCrate_ConcurrentMissesFetchUpstreamOncefailed intermittently in CI, always at exactly 5.01s, with:Instrumenting
Stopwith aConnStatehook and a full goroutine dump caught it: no handler was running, and exactly one connection was stuck inConnState"new".That is Go's
http.Transportdialing speculatively. Under a burst of parallel requests it opens more connections than it ends up using; the spares land in the client's idle pool having never sent a byte. Server-side they are"new"and stay that way.http.Server.Shutdownrefuses to close a"new"connection until it has been in that state for over five seconds (net/http's issue-22682 heuristic), soShutdownpolls for the full five seconds and the caller's five-second context expires first. Nothing leaked and nothing hung forever — the shutdown simply could not succeed. Both proxies had the sameStop, so both were affected.pkg/proxies/server.gonow owns the server lifecycle for every cache proxy:BaseContextties every request context to the proxy. This fixes the failure the net/http heuristic exists for: when the grace window does close on a genuinely stuck handler, cancelling the proxy context unblocks its in-flight upstream fetch before the connections are forced shut. Without it,Shutdownneither cancels request contexts nor gives up on its own.Stop.Upstream fetches now run under the proxy's context rather than the requesting job's. A fetch fills a shared cache that other requests for the same path are queued behind, so one cargo hanging up — or its container being killed — must not abort the download everybody else is waiting for. It stays bounded by the client timeout, and
Stopcan still cancel it.The regression tests are deterministic rather than hopeful: the unread connection is dialed directly instead of hoping the transport races the right way (
TestShutdown_NotDelayedByUnreadConnections), andTestStop_CancelsInFlightUpstreamFetchpins that a request wedged on a dead upstream costs the grace window, not the 120 s client timeout.Fail-open: Cargo has no
|directThis was the design blocker, and it is the reason the proxy is shaped the way it is.
GOPROXY="<url>|direct"falls through to the origin on any proxy error, so the Go proxy can only ever slow a build down. Cargo has no equivalent. Once[source.crates-io] replace-withpoints at the proxy there is no second source, no fallback and no retry-elsewhere — a dead proxy is a red build for every Rust job on the node. A cache must never be a hard dependency of the job path.Three layers, cheapest first:
1. Not started → not injected
A proxy that fails to
Startis never added to the cache-proxy list, so neither the mount nor the env var reaches any container. Jobs behave as if ephemerd had no cache. Covers boot-time failures (port in use, unwritable cache dir).2. Running → always answers
No route returns 5xx. Previously the index and
config.jsonroutes returned502when upstream was unreachable with nothing cached, on the reasoning that "cargo treats this as fatal either way" — which was only true because we had told cargo to replace crates-io with us. They now307to the real origin, like the crate and rustup routes already did.config.jsonmatters most: it is the first thing Cargo asks for, and redirecting it hands the job the genuine document whosedlpoints at the crates.io CDN, so the rest of that build simply bypasses the proxy.So "the proxy accepts connections" is the only thing a build depends on. Upstream outages, an unreadable cache, a full disk and an unparseable
config.jsonall degrade to an uncached direct fetch. Genuine 404s still pass through as 404s — "no such crate" is not an outage. Malformed request paths are still refused with400rather than turned into a redirect built out of attacker-supplied bytes; real Cargo never emits them.3. Running but wedged → withdraws itself
Layer 2 assumes requests still reach a handler. A listener can wedge while the daemon lives on — that has happened on this fleet, in the self-upgrade path — and layer 2 cannot help, because the job never reaches a handler.
So a watchdog probes the proxy's own listener over real TCP every 15 s and, after two consecutive failures, rewrites the mounted
config.tomlto an inert one with no[source]table at all. A directory is bind-mounted, so containers already running see the swap: their nextcargoinvocation goes straight to crates.io. It is restored automatically once probes succeed again. Both transitions are logged (cargo cache withdrawn from jobs/cargo cache re-enabled for jobs).The probe deliberately targets the bound address, not the advertised one. On Linux the bridge gateway does not exist until CNI creates it with the first job container, so probing the advertised address would report every freshly booted daemon as dead — precisely when it is fine. A wildcard binding is probed on loopback.
This subsumes the "health-gate at injection time" option: the gate lives in the one place that needs no cross-package plumbing, and unlike an injection-time check it also covers a proxy that dies after the job started.
Residual failure mode
Stated honestly, in the package comment and in
docs/getting-started/configuration.md:needsHostAccessneedsHostAccess()gainedcfg.CargoProxy.Enabled. Main's L2Bridge ACL ladder blocks the host from containers unless something ephemerd serves needs reaching, and it only knew about dind and the Go proxy. On a Windows L2Bridge node with only the cargo proxy enabled, every Rust job would have failed on an unreachable proxy with no fallback.Config
[cargo_proxy]:enabled(false),port(8083),upstream(https://index.crates.io),rustup_upstream(https://static.rust-lang.org),index_ttl(10m),cleanup(false).cleanupdefaults false, deliberately unlike[module_proxy]— wiping a pull-through cache on every restart is the bug diagnosed above. Registered inmanagedCaches()asLiveSafe: true; the generated container config lives outside the cache root socache clear cargocannot pull a mounted file out from under a running job.Merge surface
Rebased onto current
main(L2Bridge, native-macOS removal, dind host-port scoping) — merged clean, no conflicts. Two other proxies (npm/pip/pub) and a registry mirror are in flight on other branches and touch the same two files, so the footprint there is deliberately tiny:cmd/ephemerd/main.go: the[cargo_proxy]start block, the port added togatewayPorts,cacheProxyMountscollected alongsidecacheProxyEnvVars, and one term added toneedsHostAccess.pkg/config/config.go: oneCargoProxyConfigstruct plus itsCleanupEnabled(), andModuleProxyConfig.Cleanupchanged to*bool(which is what fixes the deadcleanupknob).Everything else lives in
pkg/proxies/.Testing
go build ./...,GOOS=linux go build ./...andGOOS=windows go build ./...clean;go vet ./...clean;golangci-lint runreports 0 issues.go test ./pkg/proxies/... -racepasses, including 15 consecutive runs of the previously flaky package — the shutdown regression reproduced in roughly one run in three before the fix and zero times after. ~60 tests against anhttptestfake upstream, no network access.Not yet run on a node. The Go-proxy fix in particular is worth confirming live, since the symptom (an empty 4 KB directory) is easy to mistake for "working but unused".
Follow-ups found, not fixed
Manager.GatewayIP()hardcodes10.88.0.1whenSubnetis empty, whileConfig.subnet()→pickSubnet()may auto-select a different10.x/16on conflict. On such a host the two disagree.GatewayPortsACCEPT rules land inEPHEMERD-FORWARD, jumped fromFORWARD, but container→gateway traffic hitsINPUT— so those rules are currently no-ops. Harmless today (reachability comes from the default INPUT policy), but the carve-out is not doing what it appears to.sccache
docs/arch/sccache-evaluation.md. Recommends building it, sequenced after the disk-pressure GC, with a hard size cap wired into that GC from day one. Local-disk backend, partitioned per repo, and scoped tocargo buildrather thandocker buildso it stays disjoint from the BuildKit layer cache instead of storing the same output twice.