Skip to content

fix(registry): end a proxied response on half-close, not 50ms of silence - #24

Open
kmannislands wants to merge 8 commits into
mainfrom
giles-registry-proxy-framing
Open

kmannislands wants to merge 8 commits into
mainfrom
giles-registry-proxy-framing

Conversation

@kmannislands

Copy link
Copy Markdown

The session-registry proxy decided that an HTTP response had ended when its socket went quiet for 50ms. docker pull from the embedded registry therefore fails intermittently with a bare EOF and no status, which is the server half of EarthBuild/earthbuild#912.

failed to copy: httpReadSeeker: failed open: failed to do request: Get
  "https://127.0.0.1:PORT/v2/sess-ID/pullping/blobs/sha256:...": EOF

What was wrong

CopyWithDeadline set a 50ms read deadline before every read and, on the timeout, declared the response complete and returned; Proxy then 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 -race suite pulling a 27-layer image — it pauses for longer routinely. The client gets a body shorter than the Content-Length it 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 Proxy also discarded errgroup.WithContext's derived context, leaving the request direction blocked in Recv forever. And both copy directions assigned to a single captured err, 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. 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 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.go has used to carry forwarded agent sockets for years, in this repository, and session/socketforward alongside it. The same problem outside this repo is solved the same way — kubectl port-forward opens 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/http frames 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.

StreamRW goes with it, and its removal is a relief rather than a loss. Its Read copied leftover bytes into the caller's buffer, then overwrote them with a fresh Recv and returned the sum of both copies — a count that can exceed len(p), which makes an io.Copy caller 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/http server stands in for the embedded registry, a real net/http client drives it, the production Proxy sits between the two, and 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.

before after
a response the registry pauses 300ms mid-body truncated at 65536 of 131072 bytes intact
a second request on a kept-alive connection never answered; hangs until the client's timeout answered on the same connection
serving any response, under -race data race on a shared err clean
a 256KiB request body byte-exact byte-exact

One 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-forward gives a forwarded connection, and teardown still arrives with the session context.

Lockstep

CopyWithDeadline and StreamRW are gone, and earthly's regproxy called both, so this needs a companion earthly PR that moves regproxy.handle onto registry.Copy and bumps the pin. That change is written and verified locally against this branch — regproxy builds 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

@github-actions

Copy link
Copy Markdown

⚠️ Are we earthbuild yet?

Warning: "earthly" occurrences have increased by 2 (0.92%)

📈 Overall Progress

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.sh

Note 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.

@kmannislands kmannislands self-assigned this Sep 10, 2026
@kmannislands
kmannislands marked this pull request as draft September 10, 2026 19:37
@kmannislands
kmannislands force-pushed the giles-registry-proxy-framing branch from 70dfd3f to f1ff85f Compare September 10, 2026 20:03
@kmannislands
kmannislands changed the base branch from main to fix-ci-action-pinning September 10, 2026 20:03
@kmannislands
kmannislands added this pull request to stack #26 September 10, 2026 20:04
@kmannislands
kmannislands marked this pull request as ready for review September 10, 2026 20:31
@kmannislands
kmannislands force-pushed the giles-registry-proxy-framing branch from f1ff85f to c71acaa Compare September 11, 2026 00:17
Base automatically changed from fix-ci-action-pinning to main September 11, 2026 00:21
kmannislands and others added 3 commits September 10, 2026 17:21
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>
@kmannislands
kmannislands force-pushed the giles-registry-proxy-framing branch from c71acaa to b78c6a7 Compare September 11, 2026 00:21
Comment thread api/services/registry/copy.go Outdated
Comment thread api/services/registry/copy.go Outdated
Comment thread api/services/registry/copy.go Outdated
Comment thread api/services/registry/copy.go
Comment thread api/services/registry/copy.go Outdated
kmannislands and others added 3 commits September 11, 2026 08:59
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>
kmannislands and others added 2 commits September 11, 2026 10:18
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>
kmannislands added a commit that referenced this pull request Sep 11, 2026
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>
@kmannislands

Copy link
Copy Markdown
Author

@coderabbitai review pls

@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

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; fi

Length 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*\(' . || true

Length 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/registry

Length 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


@kmannislands: I found one correctness issue.

Copy discards bytes when conn.Read(buf) returns n > 0 with a non-io.EOF error. The io.Reader contract requires callers to process returned bytes before they process the error. A deadline or transport error can occur after valid bytes were read. Line 112 returns before send(n), so the peer can receive a truncated HTTP message.

Send buf[:n] when n > 0 before returning the read error. Keep the current io.EOF half-close logic after that send.

The regression tests cover n > 0 with io.EOF, but they do not cover n > 0 with another read error. Add a focused Copy unit test for that case.

You are interacting with an AI system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants