mirror of
https://github.com/tailscale/tailscale.git
synced 2026-09-15 15:29:33 -04:00
This adds the following helpers:
* WaitLength waits until the buffer is non-empty or a context is canceled.
It supports batchDelays so that even if data is ready, it blocks
until the delay is over. This is useful for how we upload logs for iOS,
where we deliberately wait a few minutes to reduce wakeup costs.
* DiscardOversize asynchronously discards data in the buffer
once it exceeds the specified maxSize. It takes in an optional frameLen
to ensure discarding maintains consistent frames in the buffer.
This avoids known problems with today's ring buffer where we can get
torn frames that lead to silent data corruption.
An alternative approach would be to synchronously delete data upon
an oversize condition at Write time, but there are two reasons
not to do that:
1. Doing requires teaching each Buffer implementation about
the concept of framing, which the interface deliberately avoids.
2. We want the write path to be extremely fast as we never want to
be blocking production logic. Going over maxSize momentarily is
considered a better tradeoff than synchronously blocking writes.
* StreamReader converts a non-blocking Buffer reader into a blocking one.
This exists primarily for debugging where you can simply stream
the entirety of a buffer to stdout.
We also adjust the package to avoid wrapping io.EOF and ErrEmpty
as those are sentinel errors with very specific meanings.
Updates tailscale/corp#21363
Signed-off-by: Joe Tsai <joetsai@digital-static.net>
396 lines
11 KiB
Go
396 lines
11 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package ioqueue
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"tailscale.com/util/must"
|
|
)
|
|
|
|
// writeCloser is the optional write-side close API implemented by
|
|
// concrete buffers (for example [VolatileBuffer.CloseWrite]).
|
|
type writeCloser interface {
|
|
CloseWrite() error
|
|
}
|
|
|
|
// testBuffers runs f against each concrete [Buffer] implementation.
|
|
func testBuffers(t *testing.T, f func(t *testing.T, b Buffer)) {
|
|
t.Helper()
|
|
t.Run("Volatile", func(t *testing.T) { f(t, new(VolatileBuffer)) })
|
|
// TODO: t.Run("Durable", func(t *testing.T) { f(t, openTestDurable(t)) })
|
|
}
|
|
|
|
func TestBufferBasic(t *testing.T) {
|
|
testBuffers(t, func(t *testing.T, b Buffer) {
|
|
if b.Len() != 0 || b.ReadOffset() != 0 || b.WriteOffset() != 0 {
|
|
t.Fatalf("empty: Len=%d ReadOff=%d WriteOff=%d", b.Len(), b.ReadOffset(), b.WriteOffset())
|
|
}
|
|
|
|
const msg = "hello world"
|
|
n, err := b.Write([]byte(msg))
|
|
if err != nil || n != len(msg) {
|
|
t.Fatalf("Write = (%d, %v), want (%d, nil)", n, err, len(msg))
|
|
}
|
|
if b.WriteOffset() != int64(len(msg)) || b.ReadOffset() != 0 || b.Len() != int64(len(msg)) {
|
|
t.Fatalf("after Write: Len=%d ReadOff=%d WriteOff=%d", b.Len(), b.ReadOffset(), b.WriteOffset())
|
|
}
|
|
|
|
p := make([]byte, 5)
|
|
ro, nn, err := b.Peek(p)
|
|
if err != nil || ro != 0 || nn != 5 || string(p) != "hello" {
|
|
t.Fatalf("Peek = (%d, %d, %v, %q), want (0, 5, nil, hello)", ro, nn, err, p)
|
|
}
|
|
if b.ReadOffset() != 0 || b.Len() != int64(len(msg)) {
|
|
t.Fatalf("Peek must not advance offsets: ReadOff=%d Len=%d", b.ReadOffset(), b.Len())
|
|
}
|
|
|
|
dn, err := b.DiscardUntil(5)
|
|
if err != nil || dn != 5 {
|
|
t.Fatalf("DiscardUntil(5) = (%d, %v), want (5, nil)", dn, err)
|
|
}
|
|
if b.ReadOffset() != 5 || b.Len() != int64(len(msg)-5) {
|
|
t.Fatalf("after DiscardUntil: ReadOff=%d Len=%d", b.ReadOffset(), b.Len())
|
|
}
|
|
|
|
// DiscardUntil behind the cursor is a no-op.
|
|
dn, err = b.DiscardUntil(3)
|
|
if err != nil || dn != 0 || b.ReadOffset() != 5 {
|
|
t.Fatalf("DiscardUntil(3) = (%d, %v), ReadOff=%d; want (0, nil), 5", dn, err, b.ReadOffset())
|
|
}
|
|
|
|
got := make([]byte, 64)
|
|
rn, err := b.Read(got)
|
|
if err != nil || rn != len(msg)-5 || string(got[:rn]) != " world" {
|
|
t.Fatalf("Read = (%d, %v, %q), want (%d, nil, %q)", rn, err, got[:rn], len(msg)-5, " world")
|
|
}
|
|
if b.Len() != 0 || b.ReadOffset() != b.WriteOffset() {
|
|
t.Fatalf("after full Read: Len=%d ReadOff=%d WriteOff=%d", b.Len(), b.ReadOffset(), b.WriteOffset())
|
|
}
|
|
|
|
_, err = b.Read(got)
|
|
if err != ErrEmpty {
|
|
t.Fatalf("Read empty = %v, want ErrEmpty", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBufferDiscardPastEnd(t *testing.T) {
|
|
testBuffers(t, func(t *testing.T, b Buffer) {
|
|
if _, err := b.Write([]byte("abc")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n, err := b.DiscardUntil(100)
|
|
if n != 3 || err != ErrEmpty {
|
|
t.Fatalf("DiscardUntil(100) = (%d, %v), want (3, ErrEmpty)", n, err)
|
|
}
|
|
if b.ReadOffset() != 3 || b.WriteOffset() != 3 || b.Len() != 0 {
|
|
t.Fatalf("offsets after over-discard: ReadOff=%d WriteOff=%d Len=%d", b.ReadOffset(), b.WriteOffset(), b.Len())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBufferWaitUntil(t *testing.T) {
|
|
testBuffers(t, func(t *testing.T, b Buffer) {
|
|
// WriteOffset (0) does not exceed 0, so must wait.
|
|
// After a write of 1 byte, WriteOffset=1 exceeds 0.
|
|
ch := b.WaitUntil(0)
|
|
select {
|
|
case <-ch:
|
|
t.Fatal("WaitUntil(0) on empty buffer closed immediately")
|
|
default:
|
|
}
|
|
|
|
// Coalesce: same target shares the channel.
|
|
ch2 := b.WaitUntil(0)
|
|
if ch != ch2 {
|
|
t.Fatal("WaitUntil coalescing: expected same channel for same offset")
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
<-ch
|
|
}()
|
|
|
|
if _, err := b.Write([]byte("x")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("WaitUntil(0) did not fire after Write")
|
|
}
|
|
|
|
// Already satisfied after write: WriteOffset=1 > 0.
|
|
select {
|
|
case <-b.WaitUntil(0):
|
|
default:
|
|
t.Fatal("WaitUntil(0) should be already closed when WriteOffset=1")
|
|
}
|
|
|
|
// Wait for WriteOffset > 10.
|
|
ch = b.WaitUntil(10)
|
|
select {
|
|
case <-ch:
|
|
t.Fatal("WaitUntil(10) fired too early")
|
|
default:
|
|
}
|
|
if _, err := b.Write(make([]byte, 9)); err != nil { // WriteOffset = 10, not yet >
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-ch:
|
|
t.Fatal("WaitUntil(10) fired at WriteOffset==10; need strictly greater")
|
|
default:
|
|
}
|
|
if _, err := b.Write([]byte("y")); err != nil { // WriteOffset = 11
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("WaitUntil(10) did not fire when WriteOffset=11")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBufferCloseWrite(t *testing.T) {
|
|
testBuffers(t, func(t *testing.T, b Buffer) {
|
|
cw, ok := b.(writeCloser)
|
|
if !ok {
|
|
t.Skip("CloseWrite not supported")
|
|
}
|
|
|
|
if _, err := b.Write([]byte("hi")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Waiter past final write offset should unblock on close.
|
|
ch := b.WaitUntil(100)
|
|
if err := cw.CloseWrite(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("WaitUntil did not unblock after CloseWrite")
|
|
}
|
|
|
|
// Future waiters also unblock immediately.
|
|
select {
|
|
case <-b.WaitUntil(1 << 20):
|
|
default:
|
|
t.Fatal("WaitUntil after CloseWrite should be already closed")
|
|
}
|
|
|
|
// Data still readable, then EOF.
|
|
p := make([]byte, 8)
|
|
n, err := b.Read(p)
|
|
if err != nil || string(p[:n]) != "hi" {
|
|
t.Fatalf("Read before drain = (%d, %v, %q)", n, err, p[:n])
|
|
}
|
|
n, err = b.Read(p)
|
|
if n != 0 || err != io.EOF {
|
|
t.Fatalf("Read after drain = (%d, %v), want (0, EOF)", n, err)
|
|
}
|
|
_, _, err = b.Peek(p)
|
|
if err != io.EOF {
|
|
t.Fatalf("Peek after drain = %v, want EOF", err)
|
|
}
|
|
|
|
// Writes rejected.
|
|
if _, err := b.Write([]byte("x")); !errors.Is(err, errClosed) {
|
|
t.Fatalf("Write after CloseWrite = %v, want errClosed", err)
|
|
}
|
|
|
|
// Discard past end after close → EOF.
|
|
if _, err := b.DiscardUntil(b.WriteOffset() + 1); err != io.EOF {
|
|
t.Fatalf("DiscardUntil past end after close = %v, want EOF", err)
|
|
}
|
|
|
|
// Double close.
|
|
if err := cw.CloseWrite(); !errors.Is(err, errClosed) {
|
|
t.Fatalf("second CloseWrite = %v, want errClosed", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBufferPeekDiscardCommit(t *testing.T) {
|
|
// Pattern from Buffer docs: peek, use, discard until readOffset+n.
|
|
testBuffers(t, func(t *testing.T, b Buffer) {
|
|
if _, err := b.Write([]byte("abcdef")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
buf := make([]byte, 3)
|
|
ro, n, err := b.Peek(buf)
|
|
if err != nil || ro != 0 || n != 3 || string(buf) != "abc" {
|
|
t.Fatalf("Peek = (%d, %d, %v, %q)", ro, n, err, buf)
|
|
}
|
|
if _, err := b.DiscardUntil(ro + n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ro, n, err = b.Peek(buf)
|
|
if err != nil || ro != 3 || n != 3 || string(buf) != "def" {
|
|
t.Fatalf("second Peek = (%d, %d, %v, %q)", ro, n, err, buf)
|
|
}
|
|
if _, err := b.DiscardUntil(ro + n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if b.Len() != 0 {
|
|
t.Fatalf("Len = %d, want 0", b.Len())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBufferConcurrent(t *testing.T) {
|
|
testBuffers(t, func(t *testing.T, b Buffer) {
|
|
var group sync.WaitGroup
|
|
defer group.Wait()
|
|
|
|
const writers = 4
|
|
const perWriter = 1000
|
|
payload := []byte("x")
|
|
|
|
errc := make(chan error, writers)
|
|
for range writers {
|
|
group.Go(func() {
|
|
for range perWriter {
|
|
if _, err := b.Write(payload); err != nil {
|
|
errc <- err
|
|
return
|
|
}
|
|
}
|
|
errc <- nil
|
|
})
|
|
}
|
|
for range writers {
|
|
if err := <-errc; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
want := int64(writers * perWriter)
|
|
if b.WriteOffset() != want || b.Len() != want {
|
|
t.Fatalf("WriteOffset=%d Len=%d, want %d", b.WriteOffset(), b.Len(), want)
|
|
}
|
|
|
|
// Drain with concurrent Read and DiscardUntil.
|
|
// Monotonic DiscardUntil makes this safe: readers cooperate on one cursor.
|
|
done := make(chan struct{})
|
|
group.Go(func() {
|
|
defer close(done)
|
|
p := make([]byte, 16)
|
|
for b.Len() > 0 {
|
|
if _, err := b.Read(p); err != nil && err != ErrEmpty {
|
|
t.Errorf("Read: %v", err)
|
|
return
|
|
}
|
|
}
|
|
})
|
|
for b.Len() > 0 {
|
|
if _, err := b.DiscardUntil(b.ReadOffset() + 7); err != nil && err != ErrEmpty {
|
|
t.Fatalf("DiscardUntil: %v", err)
|
|
}
|
|
}
|
|
<-done
|
|
if b.Len() != 0 || b.ReadOffset() != b.WriteOffset() {
|
|
t.Fatalf("after concurrent drain: Len=%d ReadOff=%d WriteOff=%d",
|
|
b.Len(), b.ReadOffset(), b.WriteOffset())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestVolatileBufferSteadyStateNoAlloc(t *testing.T) {
|
|
// Sawtooth via public API: write N bytes M times, discard N*M, repeat.
|
|
// After warm-up, a stable peak must not allocate.
|
|
var b VolatileBuffer
|
|
const bytesPerWrite = 1 << 10 // bytes per write
|
|
const writesPerCycle = 64 // writes per cycle
|
|
payload := make([]byte, bytesPerWrite)
|
|
cycle := func() {
|
|
for range writesPerCycle {
|
|
must.Get(b.Write(payload))
|
|
}
|
|
must.Get(b.DiscardUntil(b.WriteOffset()))
|
|
}
|
|
for range 10 {
|
|
cycle()
|
|
}
|
|
if allocs := testing.AllocsPerRun(100, cycle); allocs != 0 {
|
|
t.Fatalf("steady-state allocs/run = %v, want 0 (len=%d cap=%d)", allocs, len(b.buf), cap(b.buf))
|
|
}
|
|
}
|
|
|
|
func TestVolatileBufferTransientShrink(t *testing.T) {
|
|
// Large write then many small reads: backing store should slide (len drops
|
|
// while unread is reclaimed) and eventually shrink cap as peakLen decays.
|
|
var b VolatileBuffer
|
|
const large = 1 << 20 // 1MiB: enough slides for peak to fall below cap/4
|
|
must.Get(b.Write(make([]byte, large)))
|
|
cap0 := cap(b.buf)
|
|
if cap0 < large {
|
|
t.Fatalf("cap after write = %d, want >= %d", cap0, large)
|
|
}
|
|
|
|
small := make([]byte, 256)
|
|
prevCap, prevLen := cap0, len(b.buf)
|
|
var numSlides, numShrinks int
|
|
for b.Len() > 0 {
|
|
must.Get(b.Read(small))
|
|
c, n := cap(b.buf), len(b.buf)
|
|
if c > prevCap {
|
|
t.Fatalf("cap grew during drain: %d → %d", prevCap, c)
|
|
}
|
|
if n < prevLen && c == prevCap {
|
|
numSlides++ // in-place prefix reclaim
|
|
}
|
|
if c < prevCap {
|
|
numShrinks++
|
|
// Shrink replaces the backing array; no dead prefix remains.
|
|
if n != int(b.Len()) {
|
|
t.Fatalf("after shrink: len(buf)=%d, Len()=%d", n, b.Len())
|
|
}
|
|
}
|
|
prevCap, prevLen = c, n
|
|
}
|
|
if numSlides == 0 {
|
|
t.Fatal("expected in-place slides (len drop at constant cap) during drain")
|
|
}
|
|
if numShrinks == 0 {
|
|
t.Fatalf("cap never shrank during small-read drain (start=%d end=%d)", cap0, cap(b.buf))
|
|
}
|
|
if cap(b.buf) >= cap0 {
|
|
t.Fatalf("cap after drain = %d, want < initial %d", cap(b.buf), cap0)
|
|
}
|
|
t.Logf("after large drain: numSlides=%d numShrinks=%d cap=%d", numSlides, numShrinks, cap(b.buf))
|
|
|
|
// Keep writing and reading one byte so peakLen decays and capacity
|
|
// ratchets down until the 4KiB shrink floor.
|
|
one := []byte{0}
|
|
const maxCycles = 1_000_000
|
|
for range maxCycles {
|
|
if cap(b.buf) <= 4<<10 {
|
|
break
|
|
}
|
|
must.Get(b.Write(one))
|
|
must.Get(b.Read(one))
|
|
if c := cap(b.buf); c > prevCap {
|
|
t.Fatalf("cap grew during 1-byte cycles: %d → %d", prevCap, c)
|
|
} else if c < prevCap {
|
|
numShrinks++
|
|
}
|
|
prevCap = cap(b.buf)
|
|
}
|
|
if cap(b.buf) > 4<<10 {
|
|
t.Fatalf("cap = %d after %d 1-byte cycles, want <= 4KiB", cap(b.buf), maxCycles)
|
|
}
|
|
t.Logf("final: numShrinks=%d cap=%d", numShrinks, cap(b.buf))
|
|
}
|