···2626// It is cleared once the (possibly long) transfer begins.
2727const handshakeTimeout = 30 * time.Second
28282929+// streamingStorer wraps a storage.Storer to hide its optional
3030+// storer.PackfileWriter capability. go-git's UpdateObjectStorage drains the
3131+// incoming pack into PackfileWriter via io.CopyBuffer, which only returns on
3232+// io.EOF — fine over HTTP (the request body has a natural EOF) but a deadlock
3333+// over git://, where the client holds the connection open waiting for the
3434+// server's report-status. With PackfileWriter hidden, UpdateObjectStorage
3535+// falls through to Parser.Parse, which knows the end of the pack from the
3636+// pack format itself and never waits for an EOF.
3737+//
3838+// Trade-off: Parser.Parse writes loose objects (one Rename → one S3 PUT each)
3939+// instead of one packfile, so large git:// pushes incur more S3 calls. HTTP
4040+// keeps the fast PackfileWriter path.
4141+type streamingStorer struct {
4242+ storage.Storer
4343+}
4444+2945// daemon serves the git:// (TCP) protocol out of a billy filesystem.
3046type daemon struct {
3147 fs billy.Filesystem
···119135 _, _ = pktline.WriteError(conn, fmt.Errorf("cannot open repository %q", req.Pathname))
120136 return fmt.Errorf("opening %q for push: %w", req.Pathname, err)
121137 }
122122- return transport.ReceivePack(ctx, st, r, conn, &transport.ReceivePackRequest{
138138+ return transport.ReceivePack(ctx, streamingStorer{Storer: st}, r, conn, &transport.ReceivePackRequest{
123139 GitProtocol: gitProtocol,
124140 })
125141
···33package s3fs
4455import (
66+ "bytes"
67 "context"
78 "errors"
89 "fmt"
···1011 "os"
1112 "path"
1213 "strings"
1414+ "time"
13151416 "github.com/aws/aws-sdk-go-v2/aws"
1517 "github.com/aws/aws-sdk-go-v2/service/s3"
···6668 return newS3DirFile(key, fs3.bucket, fs3.client), nil
6769 }
68707171+ // A TempFile that has not yet been renamed lives only in memory; serve
7272+ // reads from that buffer so go-git's PackWriter can read the pack back
7373+ // while it is still being written.
7474+ if buf, ok := fs3.lookupTemp(filename); ok {
7575+ return &tempReadFile{buf: buf, name: filename}, nil
7676+ }
7777+6978 f, err := newS3ReadFile(fs3.client, fs3.bucket, key, filename)
7079 if err == nil {
7180 return f, nil
···118127 return newDirInfo("/"), nil
119128 }
120129130130+ // A still-open TempFile lives only in memory; report its current size so
131131+ // callers that Stat the temp path before Rename see a consistent view.
132132+ if buf, ok := fs3.lookupTemp(filename); ok {
133133+ return newFileInfo(path.Base(filename), buf.size(), time.Now()), nil
134134+ }
135135+121136 ctx := context.TODO()
122137123138 head, err := fs3.client.HeadObject(ctx, &s3.HeadObjectInput{
···160175 return nil, &os.PathError{Op: "stat", Path: filename, Err: fs.ErrNotExist}
161176}
162177163163-// Rename renames (moves) oldpath to newpath. If newpath already exists and
164164-// is not a directory, Rename replaces it. OS-specific restrictions may
165165-// apply when oldpath and newpath are in different directories.
178178+// Rename renames (moves) oldpath to newpath. If oldpath refers to an
179179+// in-memory TempFile, its buffer is uploaded to S3 under newpath and the
180180+// registry entry is dropped — this is how PackWriter's "tmp_pack_… →
181181+// pack-<sha>.pack" promotion lands the final pack in the bucket. Otherwise
182182+// Rename uses Tigris's in-place RenameObject extension.
166183func (fs3 *S3FS) Rename(oldpath, newpath string) error {
167184 ctx := context.TODO() // TODO: Get user-supplied context?
168185169186 src := fs3.key(oldpath)
170187 dst := fs3.key(newpath)
171188189189+ if buf, ok := fs3.detachTemp(oldpath); ok {
190190+ data := buf.snapshot()
191191+ _, err := fs3.client.PutObject(ctx, &s3.PutObjectInput{
192192+ Bucket: &fs3.bucket,
193193+ Key: &dst,
194194+ Body: bytes.NewReader(data),
195195+ })
196196+ if err != nil {
197197+ return fmt.Errorf("failed to upload temp %q to %q: %w", oldpath, newpath, err)
198198+ }
199199+ return nil
200200+ }
201201+172202 // RenameObject is a Tigris extension that renames in place (no data copy),
173203 // so we don't need a separate CopyObject + DeleteObject. CopySource is
174204 // bucket-qualified; Key is the destination key.
···185215 return nil
186216}
187217188188-// Remove removes the named file or directory.
218218+// Remove removes the named file or directory. In-memory TempFile entries are
219219+// dropped from the registry without an S3 call.
189220func (fs3 *S3FS) Remove(filename string) error {
190190- // TODO: Validate the path?
191191- // ...
221221+ if _, ok := fs3.detachTemp(filename); ok {
222222+ return nil
223223+ }
192224193193- // Create a context
194225 ctx := context.TODO() // TODO: Get user-supplied context?
195226196227 key := fs3.key(filename)
···44 "fmt"
55 "path"
66 "strings"
77+ "sync"
7889 "github.com/go-git/go-billy/v6"
910 "github.com/tigrisdata/storage-go"
···1819 bucket string
1920 root string
2021 separator string
2222+2323+ // temps holds TempFile-backed buffers keyed by canonical S3 key, so a
2424+ // subsequent Open of the same path returns a reader over the same bytes
2525+ // the writer is still appending to. See tempfs.go.
2626+ tempMu sync.Mutex
2727+ temps map[string]*tempBuffer
2128}
22292330// NewS3FS creates a new S3FS Filesystem.
···3138 bucket: bucket,
3239 root: "",
3340 separator: DefaultSeparator,
4141+ temps: make(map[string]*tempBuffer),
3442 }, nil
3543}
3644
+10-9
internal/s3fs/tempfile.go
···1010 "github.com/go-git/go-billy/v6"
1111)
12121313-// TempFile creates a uniquely named, write-only file under dir whose name
1414-// begins with prefix. The object is uploaded to S3 when the returned file is
1515-// closed; until then nothing exists in the bucket. The caller is responsible
1616-// for renaming or removing it.
1717-//
1818-// Note: the returned file is write-only. S3 has no read-while-write temp file,
1919-// so callers that reopen the temp path for reading before Close (e.g. go-git's
2020-// streaming PackWriter) are not supported; use the loose-object path instead.
1313+// TempFile creates a uniquely named file under dir whose name begins with
1414+// prefix and returns a write handle to it. The bytes live in an in-memory
1515+// buffer registered against the filesystem; a subsequent Open of the same
1616+// path returns a reader over that same buffer (needed by go-git's streaming
1717+// PackWriter, which reads the temp pack back as it is written). The buffer
1818+// is uploaded to S3 only when the caller renames the path to its final
1919+// location; Remove discards it.
2120func (fs3 *S3FS) TempFile(dir, prefix string) (billy.File, error) {
2221 var b [16]byte
2322 if _, err := rand.Read(b[:]); err != nil {
···2524 }
26252726 name := fs3.Join(dir, prefix+hex.EncodeToString(b[:]))
2828- return newS3WriteFile(fs3.client, fs3.bucket, fs3.key(name), name)
2727+ buf := &tempBuffer{}
2828+ fs3.registerTemp(name, buf)
2929+ return &tempWriteFile{buf: buf, name: name}, nil
2930}
+213
internal/s3fs/tempfs.go
···11+// tempfs.go backs billy.TempFile with an in-memory buffer that supports
22+// read-while-write on the same path. go-git/v6's streaming PackWriter creates
33+// a temp pack file, immediately opens the same path for reading, and reads it
44+// back concurrently while writing to build the index. S3 cannot offer that on
55+// a single object, so until the final Rename uploads the bytes the buffer is
66+// the file.
77+88+package s3fs
99+1010+import (
1111+ "fmt"
1212+ "io"
1313+ "io/fs"
1414+ "os"
1515+ "sync"
1616+ "time"
1717+1818+ "github.com/go-git/go-billy/v6"
1919+)
2020+2121+// tempBuffer is a growable byte buffer that one writer and one reader can
2222+// access concurrently. It is the backing store for a single TempFile entry in
2323+// the S3FS temp registry.
2424+type tempBuffer struct {
2525+ mu sync.Mutex
2626+ data []byte
2727+}
2828+2929+func (b *tempBuffer) write(p []byte) (int, error) {
3030+ b.mu.Lock()
3131+ b.data = append(b.data, p...)
3232+ b.mu.Unlock()
3333+ return len(p), nil
3434+}
3535+3636+// readAt copies bytes starting at off. It returns (0, io.EOF) when off is at
3737+// or past the current end so callers (most notably go-git's syncedReader) can
3838+// distinguish "no data right now" from a hard error and retry.
3939+func (b *tempBuffer) readAt(p []byte, off int64) (int, error) {
4040+ if off < 0 {
4141+ return 0, fmt.Errorf("s3fs: negative offset")
4242+ }
4343+ b.mu.Lock()
4444+ defer b.mu.Unlock()
4545+ if off >= int64(len(b.data)) {
4646+ return 0, io.EOF
4747+ }
4848+ return copy(p, b.data[off:]), nil
4949+}
5050+5151+func (b *tempBuffer) size() int64 {
5252+ b.mu.Lock()
5353+ defer b.mu.Unlock()
5454+ return int64(len(b.data))
5555+}
5656+5757+// snapshot returns a copy of the current bytes. Used by Rename to upload the
5858+// final pack to S3 without holding the mutex during the network call.
5959+func (b *tempBuffer) snapshot() []byte {
6060+ b.mu.Lock()
6161+ out := make([]byte, len(b.data))
6262+ copy(out, b.data)
6363+ b.mu.Unlock()
6464+ return out
6565+}
6666+6767+// tempWriteFile is the billy.File returned by TempFile. Close marks the handle
6868+// closed but does not upload; the final Rename uploads to S3 and Remove
6969+// discards.
7070+type tempWriteFile struct {
7171+ buf *tempBuffer
7272+ name string
7373+ closed bool
7474+}
7575+7676+func (f *tempWriteFile) Name() string { return f.name }
7777+7878+func (f *tempWriteFile) Write(p []byte) (int, error) {
7979+ if f.closed {
8080+ return 0, ErrFileClosed
8181+ }
8282+ return f.buf.write(p)
8383+}
8484+8585+func (f *tempWriteFile) WriteAt(p []byte, off int64) (int, error) {
8686+ return 0, &os.PathError{Op: "write", Path: f.name, Err: ErrNotImplemented}
8787+}
8888+8989+func (f *tempWriteFile) Read(p []byte) (int, error) { return 0, ErrCantReadFromWriteOnly }
9090+func (f *tempWriteFile) ReadAt(p []byte, off int64) (int, error) { return 0, ErrCantReadFromWriteOnly }
9191+9292+func (f *tempWriteFile) Seek(offset int64, whence int) (int64, error) {
9393+ return 0, &os.PathError{Op: "seek", Path: f.name, Err: ErrNotImplemented}
9494+}
9595+9696+func (f *tempWriteFile) Truncate(size int64) error { return ErrTruncateNotSupported }
9797+func (f *tempWriteFile) Lock() error { return ErrLockNotSupported }
9898+func (f *tempWriteFile) Unlock() error { return ErrLockNotSupported }
9999+100100+func (f *tempWriteFile) Close() error {
101101+ if f.closed {
102102+ return ErrFileClosed
103103+ }
104104+ f.closed = true
105105+ return nil
106106+}
107107+108108+func (f *tempWriteFile) Stat() (fs.FileInfo, error) {
109109+ return newFileInfo(f.name, f.buf.size(), time.Now()), nil
110110+}
111111+112112+// tempReadFile is what Open returns for a path that is still in the temp
113113+// registry. It carries its own cursor; Read returns (0, io.EOF) at the current
114114+// end of the buffer so go-git's syncedReader can sleep and retry.
115115+type tempReadFile struct {
116116+ buf *tempBuffer
117117+ name string
118118+ pos int64
119119+ closed bool
120120+}
121121+122122+func (f *tempReadFile) Name() string { return f.name }
123123+124124+func (f *tempReadFile) Read(p []byte) (int, error) {
125125+ if f.closed {
126126+ return 0, ErrFileClosed
127127+ }
128128+ n, err := f.buf.readAt(p, f.pos)
129129+ f.pos += int64(n)
130130+ return n, err
131131+}
132132+133133+func (f *tempReadFile) ReadAt(p []byte, off int64) (int, error) {
134134+ if f.closed {
135135+ return 0, ErrFileClosed
136136+ }
137137+ return f.buf.readAt(p, off)
138138+}
139139+140140+func (f *tempReadFile) Seek(offset int64, whence int) (int64, error) {
141141+ if f.closed {
142142+ return 0, ErrFileClosed
143143+ }
144144+ switch whence {
145145+ case io.SeekStart:
146146+ f.pos = offset
147147+ case io.SeekCurrent:
148148+ f.pos += offset
149149+ case io.SeekEnd:
150150+ f.pos = f.buf.size() + offset
151151+ default:
152152+ return 0, fmt.Errorf("s3fs: invalid whence %d", whence)
153153+ }
154154+ return f.pos, nil
155155+}
156156+157157+func (f *tempReadFile) Write(p []byte) (int, error) { return 0, ErrCantWriteToReadOnly }
158158+func (f *tempReadFile) WriteAt(p []byte, off int64) (int, error) { return 0, ErrCantWriteToReadOnly }
159159+func (f *tempReadFile) Truncate(size int64) error { return ErrTruncateNotSupported }
160160+func (f *tempReadFile) Lock() error { return ErrLockNotSupported }
161161+func (f *tempReadFile) Unlock() error { return ErrLockNotSupported }
162162+163163+func (f *tempReadFile) Close() error {
164164+ if f.closed {
165165+ return ErrFileClosed
166166+ }
167167+ f.closed = true
168168+ return nil
169169+}
170170+171171+func (f *tempReadFile) Stat() (fs.FileInfo, error) {
172172+ return newFileInfo(f.name, f.buf.size(), time.Now()), nil
173173+}
174174+175175+// lookupTemp returns the tempBuffer for a path if it is currently registered,
176176+// keyed by the canonical S3 key so the lookup matches the key used when
177177+// inserting.
178178+func (fs3 *S3FS) lookupTemp(name string) (*tempBuffer, bool) {
179179+ fs3.tempMu.Lock()
180180+ defer fs3.tempMu.Unlock()
181181+ buf, ok := fs3.temps[fs3.key(name)]
182182+ return buf, ok
183183+}
184184+185185+// registerTemp installs buf at the canonical key for name. Used by TempFile.
186186+func (fs3 *S3FS) registerTemp(name string, buf *tempBuffer) {
187187+ fs3.tempMu.Lock()
188188+ if fs3.temps == nil {
189189+ fs3.temps = make(map[string]*tempBuffer)
190190+ }
191191+ fs3.temps[fs3.key(name)] = buf
192192+ fs3.tempMu.Unlock()
193193+}
194194+195195+// detachTemp removes a path from the registry and returns its buffer, if any.
196196+// Used by Rename (after which the bytes are uploaded to S3) and Remove (which
197197+// discards them).
198198+func (fs3 *S3FS) detachTemp(name string) (*tempBuffer, bool) {
199199+ fs3.tempMu.Lock()
200200+ defer fs3.tempMu.Unlock()
201201+ k := fs3.key(name)
202202+ buf, ok := fs3.temps[k]
203203+ if ok {
204204+ delete(fs3.temps, k)
205205+ }
206206+ return buf, ok
207207+}
208208+209209+// Compile-time assertions: the temp handles satisfy billy.File.
210210+var (
211211+ _ billy.File = (*tempWriteFile)(nil)
212212+ _ billy.File = (*tempReadFile)(nil)
213213+)
+223
internal/s3fs/tempfs_test.go
···11+package s3fs
22+33+import (
44+ "bytes"
55+ "errors"
66+ "io"
77+ "strings"
88+ "sync"
99+ "testing"
1010+)
1111+1212+// newTempFS returns an S3FS with only the fields the temp-file code touches.
1313+// No S3 client is needed because TempFile, Open(temp), Remove(temp), and the
1414+// read/write handles never reach S3 until Rename uploads.
1515+func newTempFS() *S3FS {
1616+ return &S3FS{
1717+ bucket: "test",
1818+ separator: DefaultSeparator,
1919+ temps: make(map[string]*tempBuffer),
2020+ }
2121+}
2222+2323+// TestTempFileReadWhileWrite locks in the read-while-write semantics go-git's
2424+// streaming PackWriter relies on: TempFile + Open of the same path must share
2525+// a buffer, reads at the current end must return io.EOF (not "not found"),
2626+// and Seek must let the reader rewind to re-parse from the start.
2727+func TestTempFileReadWhileWrite(t *testing.T) {
2828+ for _, tt := range []struct {
2929+ name string
3030+ run func(t *testing.T, fs *S3FS, fw, fr io.ReadWriteSeeker)
3131+ }{
3232+ {
3333+ name: "read sees writes",
3434+ run: func(t *testing.T, _ *S3FS, fw, fr io.ReadWriteSeeker) {
3535+ if _, err := fw.Write([]byte("hello world")); err != nil {
3636+ t.Fatalf("Write: %v", err)
3737+ }
3838+ got, err := io.ReadAll(fr)
3939+ if err != nil {
4040+ t.Fatalf("ReadAll: %v", err)
4141+ }
4242+ if string(got) != "hello world" {
4343+ t.Logf("want: %q", "hello world")
4444+ t.Logf("got: %q", string(got))
4545+ t.Error("read did not see written bytes")
4646+ }
4747+ },
4848+ },
4949+ {
5050+ name: "EOF at current end, then resume after more writes",
5151+ run: func(t *testing.T, _ *S3FS, fw, fr io.ReadWriteSeeker) {
5252+ if _, err := fw.Write([]byte("part1")); err != nil {
5353+ t.Fatalf("Write: %v", err)
5454+ }
5555+ buf := make([]byte, 5)
5656+ if n, err := fr.Read(buf); err != nil || n != 5 {
5757+ t.Fatalf("first Read: n=%d err=%v", n, err)
5858+ }
5959+ if n, err := fr.Read(buf); !errors.Is(err, io.EOF) || n != 0 {
6060+ t.Fatalf("Read at end: n=%d err=%v, want (0, io.EOF)", n, err)
6161+ }
6262+ if _, err := fw.Write([]byte("part2")); err != nil {
6363+ t.Fatalf("Write 2: %v", err)
6464+ }
6565+ if n, err := fr.Read(buf); err != nil || n != 5 {
6666+ t.Fatalf("Read after second write: n=%d err=%v", n, err)
6767+ }
6868+ if string(buf) != "part2" {
6969+ t.Errorf("got %q, want %q", string(buf), "part2")
7070+ }
7171+ },
7272+ },
7373+ {
7474+ name: "seek to start re-reads the whole buffer",
7575+ run: func(t *testing.T, _ *S3FS, fw, fr io.ReadWriteSeeker) {
7676+ if _, err := fw.Write([]byte("abcdef")); err != nil {
7777+ t.Fatalf("Write: %v", err)
7878+ }
7979+ if _, err := io.ReadAll(fr); err != nil {
8080+ t.Fatalf("drain: %v", err)
8181+ }
8282+ if pos, err := fr.Seek(0, io.SeekStart); err != nil || pos != 0 {
8383+ t.Fatalf("Seek(0): pos=%d err=%v", pos, err)
8484+ }
8585+ got, err := io.ReadAll(fr)
8686+ if err != nil {
8787+ t.Fatalf("ReadAll after seek: %v", err)
8888+ }
8989+ if string(got) != "abcdef" {
9090+ t.Errorf("got %q, want %q", string(got), "abcdef")
9191+ }
9292+ },
9393+ },
9494+ } {
9595+ t.Run(tt.name, func(t *testing.T) {
9696+ fs := newTempFS()
9797+ fw, err := fs.TempFile("objects/pack", "tmp_pack_")
9898+ if err != nil {
9999+ t.Fatalf("TempFile: %v", err)
100100+ }
101101+ if !strings.HasPrefix(fw.Name(), "objects/pack/tmp_pack_") {
102102+ t.Fatalf("unexpected temp name: %q", fw.Name())
103103+ }
104104+ fr, err := fs.Open(fw.Name())
105105+ if err != nil {
106106+ t.Fatalf("Open(%q): %v", fw.Name(), err)
107107+ }
108108+ tt.run(t, fs, fw, fr)
109109+ })
110110+ }
111111+}
112112+113113+// TestTempFileReadAt covers the io.ReaderAt path that idxfile parsing uses
114114+// to seek around in the pack while it is being indexed.
115115+func TestTempFileReadAt(t *testing.T) {
116116+ fs := newTempFS()
117117+ fw, err := fs.TempFile("objects/pack", "tmp_pack_")
118118+ if err != nil {
119119+ t.Fatalf("TempFile: %v", err)
120120+ }
121121+ if _, err := fw.Write([]byte("0123456789")); err != nil {
122122+ t.Fatalf("Write: %v", err)
123123+ }
124124+ fr, err := fs.Open(fw.Name())
125125+ if err != nil {
126126+ t.Fatalf("Open: %v", err)
127127+ }
128128+ buf := make([]byte, 4)
129129+ n, err := fr.ReadAt(buf, 3)
130130+ if err != nil || n != 4 {
131131+ t.Fatalf("ReadAt(_, 3): n=%d err=%v", n, err)
132132+ }
133133+ if string(buf) != "3456" {
134134+ t.Errorf("got %q, want %q", string(buf), "3456")
135135+ }
136136+ // Reading past end returns io.EOF.
137137+ if n, err := fr.ReadAt(make([]byte, 1), 100); !errors.Is(err, io.EOF) || n != 0 {
138138+ t.Errorf("ReadAt past end: n=%d err=%v, want (0, io.EOF)", n, err)
139139+ }
140140+}
141141+142142+// TestTempFileRemove drops the registry entry without hitting S3 — important
143143+// because a nil S3 client would otherwise crash the test. After Remove, Open
144144+// of the same path must no longer return the temp buffer.
145145+func TestTempFileRemove(t *testing.T) {
146146+ fs := newTempFS()
147147+ fw, err := fs.TempFile("objects/pack", "tmp_pack_")
148148+ if err != nil {
149149+ t.Fatalf("TempFile: %v", err)
150150+ }
151151+ if err := fs.Remove(fw.Name()); err != nil {
152152+ t.Fatalf("Remove: %v", err)
153153+ }
154154+ if _, ok := fs.lookupTemp(fw.Name()); ok {
155155+ t.Fatal("temp registry still has entry after Remove")
156156+ }
157157+ if len(fs.temps) != 0 {
158158+ t.Errorf("temps len = %d, want 0", len(fs.temps))
159159+ }
160160+}
161161+162162+// TestTempFileConcurrentWriteRead exercises the actual go-git pattern: one
163163+// goroutine writes, another reads, and the reader retries on (0, io.EOF) like
164164+// syncedReader does. The full payload must round-trip.
165165+func TestTempFileConcurrentWriteRead(t *testing.T) {
166166+ fs := newTempFS()
167167+ fw, err := fs.TempFile("objects/pack", "tmp_pack_")
168168+ if err != nil {
169169+ t.Fatalf("TempFile: %v", err)
170170+ }
171171+ fr, err := fs.Open(fw.Name())
172172+ if err != nil {
173173+ t.Fatalf("Open: %v", err)
174174+ }
175175+176176+ payload := bytes.Repeat([]byte("xyzpdq"), 4096) // ~24 KiB
177177+ var got bytes.Buffer
178178+ var wg sync.WaitGroup
179179+180180+ done := make(chan struct{})
181181+ wg.Add(1)
182182+ go func() {
183183+ defer wg.Done()
184184+ buf := make([]byte, 1024)
185185+ for {
186186+ n, err := fr.Read(buf)
187187+ if n > 0 {
188188+ got.Write(buf[:n])
189189+ }
190190+ if errors.Is(err, io.EOF) {
191191+ if got.Len() == len(payload) {
192192+ return
193193+ }
194194+ select {
195195+ case <-done:
196196+ return
197197+ default:
198198+ continue
199199+ }
200200+ }
201201+ if err != nil {
202202+ t.Errorf("Read: %v", err)
203203+ return
204204+ }
205205+ }
206206+ }()
207207+208208+ for off := 0; off < len(payload); off += 1000 {
209209+ end := off + 1000
210210+ if end > len(payload) {
211211+ end = len(payload)
212212+ }
213213+ if _, err := fw.Write(payload[off:end]); err != nil {
214214+ t.Fatalf("Write: %v", err)
215215+ }
216216+ }
217217+ close(done)
218218+ wg.Wait()
219219+220220+ if !bytes.Equal(got.Bytes(), payload) {
221221+ t.Errorf("payload mismatch: got %d bytes, want %d", got.Len(), len(payload))
222222+ }
223223+}