Repository navigation
fix(regproxy): stop the registry proxy without waiting on open connections - #1035
Draft
kmannislands wants to merge 1 commit into
Draft
kmannislands wants to merge 1 commit into
kmannislands wants to merge 1 commit into
Conversation
➖ Are we earthbuild yet?No change in "earthly" occurrences 📈 Overall Progress
Keep up the great work migrating from Earthly to Earthbuild! 🚀 💡 Tips for finding more occurrencesRun locally to see detailed breakdown: ./.github/scripts/count-earthly.shNote that the goal is not to reach 0. |
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…tions On Docker Desktop (macOS, Windows, WSL2) every build sat for about 90s after printing SUCCESS before `earth` exited, and any client holding a connection to the registry proxy open could keep it there for as long as that client chose. Since #914 a proxied connection lasts as long as its client keeps it, which is what a kept-alive docker pull needs. But stopping the proxy closed only its listener and then waited, on the build's context (never cancelled at that point), for every connection's handler to return. The proxy's own Docker Desktop readiness check holds one. It GETs /v2/ through the socat support container with http.DefaultClient and closes the body unread; since Go 1.27 the transport drains a small unread body on Close and pools the connection, so it sits in DefaultTransport's idle pool for its 90s IdleConnTimeout. That connection runs back through socat and host.docker.internal into the proxy, so the CLI was waiting on itself. The SIGQUIT dump from the hung run shows exactly this: the deferred closer in its select (controller.go:91), serve in wg.Wait, one handle in registry.Copy with one goroutine in stream.RecvMsg and the other in conn.Read, and an idle net/http persistConn in the same process. lsof on a stalled run shows the loop: earth -> 127.0.0.1:<socat port> held by com.docker.backend, and com.docker.backend -> earth's proxy listener. docker also keeps the connection it pulled a SAVE IMAGE image over. - The readiness check sets req.Close, so its connection is closed after the response instead of pooled. - Stopping the proxy gives open connections a 250ms grace, then closes them: connections are served under their own context, which stopping cancels, and handle closes its conn when that context is done, so shutdown never depends on a client letting go. By then every pull through the proxy has returned: the proxy is stopped after bkClient.Build returns, and the earthlyoutputs exporter does not finish until the client answers its pull-ping, which onPull does only after DockerPullLocalImages has returned for every image. What is left is idle. - The goroutine draining the proxy's errors closes its done channel rather than sending on an unbuffered one nobody reads once the closer has stopped waiting. - A failure to start the Darwin support container also stops the proxy. The support container is removed only after the proxy stops, so killing a stalled `earth` also leaked its earthly-darwin-proxy-* container; with the stop bounded, the removal always runs. registry.Copy in the buildkit fork behaves as designed: each direction ends when its source does. It is not the cause and this fix needs no change there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kmannislands
force-pushed
the
fix/regproxy-close-hang
branch
from
October 8, 2026 18:44
fd85e5c to
0fa2ecc
Compare
This branch has not been deployed
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.
Problem
On Docker Desktop (macOS, Windows, WSL2), every build waits about 90 seconds after printing
Earth Build ✅ SUCCESSbeforeearthexits. A client that holds a connection to the registry proxy open can delay exit for as long as it likes.This is a regression on
mainand has not shipped. Since #914, a proxied connection stays open for as long as its client keeps it, which is what a kept-alivedocker pullneeds. But stopping the proxy closed only its listener, then waited for every connection handler to return. It waited on the build's context, which is never cancelled at that point.The proxy holds one of those connections itself. On Docker Desktop,
startDarwinProxychecks readiness with aGET /v2/through the socat support container, usinghttp.DefaultClient, and closes the body unread. Since Go 1.27 (#883), the transport drains a small unread body onCloseand pools the connection. That connection sits idle inDefaultTransportfor its 90sIdleConnTimeout. It runs back through socat andhost.docker.internalinto the proxy, soearthwas waiting on itself. docker also keeps the connection it pulled aSAVE IMAGEimage over.flowchart LR subgraph host["macOS host"] probe["earth: readiness check<br/>GET /v2/ with http.DefaultClient"] proxy["earth: registry proxy<br/>127.0.0.1:P"] end subgraph vm["Docker Desktop VM"] socat["earthly-darwin-proxy<br/>socat container"] dockerd["docker daemon"] end bk["buildkitd<br/>embedded registry"] probe -- "127.0.0.1:S<br/>connection pooled, idle 90s" --> socat dockerd -- "docker pull of a SAVE IMAGE<br/>connection kept alive" --> socat socat -- "host.docker.internal:P" --> proxy proxy -- "one gRPC stream per connection" --> bkEvidence:
select(controller.go:91)serveis inwg.Waithandleis inregistry.Copy, with one goroutine instream.RecvMsgand another inconn.Readnet/httppersistConnis in the same process: the readiness check's connectionlsof: shows the loop:earth→127.0.0.1:<socat port>(held bycom.docker.backend), andcom.docker.backend→earth's proxy listener.main, three runs took 88.7s, 89.4s and 89.5s fromSUCCESSto exit. The verbose log shows "Starting Darwin proxy" and the container removal exactly 90.0s apart.earthleaked itsearthly-darwin-proxy-*container.Change
sequenceDiagram participant B as convertAndBuild participant S as proxy stop participant H as connection handlers participant C as clients (readiness check, docker) Note over B: bkClient.Build has returned, so every pull is done B->>S: deferred stop S->>S: close the listener alt Before S->>H: wait for every handler, on the build context (never cancelled) Note over H,C: A handler returns only when its client closes.<br/>The readiness check's idle connection: 90s. docker: as long as it likes else After S->>H: wait up to 250ms for handlers to return S->>H: cancel the connections' context H->>C: close the connection H-->>S: handlers return end S-->>B: stopped, earth exitsregproxy/controller.gowaitForRegistry) setsreq.Close, so its connection is closed after the response instead of pooled.defaultCloseGrace) to finish, then cancels that context.regproxy/proxy.go:handlecloses its connection when the context is done (context.AfterFunc), so shutdown never depends on a client letting go.The buildkit fork's
registry.Copybehaves as designed, and this needs no fork change.No changelog entry, because the stall never shipped.
Why closing after 250ms can't cut off a pull
The proxy is stopped by a
deferinconvertAndBuild, which runs afterbkClient.Buildreturns. The build can't return while a pull is in flight:earthlyoutputsexporter sends a pull-ping and waits for the client's reply ("wait for the client to finish pulling") before the export finishes.onPull, only afterDockerPullLocalImageshas returned for every image.So on a successful build, any connection still open at stop is idle, and the grace only lets one that is already ending finish cleanly. If the build fails or is cancelled, cutting off a pull still in flight is the intended outcome.
I checked this end to end. Each of 10 iterations builds two images in parallel, each with a new ~150MB random layer, and loads them through the proxy. After each
earthexit, a container verifies the sha256 of the layer inside each loaded image:SUCCESS→ exitearthly-darwin-proxy-*containersTests
TestProxyStopDoesNotWaitOnAConnectionTheClientKeepsOpenruns the real gRPC chain against buildkit'sServer.Proxy. It has two cases: an idle keep-alive, and an unfinished request.TestWaitForRegistryDoesNotKeepItsConnectiongo test -race -count=50 ./regproxy/...: pass.SUCCESSin about 70 runs, including:SAVE IMAGEbuildsearth +lint: 0 issues.Not in this PR
buildFuncstarts a proxy each time it runs. When the push phase runs a second build, the first proxy's stop function is overwritten, and its Darwin container is never removed. That only happens whenRunPush.HasStateis set, which the converter forbids with wait blocks. So it's limited to VERSION 0.6 Earthfiles, or builds with wait blocks disabled.registry.Copycould end the copy whenever its context is cancelled (context.AfterFunc(ctx, ...)). Today that only works because both callers pass the stream's own context.Prepared with AI assistance (Claude Code).
🤖 Generated with Claude Code