fix(registry): end a proxied response on half-close, not 50ms of silence - #24
kmannislands wants to merge 8 commits into
Conversation
|
| Branch | Total Count |
|---|---|
| main | 217 |
| This PR | 219 |
| Difference | +2 (0.92%) |
📁 Changes by file type:
| File Type | Change |
|---|---|
| Go files (.go) | ❌ +2 |
| Documentation (.md) | ➖ No change |
| Earthfiles | ➖ No change |
Keep up the great work migrating from Earthly to Earthbuild! 🚀
💡 Tips for finding more occurrences
Run locally to see detailed breakdown:
./.github/scripts/count-earthly.shNote that the goal is not to reach 0.
There is anticipated to be at least some occurences of earthly in the source code due to backwards compatibility with config files and language constructs.
70dfd3f to
f1ff85f
Compare
f1ff85f to
c71acaa
Compare
The registry proxy tunnels one HTTP conversation between a local docker
daemon and buildkitd's embedded registry over a gRPC stream, and it had
no tests. These stand a real net/http server in for the embedded
registry, drive it with a real net/http client, and put the production
Server.Proxy between the two, so the bytes are framed by net/http at
both ends and the only question asked is whether they arrive intact.
The client half of the tunnel is written out in the test rather than
borrowed from earthly's regproxy, so the server is exercised against an
independently correct peer: one that ends each direction on half-close
and never on a timer.
Three of the four fail against this commit, each reproducing a symptom
seen in CI:
- a response the registry pauses 300ms mid-body arrives truncated at
exactly half its Content-Length, which is the
"httpReadSeeker: failed open: ... EOF" behind the flaky
"pull ping error" in +test-no-qemu-slow
- a second request on a kept-alive connection is never answered at
all -- it hangs until the client's timeout rather than failing
- serving any response races on an error variable shared between the
two copy directions, reported by -race, which is the suite the
flake shows up in
The fourth, a 256KiB request body arriving byte-exact, passes today and
is here to hold that ground through the change that follows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CopyWithDeadline inferred the end of an HTTP response from the socket
going quiet for 50ms: it set a read deadline before every read and, on
the timeout, declared the response complete and returned. Proxy then
returned too, closing both the connection to the embedded registry and
the gRPC stream.
A registry that pauses longer than that mid-body is not a registry that
has finished. On a loaded runner -- the -race integration suite on a
27-layer image, say -- it pauses for longer routinely, and the client
then gets a truncated body against a Content-Length that promised more,
which docker reports as a bare
httpReadSeeker: failed open: failed to do request: Get
"https://127.0.0.1:PORT/v2/.../blobs/sha256:...": EOF
with no status to explain it. The same 50ms decided the fate of the
whole connection, so a kept-alive connection was torn down after its
first response and any later request on it was never answered.
A byte tunnel does not need to know where a response ends. Copy ends
each direction when its source ends it -- io.EOF from the socket, or a
peer that closed its send direction -- and passes that on as a
half-close, so the far end can finish what it still owes. This is the
shape session/sshforward.Copy has used to carry forwarded agent sockets
for years, and it is what kubectl port-forward does with the same
problem: SPDY streams per connection, terminated by close, with no idle
heuristic anywhere.
Two further defects go with it, both of which the tests caught:
- Proxy's two copy goroutines assigned to one captured err, a data
race reported under -race, which is the suite this flake appears in
- errgroup.WithContext's derived context was discarded, so neither
direction failing cancelled the other; a finished response
direction left the request direction blocked in Recv and Proxy
never returned, which is why an unanswered keep-alive request hung
rather than failed
The proxy now holds the stream and the loopback connection for as long
as the client holds its connection, rather than dropping them 50ms
after the first response. That is the same lifetime port-forward gives
a forwarded connection, and teardown still arrives with the session
context.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copy moves bytes between the connection and the stream directly, so
there is no longer an io.Reader/io.Writer adapter in the path. Nothing
else in the fork used StreamRW; earthly's regproxy did, and moves to
Copy in the change that bumps its pin.
Worth recording why its removal is a relief rather than a loss, because
the type was one buffer-size change away from corrupting pulled images.
Read did this:
l := 0
if len(s.last) > 0 {
l = copy(p, s.last) // stash the leftovers into p
}
msg, err := s.stream.Recv()
...
s.last = msg.GetData()
n := copy(p, s.last) // and overwrite them
s.last = s.last[n:]
return n + l // counting both
Three contract violations in one function: the second copy overwrites
the leftovers the first just wrote and they are never re-stashed; the
returned count is the sum of two copies into the same region, so it can
exceed len(p), which makes an io.Copy caller slice past its buffer's
capacity and panic; and leftovers are silently dropped when Recv
returns an error, including io.EOF at the clean end of a stream.
It was latent because a leftover only appears when a message is larger
than the reader's buffer, and both pumps read 32KiB while both senders
sent at most 32KiB. Any change to either buffer, or a peer that chunked
differently, would have corrupted image bytes rather than failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c71acaa to
b78c6a7
Compare
The number was a bare literal with no stated reason, which invites the question of why not 16KiB or 64KiB. It is io.Copy's own default and what session/sshforward.Copy uses for the same job, and it sits well under gRPC's 4MiB default maximum message size. Worth recording alongside that: nothing depends on the two ends of the tunnel agreeing on the value. That they happened to agree is precisely what kept StreamRW's broken leftover handling from ever being reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule directed authors to errors.Errorf from github.com/pkg/errors, which is archived, and which moby is migrating away from. It also has a sharper problem: errors.Errorf is Sprintf plus a stack, so it does not honour %w -- under this rule there was no way to wrap an error except errors.Wrap, and no way to reach the standard library's idiom at all. The logrus rule stays; only the fmt.Errorf entry goes. Nothing in the tree needs fixing for this: the rule forbade something, it did not require it. The five existing fmt.Errorf calls in non-generated code are all in _windows.go files, which lint never compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ation github.com/pkg/errors is archived and moby is migrating off it, so new code in this package should not add to the pile. Wrapping moves to fmt.Errorf with %w; errors.Is and errors.New come from the standard library unchanged. The messages lose their "failed to" prefixes. They compose -- a wrapped chain reads "failed to X: failed to Y: failed to Z" -- and the reader already knows an error is a failure. What they do not know is what the program was attempting, so that is what the message now says. One conversion is not mechanical: errors.Wrap returns nil for a nil cause, and fmt.Errorf does not. The closeStream call relied on that, and now checks the error itself rather than wrapping unconditionally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
goimports groups standard-library imports separately from third-party ones, and swapping github.com/pkg/errors for errors in place left the latter sitting in the third-party block. gofmt does not regroup imports, so only goimports catches it -- which is what CI runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per review. The io.Reader contract's substance is in the EOF case -- a final read may hand back bytes alongside io.EOF, and dropping those truncates the body -- so that case still forwards what it got. A hard read error is different: it is terminal for a TCP connection, the copy returns, the stream errors out, and the bytes in the buffer belong to a response that will never be completed. Forwarding them buys nothing, so the error case no longer does. The switch also reads better than a send wedged in front of an error check, which is what prompted the comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Protobuf packages are named by the .proto, so revive's var-naming rule fires on every file that declares one, and fourteen files carried a //nolint:revive to silence it. copy.go in #24 had to grow one too, which is what prompted this. Upstream already solved it in config -- origin/master's exclude list is ours plus a var-naming entry -- and converted the remaining directives to //nolint:staticcheck, which is a newer staticcheck absorbing stylecheck's ST1003. Our pinned golangci-lint 1.55 keeps stylecheck separate and does not enable it, so nothing replaces the directives here. Removing them is not optional once the exclusion lands: nolintlint is enabled, and an unused directive is itself a lint failure. The set is the one upstream converted -- package clauses, _LOOP_CTL_GET_FREE, and IsSolve_Subject -- verified by running revive 1.3.2, the version 1.55 bundles, with var-naming alone. dockerfile_test.go keeps its directive: that one is context-as-argument, a different rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@coderabbitai review pls |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '== Changed files =='
git diff --name-status HEAD^ HEAD
echo '== Diff stat =='
git diff --stat HEAD^ HEAD
echo '== Diff =='
git diff --find-renames --find-copies --unified=80 HEAD^ HEAD
echo '== Candidate source maps =='
fd -t f . | rg '(^|/)(registry|copy|stream|socketforward|sshforward).*\.go$' | sort | while IFS= read -r f; do
echo "--- $f"
ast-grep outline "$f" --items all --type function,method,struct,interface 2>/dev/null || true
done
echo '== Related symbol references =='
rg -n -C 5 '\b(CopyWithDeadline|StreamRW|registry\.Copy|Copy\s*\(|Proxy\s*\()' --glob '*.go' . || true
echo '== Module and Go version =='
if [ -f go.mod ]; then sed -n '1,80p' go.mod; fiLength of output: 201251 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '== Changed paths =='
git diff --name-status HEAD^ HEAD
echo '== Diff name/status with numstat =='
git diff --numstat HEAD^ HEAD
echo '== Matching non-vendor paths =='
fd -t f -E vendor . | rg '(^|/)(registry|socketforward|sshforward)(/|$)|(^|/)(registry|copy|stream).*\.go$' | sort
echo '== Registry-related changed diff =='
git diff --find-renames --unified=100 HEAD^ HEAD -- 'session/registry/**' ':!vendor/**'
echo '== Current registry source and tests =='
for f in $(fd -t f -E vendor . session | rg '^session/registry/.*\.go$' | sort); do
echo "--- $f"
cat -n "$f"
done
echo '== Parent registry source =='
for f in $(git diff --name-only HEAD^ HEAD -- 'session/registry/*.go'); do
echo "--- $f (HEAD^)"
git show "HEAD^:$f" | cat -n || true
done
echo '== Analogous forwarding copies =='
for f in session/sshforward/copy.go session/socketforward/copy.go; do
if [ -f "$f" ]; then
echo "--- $f"
cat -n "$f"
fi
done
echo '== Non-vendor references to removed or changed API =='
rg -n -C 4 --glob '*.go' -g '!vendor/**' '\b(CopyWithDeadline|StreamRW)\b|\bregistry\.Copy\s*\(' . || trueLength of output: 7523 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '== Exact diff =='
git diff --unified=120 HEAD^ HEAD -- api/services/registry/copy.go
echo '== Current copy implementation =='
cat -n api/services/registry/copy.go
echo '== Parent copy implementation =='
git show HEAD^:api/services/registry/copy.go | cat -n
echo '== Registry server structure =='
ast-grep outline api/services/registry/server.go --items all --type function,method,struct,interface
echo '== Proxy and stream call context =='
rg -n -C 60 --glob '*.go' -g '!vendor/**' '\bfunc \(.*\) Proxy\s*\(|\bCopy\s*\(' api/services/registry
echo '== Protocol stream definitions =='
rg -n -C 20 --glob '*.{proto,go}' -g '!vendor/**' 'service .*Registry|rpc .*Proxy|type .*Proxy.*Server|type .*Proxy.*Client|CloseSend' api/services/registry
echo '== Registry tests =='
cat -n api/services/registry/proxy_test.go
echo '== All package-local API references, including tests =='
rg -n -C 8 --glob '*.go' -g '!vendor/**' '\b(Copy|CopyWithDeadline|StreamRW|Proxy)\b' api/services/registryLength of output: 76755 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '== Pull request metadata =='
gh pr view 24 --repo EarthBuild/buildkit --json number,baseRefName,baseRefOid,headRefName,headRefOid,commits,files --jq '{number,baseRefName,baseRefOid,headRefName,headRefOid, commits:[.commits[]|{oid,messageHeadline}], files:[.files[]|{path,additions,deletions}]}'
echo '== Local refs and merge base =='
git branch -a --contains HEAD || true
git show -s --format='HEAD %H%nparents %P%nsubject %s' HEAD
git merge-base HEAD origin/main 2>/dev/null || true
git log --oneline --decorate -12 HEAD
echo '== Full PR diff from GitHub =='
gh pr diff 24 --repo EarthBuild/buildkit --color=never
echo '== io.Reader contract =='
go env GOROOT
grep -n -A35 -B5 'type Reader interface' "$(go env GOROOT)/src/io/io.go"Length of output: 25760
Send The regression tests cover You are interacting with an AI system. |
The session-registry proxy decided that an HTTP response had ended when its socket went quiet for 50ms.
docker pullfrom the embedded registry therefore fails intermittently with a bareEOFand no status, which is the server half of EarthBuild/earthbuild#912.What was wrong
CopyWithDeadlineset a 50ms read deadline before every read and, on the timeout, declared the response complete and returned;Proxythen closed both the loopback connection and the gRPC stream. A registry that pauses longer than 50ms mid-body has not finished, and on a loaded runner — the-racesuite pulling a 27-layer image — it pauses for longer routinely. The client gets a body shorter than theContent-Lengthit was promised, and reports the only thing it can:EOF.Because that one timer governed the whole connection, keep-alive was broken too: the tunnel was dismantled after its first response, so a second request on the same connection was never answered. It hung rather than failed, because
Proxyalso discardederrgroup.WithContext's derived context, leaving the request direction blocked inRecvforever. And both copy directions assigned to a single capturederr, a data race — under-race, which is the suite this flake shows up in.The fix
A byte tunnel does not need to know where a response ends, and this one now doesn't ask.
Copyends each direction when its source ends it —io.EOFfrom the socket, or a peer that closed its send direction — and passes that end on as a half-close, so the far side can finish what it still owes.That is not a new design here: it is the shape
session/sshforward/copy.gohas used to carry forwarded agent sockets for years, in this repository, andsession/socketforwardalongside it. The same problem outside this repo is solved the same way —kubectl port-forwardopens a stream per accepted connection and terminates it by close (client-go/tools/portforward/portforward.go,handleConnection); there is no idle heuristic anywhere in it. Nothing in the registry ecosystem hand-parses HTTP framing to proxy it, so neither do we:net/httpframes the bytes at both ends, and the tunnel stays opaque, which keeps it indifferent to ranges, chunked pushes, and the TLS probe docker sends before falling back to HTTP on a loopback registry.StreamRWgoes with it, and its removal is a relief rather than a loss. ItsReadcopied leftover bytes into the caller's buffer, then overwrote them with a freshRecvand returned the sum of both copies — a count that can exceedlen(p), which makes anio.Copycaller slice past its buffer's capacity and panic. Leftovers were dropped on error, including at a clean end of stream. It was latent only because a leftover appears only when a message exceeds the reader's buffer, and both pumps read 32KiB while both senders sent at most 32KiB.Tests
The package had none. The first commit adds them and fails: a real
net/httpserver stands in for the embedded registry, a realnet/httpclient drives it, the productionProxysits between the two, and the client half of the tunnel is written out in the test rather than borrowed from earthly'sregproxy, so the server is exercised against an independently correct peer.-raceerrOne test earned its keep by first being wrong: without a deliberate gap between the two requests, both fit inside a single 50ms idle window and it passed against the broken code.
Behaviour worth knowing about
The proxy now holds the stream and the loopback connection for as long as the client holds its connection, instead of dropping them 50ms after the first response. That is the same lifetime
port-forwardgives a forwarded connection, and teardown still arrives with the session context.Lockstep
CopyWithDeadlineandStreamRWare gone, and earthly'sregproxycalled both, so this needs a companion earthly PR that movesregproxy.handleontoregistry.Copyand bumps the pin. That change is written and verified locally against this branch —regproxybuilds and its tests pass — and I'll raise it once this merges and there is a commit to pin. EarthBuild/earthbuild#884 (client-side pull retry) is complementary and worth keeping: it covers daemons that predate this fix, and transport failures this one doesn't address.🤖 Generated with Claude Code