cdrop — 跨 OS 剪贴板与文件传输服务

Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
package relay
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"time"
)
const (
DefaultMaxSessions = 8
DefaultMaxSessionsPerUser = 3
DefaultSessionCap = 64 << 20 // 64 MiB
DefaultChunkLimit = 1 << 20 // 1 MiB
// DefaultIdleTTL is how long a registered relay session may sit without any
// chunk/stream activity before the reaper tears it down. A fallback that
// registered but never got used (both peers vanished) is reclaimed rather
// than pinning a slot — and, once bytes flowed, its 64 MiB ring — forever.
DefaultIdleTTL = 2 * time.Minute
// DefaultReapPeriod is how often the reaper goroutine sweeps for idle sessions.
DefaultReapPeriod = 30 * time.Second
)
var (
ErrTooManySessions = errors.New("relay: too many active sessions")
ErrForbidden = errors.New("relay: caller is not a participant of this session")
ErrNotRegistered = errors.New("relay: session not registered")
)
// Session pairs a Ring with the metadata that proves who is allowed to use it.
// The ring is allocated lazily on the first Acquire so a registered-but-unused
// fallback costs only bookkeeping until bytes actually flow.
type Session struct {
ID string
UserID string
Sender string // device name of the legitimate sender (POSTs chunks)
Receiver string // device name of the legitimate receiver (GETs the stream)
capBytes int
ring *Ring // nil until first Acquire; guarded by Manager.mu
bytesWritten atomic.Int64
lastActivity atomic.Int64 // unix seconds; the reaper's idle clock
readerActive atomic.Bool // single-consumer guard (a second reader tears the ring)
}
func (s *Session) touch() { s.lastActivity.Store(time.Now().Unix()) }
func (s *Session) Write(ctx context.Context, p []byte) (int, error) {
s.touch()
n, err := s.ring.Write(ctx, p)
if n > 0 {
s.bytesWritten.Add(int64(n))
}
return n, err
}
func (s *Session) Read(ctx context.Context, p []byte) (int, error) {
s.touch()
return s.ring.Read(ctx, p)
}
func (s *Session) CloseWriter() {
if s.ring != nil {
s.ring.CloseWriter()
}
}
func (s *Session) Abort() {
if s.ring != nil {
s.ring.Abort()
}
}
func (s *Session) BytesWritten() int64 { return s.bytesWritten.Load() }
// AcquireReader claims the single consumer slot. The relay stream is strictly
// single-consumer: two concurrent readers would each drain bytes out of the
// ring and silently corrupt the receiver's copy (G2). A second reader gets a
// false here so the handler can 409 instead of joining.
func (s *Session) AcquireReader() bool { return s.readerActive.CompareAndSwap(false, true) }
// ReleaseReader frees the consumer slot when the stream handler returns.
func (s *Session) ReleaseReader() { s.readerActive.Store(false) }
// Manager owns active relay sessions. A session must be Register-ed (which the
// transfer service does when a transfer enters RELAY_ACTIVE) before either peer
// can Acquire it. This is the core of the R1/G2 hardening: the old Acquire
// minted a session for any unknown id on demand, so 8 one-byte requests could
// exhaust every slot and pin ~512 MiB. Now only legitimate, server-minted
// transfers that actually reached RELAY_ACTIVE get a slot, capped both globally
// and per-user, and reclaimed when idle.
type Manager struct {
mu sync.Mutex
sessions map[string]*Session
perUser map[string]int
maxSessions int
maxPerUser int
sessionCap int
idleTTL time.Duration
}
func NewManager(maxSessions, sessionCapBytes int) *Manager {
if maxSessions <= 0 {
maxSessions = DefaultMaxSessions
}
if sessionCapBytes <= 0 {
sessionCapBytes = DefaultSessionCap
}
maxPerUser := DefaultMaxSessionsPerUser
if maxPerUser > maxSessions {
// A per-user cap above the global cap is meaningless; clamp so small
// global caps (tests, constrained deploys) still behave sanely.
maxPerUser = maxSessions
}
return &Manager{
sessions: map[string]*Session{},
perUser: map[string]int{},
maxSessions: maxSessions,
maxPerUser: maxPerUser,
sessionCap: sessionCapBytes,
idleTTL: DefaultIdleTTL,
}
}
// Register reserves a relay slot for a transfer that has just entered
// RELAY_ACTIVE, recording its two legitimate participants. Only a registered
// session can later be joined via Acquire.
//
// Idempotent for the same (id, userID): a duplicate Register just refreshes the
// idle clock. A mismatched userID is ErrForbidden (ids are server-minted, so
// this is defensive). Returns ErrTooManySessions when the global or per-user
// cap is reached — the caller (transfer service) surfaces that as a retryable
// failure rather than moving the transfer into a relay mode with no slot.
func (m *Manager) Register(id, userID, sender, receiver string) error {
m.mu.Lock()
defer m.mu.Unlock()
if s, ok := m.sessions[id]; ok {
if s.UserID != userID {
return ErrForbidden
}
s.touch()
return nil
}
if len(m.sessions) >= m.maxSessions {
return ErrTooManySessions
}
if m.perUser[userID] >= m.maxPerUser {
return ErrTooManySessions
}
s := &Session{
ID: id,
UserID: userID,
Sender: sender,
Receiver: receiver,
capBytes: m.sessionCap,
}
s.touch()
m.sessions[id] = s
m.perUser[userID] = m.perUser[userID] + 1
return nil
}
// Acquire joins an already-registered relay session as one of its two
// participants. Unlike the old implementation it never creates a session:
// - unknown id → ErrNotRegistered
// - different user → ErrForbidden
// - not sender/receiver → ErrForbidden
//
// device is the caller's X-Device-Name (set by the auth middleware). The 64 MiB
// ring is allocated here, lazily, on the first join.
func (m *Manager) Acquire(id, userID, device string) (*Session, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[id]
if !ok {
return nil, ErrNotRegistered
}
if s.UserID != userID {
return nil, ErrForbidden
}
if device == "" || (device != s.Sender && device != s.Receiver) {
return nil, ErrForbidden
}
if s.ring == nil {
s.ring = newRing(s.capBytes)
}
s.touch()
return s, nil
}
// Release removes the session from the active set and aborts any blocked
// Read/Write. Safe to call multiple times (terminal transitions and the pending
// sweeper both call it). A no-op for an id that was never registered.
func (m *Manager) Release(id string) {
m.mu.Lock()
s, ok := m.sessions[id]
if ok {
delete(m.sessions, id)
m.decUserLocked(s.UserID)
}
m.mu.Unlock()
if ok {
s.Abort()
}
}
// decUserLocked drops one from a user's active count, deleting the key at zero
// so perUser never accumulates empty entries. Caller holds m.mu.
func (m *Manager) decUserLocked(userID string) {
if n := m.perUser[userID]; n <= 1 {
delete(m.perUser, userID)
} else {
m.perUser[userID] = n - 1
}
}
// Active reports the current count of held sessions.
func (m *Manager) Active() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.sessions)
}
// reapIdle aborts and removes every session whose last activity is older than
// the idle TTL, returning how many it reclaimed. now is unix seconds (injected
// so tests don't depend on the wall clock).
func (m *Manager) reapIdle(now int64) int {
cutoff := now - int64(m.idleTTL/time.Second)
var dead []*Session
m.mu.Lock()
for id, s := range m.sessions {
if s.lastActivity.Load() < cutoff {
delete(m.sessions, id)
m.decUserLocked(s.UserID)
dead = append(dead, s)
}
}
m.mu.Unlock()
for _, s := range dead {
s.Abort()
}
return len(dead)
}
// RunReaper blocks until ctx is cancelled, periodically reclaiming idle relay
// sessions (fallbacks that registered but never completed — e.g. both peers
// went away mid-transfer). Mirrors transfer.RunSweeper; start it once at boot.
func (m *Manager) RunReaper(ctx context.Context) {
t := time.NewTicker(DefaultReapPeriod)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n := m.reapIdle(time.Now().Unix()); n > 0 {
slog.Info("relay reaper: reclaimed idle sessions", "count", n)
}
}
}
}
+195
View File
@@ -0,0 +1,195 @@
package relay
import (
"errors"
"testing"
"time"
)
// register is a test helper: every session must be Register-ed before it can be
// Acquire-d. Sender "s" / receiver "r" unless a test needs specific names.
func register(t *testing.T, m *Manager, id, user string) {
t.Helper()
if err := m.Register(id, user, "s", "r"); err != nil {
t.Fatalf("register %s/%s: %v", id, user, err)
}
}
func TestManager_AcquireReturnsSameSession(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "s1", "alice")
s1, err := m.Acquire("s1", "alice", "s")
if err != nil {
t.Fatalf("acquire 1: %v", err)
}
s2, err := m.Acquire("s1", "alice", "r")
if err != nil {
t.Fatalf("acquire 2: %v", err)
}
if s1 != s2 {
t.Error("expected same session pointer on repeat Acquire")
}
}
// Acquire never mints a session: an id that was never Register-ed is rejected.
// This is the core R1 fix — the old Acquire created one on demand, so any
// authenticated client could conjure sessions for arbitrary ids and exhaust
// every slot.
func TestManager_AcquireUnregisteredRejected(t *testing.T) {
m := NewManager(8, 1024)
if _, err := m.Acquire("ghost", "alice", "s"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("expected ErrNotRegistered, got %v", err)
}
if got := m.Active(); got != 0 {
t.Errorf("a rejected Acquire must not create a session; Active=%d", got)
}
}
func TestManager_DifferentUserForbidden(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "s1", "alice")
if _, err := m.Acquire("s1", "mallory", "s"); !errors.Is(err, ErrForbidden) {
t.Errorf("expected ErrForbidden, got %v", err)
}
}
// Only the registered sender/receiver device may join (G2). A third device of
// the same account — or a missing device name — is forbidden.
func TestManager_NonParticipantForbidden(t *testing.T) {
m := NewManager(8, 1024)
if err := m.Register("s1", "alice", "laptop", "phone"); err != nil {
t.Fatalf("register: %v", err)
}
if _, err := m.Acquire("s1", "alice", "tablet"); !errors.Is(err, ErrForbidden) {
t.Errorf("non-participant device: expected ErrForbidden, got %v", err)
}
if _, err := m.Acquire("s1", "alice", ""); !errors.Is(err, ErrForbidden) {
t.Errorf("empty device: expected ErrForbidden, got %v", err)
}
if _, err := m.Acquire("s1", "alice", "laptop"); err != nil {
t.Errorf("sender device must be allowed: %v", err)
}
if _, err := m.Acquire("s1", "alice", "phone"); err != nil {
t.Errorf("receiver device must be allowed: %v", err)
}
}
// Register, not Acquire, now enforces the global cap.
func TestManager_GlobalCapEnforced(t *testing.T) {
m := NewManager(2, 1024)
if err := m.Register("a", "u1", "s", "r"); err != nil {
t.Fatalf("a: %v", err)
}
if err := m.Register("b", "u2", "s", "r"); err != nil {
t.Fatalf("b: %v", err)
}
if err := m.Register("c", "u3", "s", "r"); !errors.Is(err, ErrTooManySessions) {
t.Errorf("expected ErrTooManySessions, got %v", err)
}
if got := m.Active(); got != 2 {
t.Errorf("Active: got %d, want 2", got)
}
}
// A single user can't monopolise every global slot — the per-user cap stops
// them well before the global cap, leaving room for other users.
func TestManager_PerUserCapEnforced(t *testing.T) {
m := NewManager(8, 1024) // per-user default is 3
for i, id := range []string{"a", "b", "c"} {
if err := m.Register(id, "greedy", "s", "r"); err != nil {
t.Fatalf("register %d: %v", i, err)
}
}
if err := m.Register("d", "greedy", "s", "r"); !errors.Is(err, ErrTooManySessions) {
t.Errorf("4th session for one user: expected ErrTooManySessions, got %v", err)
}
// A different user is unaffected — global slots remain.
if err := m.Register("e", "other", "s", "r"); err != nil {
t.Errorf("other user should still register: %v", err)
}
}
// Register is idempotent for the same owner (e.g. a retried /fallback) and does
// not consume a second slot.
func TestManager_RegisterIdempotent(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "s1", "alice")
register(t, m, "s1", "alice")
if got := m.Active(); got != 1 {
t.Errorf("duplicate Register must not add a slot; Active=%d", got)
}
if err := m.Register("s1", "mallory", "s", "r"); !errors.Is(err, ErrForbidden) {
t.Errorf("re-register under another user: expected ErrForbidden, got %v", err)
}
}
func TestManager_ReleaseFreesSlot(t *testing.T) {
m := NewManager(1, 1024)
register(t, m, "a", "u")
m.Release("a")
if got := m.Active(); got != 0 {
t.Errorf("Active after release: got %d, want 0", got)
}
// A new session can now slot in (both global and per-user counts dropped).
if err := m.Register("b", "u", "s", "r"); err != nil {
t.Errorf("register after release: %v", err)
}
}
func TestManager_ReleaseAbortsSession(t *testing.T) {
m := NewManager(1, 1024)
register(t, m, "a", "u")
s, err := m.Acquire("a", "u", "s") // allocates the ring
if err != nil {
t.Fatalf("acquire: %v", err)
}
m.Release("a")
if _, err := s.Write(nil, []byte("x")); !errors.Is(err, ErrClosed) {
t.Errorf("write after release: got %v, want ErrClosed", err)
}
}
// The reaper reclaims a session whose last activity is older than the idle TTL,
// dropping both the global and per-user counts so the slot is reusable.
func TestManager_ReaperReclaimsIdle(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "a", "u")
// Just-registered: a sweep "now" leaves it alone.
if n := m.reapIdle(time.Now().Unix()); n != 0 {
t.Errorf("fresh session reclaimed: got %d, want 0", n)
}
// Sweep far enough in the future that it's past the idle TTL.
future := time.Now().Add(DefaultIdleTTL + time.Minute).Unix()
if n := m.reapIdle(future); n != 1 {
t.Errorf("idle session not reclaimed: got %d, want 1", n)
}
if got := m.Active(); got != 0 {
t.Errorf("Active after reap: got %d, want 0", got)
}
// The per-user slot was freed too.
if err := m.Register("b", "u", "s", "r"); err != nil {
t.Errorf("register after reap: %v", err)
}
}
// The stream is single-consumer: a second concurrent reader is refused so it
// can't drain bytes out from under the first and corrupt the receiver's copy.
func TestSession_SingleReader(t *testing.T) {
m := NewManager(8, 1024)
register(t, m, "a", "u")
s, err := m.Acquire("a", "u", "r")
if err != nil {
t.Fatalf("acquire: %v", err)
}
if !s.AcquireReader() {
t.Fatal("first AcquireReader should succeed")
}
if s.AcquireReader() {
t.Error("second AcquireReader must fail while the first holds it")
}
s.ReleaseReader()
if !s.AcquireReader() {
t.Error("AcquireReader should succeed again after release")
}
}
+170
View File
@@ -0,0 +1,170 @@
package relay
import (
"context"
"errors"
"io"
"sync"
)
// ErrClosed is returned by Read/Write after Abort.
var ErrClosed = errors.New("relay: ring aborted")
// Ring is a bounded byte buffer with single-producer / single-consumer
// blocking semantics. Plan §M5: 单 session 64 MiB.
//
// Internally it's a flat slice with head/tail wrap-around, guarded by a
// sync.Cond. Both Read and Write accept ctx so handlers can abort fast on
// client disconnect — the watchdog goroutine broadcasts the cond when ctx
// fires so blocked Wait calls re-check ctx.Err().
type Ring struct {
mu sync.Mutex
cond *sync.Cond
buf []byte
head int
tail int
used int
cap int
closed bool // sender called CloseWriter; reader drains to EOF
aborted bool // both sides torn down
}
func newRing(capacity int) *Ring {
r := &Ring{
buf: make([]byte, capacity),
cap: capacity,
}
r.cond = sync.NewCond(&r.mu)
return r
}
func (r *Ring) Write(ctx context.Context, p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
stop := r.watchCtx(ctx)
defer stop()
r.mu.Lock()
defer r.mu.Unlock()
written := 0
for written < len(p) {
for r.used == r.cap && !r.aborted && !r.closed && ctxOK(ctx) {
r.cond.Wait()
}
if !ctxOK(ctx) {
return written, ctx.Err()
}
if r.aborted {
return written, ErrClosed
}
if r.closed {
return written, ErrClosed
}
free := r.cap - r.used
toWrite := len(p) - written
if toWrite > free {
toWrite = free
}
end := r.tail + toWrite
if end <= r.cap {
copy(r.buf[r.tail:end], p[written:written+toWrite])
} else {
first := r.cap - r.tail
copy(r.buf[r.tail:], p[written:written+first])
copy(r.buf[:toWrite-first], p[written+first:written+toWrite])
}
r.tail = (r.tail + toWrite) % r.cap
r.used += toWrite
written += toWrite
r.cond.Broadcast()
}
return written, nil
}
func (r *Ring) Read(ctx context.Context, p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
stop := r.watchCtx(ctx)
defer stop()
r.mu.Lock()
defer r.mu.Unlock()
for r.used == 0 && !r.closed && !r.aborted && ctxOK(ctx) {
r.cond.Wait()
}
if !ctxOK(ctx) {
return 0, ctx.Err()
}
if r.used == 0 {
if r.aborted {
return 0, ErrClosed
}
return 0, io.EOF
}
n := r.used
if n > len(p) {
n = len(p)
}
end := r.head + n
if end <= r.cap {
copy(p[:n], r.buf[r.head:end])
} else {
first := r.cap - r.head
copy(p[:first], r.buf[r.head:])
copy(p[first:n], r.buf[:n-first])
}
r.head = (r.head + n) % r.cap
r.used -= n
r.cond.Broadcast()
return n, nil
}
// CloseWriter signals end-of-stream from the sender side. Reader returns
// io.EOF after draining whatever is still buffered.
func (r *Ring) CloseWriter() {
r.mu.Lock()
r.closed = true
r.cond.Broadcast()
r.mu.Unlock()
}
// Abort tears the ring down hard. Subsequent Read/Write return ErrClosed.
func (r *Ring) Abort() {
r.mu.Lock()
r.aborted = true
r.cond.Broadcast()
r.mu.Unlock()
}
func (r *Ring) watchCtx(ctx context.Context) func() {
if ctx == nil || ctx.Err() != nil {
return func() {}
}
stop := make(chan struct{})
go func() {
select {
case <-ctx.Done():
r.mu.Lock()
r.cond.Broadcast()
r.mu.Unlock()
case <-stop:
}
}()
return func() { close(stop) }
}
func ctxOK(ctx context.Context) bool {
if ctx == nil {
return true
}
return ctx.Err() == nil
}
+177
View File
@@ -0,0 +1,177 @@
package relay
import (
"bytes"
"context"
"errors"
"io"
"sync"
"testing"
"time"
)
func TestRing_BasicWriteRead(t *testing.T) {
r := newRing(64)
want := []byte("hello, world")
if n, err := r.Write(context.Background(), want); err != nil || n != len(want) {
t.Fatalf("write: n=%d err=%v", n, err)
}
got := make([]byte, 32)
n, err := r.Read(context.Background(), got)
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got[:n], want) {
t.Errorf("read mismatch: got %q want %q", got[:n], want)
}
}
func TestRing_CloseWriterSignalsEOF(t *testing.T) {
r := newRing(32)
_, _ = r.Write(context.Background(), []byte("abc"))
r.CloseWriter()
got := make([]byte, 16)
n, err := r.Read(context.Background(), got)
if err != nil || n != 3 {
t.Fatalf("first read: n=%d err=%v", n, err)
}
n, err = r.Read(context.Background(), got)
if !errors.Is(err, io.EOF) {
t.Errorf("expected EOF after drain; got n=%d err=%v", n, err)
}
}
func TestRing_AbortReturnsErrClosed(t *testing.T) {
r := newRing(32)
r.Abort()
if _, err := r.Write(context.Background(), []byte("x")); !errors.Is(err, ErrClosed) {
t.Errorf("write after abort: got %v want ErrClosed", err)
}
if _, err := r.Read(context.Background(), make([]byte, 1)); !errors.Is(err, ErrClosed) {
t.Errorf("read after abort: got %v want ErrClosed", err)
}
}
func TestRing_BlocksWhenFullThenUnblocksOnRead(t *testing.T) {
r := newRing(8)
if _, err := r.Write(context.Background(), []byte("12345678")); err != nil {
t.Fatalf("fill: %v", err)
}
done := make(chan struct{})
go func() {
_, err := r.Write(context.Background(), []byte("X"))
if err != nil {
t.Errorf("blocked write: %v", err)
}
close(done)
}()
// Without a reader the goroutine should block.
select {
case <-done:
t.Fatal("write should block when ring is full")
case <-time.After(50 * time.Millisecond):
}
got := make([]byte, 4)
if _, err := r.Read(context.Background(), got); err != nil {
t.Fatalf("read: %v", err)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("write did not unblock after read")
}
}
func TestRing_CtxCancelsBlockedRead(t *testing.T) {
r := newRing(8)
ctx, cancel := context.WithCancel(context.Background())
type res struct {
n int
err error
}
out := make(chan res, 1)
go func() {
buf := make([]byte, 4)
n, err := r.Read(ctx, buf)
out <- res{n, err}
}()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case r := <-out:
if !errors.Is(r.err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", r.err)
}
case <-time.After(time.Second):
t.Fatal("blocked read did not unblock on ctx cancel")
}
}
func TestRing_WrapsAround(t *testing.T) {
r := newRing(4)
// Cycle through enough bytes to force head/tail wrapping.
for i := 0; i < 20; i++ {
_, _ = r.Write(context.Background(), []byte{byte(i)})
got := make([]byte, 1)
n, _ := r.Read(context.Background(), got)
if n != 1 || got[0] != byte(i) {
t.Errorf("iter %d: got %v want [%d]", i, got, i)
}
}
}
func TestRing_StressManyChunks(t *testing.T) {
const total = 4 * 1024
r := newRing(64)
var wgWriter, wgReader sync.WaitGroup
wgWriter.Add(1)
wgReader.Add(1)
src := make([]byte, total)
for i := range src {
src[i] = byte(i)
}
go func() {
defer wgWriter.Done()
for i := 0; i < total; i += 13 {
end := i + 13
if end > total {
end = total
}
_, _ = r.Write(context.Background(), src[i:end])
}
r.CloseWriter()
}()
got := make([]byte, 0, total)
go func() {
defer wgReader.Done()
buf := make([]byte, 17)
for {
n, err := r.Read(context.Background(), buf)
got = append(got, buf[:n]...)
if errors.Is(err, io.EOF) {
return
}
if err != nil {
t.Errorf("read: %v", err)
return
}
}
}()
wgWriter.Wait()
wgReader.Wait()
if !bytes.Equal(got, src) {
t.Errorf("mismatch: got %d bytes, want %d", len(got), len(src))
}
}