Files
tailscale/util/ioqueue/helpers_test.go
Joe Tsai 1a9090f14e util/ioqueue: add helper functions
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>
2026-08-14 12:35:33 -07:00

475 lines
11 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package ioqueue
import (
"bytes"
"context"
"encoding/binary"
"errors"
"io"
"strings"
"testing"
"time"
"tailscale.com/util/must"
)
func TestWaitLengthNonEmpty(t *testing.T) {
var b VolatileBuffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
WaitLength(ctx, &b, 0, 0)
}()
// Should not return while empty.
select {
case <-done:
t.Fatal("WaitLength returned on empty buffer")
case <-time.After(20 * time.Millisecond):
}
must.Get(b.Write([]byte("hi")))
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("WaitLength did not return after Write")
}
}
func TestWaitLengthCancel(t *testing.T) {
var b VolatileBuffer
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
WaitLength(ctx, &b, 0, 0)
}()
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("WaitLength did not return after cancel")
}
}
func TestWaitLengthBytes(t *testing.T) {
var b VolatileBuffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
WaitLength(ctx, &b, 10, 0)
}()
must.Get(b.Write([]byte("12345"))) // still short of 10
select {
case <-done:
t.Fatal("WaitLength returned before lengthBytes available")
case <-time.After(20 * time.Millisecond):
}
must.Get(b.Write([]byte("67890"))) // now 10 bytes; WaitUntil needs WriteOffset > ReadOffset+10
// WaitUntil(ReadOffset+10) fires when WriteOffset > ReadOffset+10, i.e. Len > 10.
// With Len==10, still waiting. Add one more byte.
must.Get(b.Write([]byte("!")))
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("WaitLength did not return after lengthBytes available")
}
if b.Len() <= 10 {
t.Fatalf("Len=%d, want > 10", b.Len())
}
}
func TestWaitLengthBatchDelay(t *testing.T) {
var b VolatileBuffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
const delay = 50 * time.Millisecond
start := time.Now()
done := make(chan struct{})
go func() {
defer close(done)
WaitLength(ctx, &b, 0, delay)
}()
must.Get(b.Write([]byte("x")))
select {
case <-done:
if d := time.Since(start); d < delay {
t.Fatalf("returned after %v, want >= %v batchDelay", d, delay)
}
case <-time.After(time.Second):
t.Fatal("WaitLength did not return after batchDelay")
}
}
func TestWaitLengthCloseWrite(t *testing.T) {
var b VolatileBuffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
WaitLength(ctx, &b, 0, 0)
}()
must.Do(b.CloseWrite())
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("WaitLength did not return after CloseWrite")
}
}
func newlineFrameLen(p []byte) (int, error) {
i := bytes.IndexByte(p, '\n')
if i < 0 {
return 0, nil
}
return i + 1, nil
}
func TestDiscardOversizeInvalidMaxSize(t *testing.T) {
var b VolatileBuffer
_, err := DiscardOversize(context.Background(), &b, 0, nil)
if err == nil {
t.Fatal("expected error for maxSize <= 0")
}
}
func TestDiscardOversizeUnframed(t *testing.T) {
var b VolatileBuffer
must.Get(b.Write(bytes.Repeat([]byte("x"), 100)))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
var discarded int64
go func() {
defer close(done)
var err error
discarded, err = DiscardOversize(ctx, &b, 40, nil)
if err != nil {
t.Errorf("DiscardOversize: %v", err)
}
}()
deadline := time.After(time.Second)
for b.Len() > 40 {
select {
case <-deadline:
t.Fatalf("Len=%d, want <= 40", b.Len())
default:
time.Sleep(time.Millisecond)
}
}
cancel()
<-done
if b.Len() > 40 {
t.Fatalf("Len=%d, want <= 40", b.Len())
}
if discarded < 60 {
t.Fatalf("discarded=%d, want >= 60", discarded)
}
}
func TestDiscardOversizeFramed(t *testing.T) {
var b VolatileBuffer
// Three newline-delimited frames (5 bytes each); maxSize 10 keeps two frames.
must.Get(b.Write([]byte("aaaa\nbbbb\ncccc\n")))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
_, err := DiscardOversize(ctx, &b, 10, newlineFrameLen)
if err != nil {
t.Errorf("DiscardOversize: %v", err)
}
}()
deadline := time.After(time.Second)
for b.Len() > 10 {
select {
case <-deadline:
t.Fatalf("Len=%d, want <= 10", b.Len())
default:
time.Sleep(time.Millisecond)
}
}
cancel()
<-done
got := make([]byte, int(b.Len()))
_, pn, err := b.Peek(got)
if err != nil {
t.Fatal(err)
}
if string(got[:pn]) != "bbbb\ncccc\n" {
t.Fatalf("remaining = %q, want %q", got[:pn], "bbbb\ncccc\n")
}
}
func TestDiscardOversizeFramedMultiFramePeek(t *testing.T) {
// Many small frames should be drained from one Peek, not one Peek per frame.
var inner VolatileBuffer
var frames strings.Builder
const n = 200
for range n {
frames.WriteString("x\n")
}
must.Get(inner.Write([]byte(frames.String())))
pb := &peekCountingBuffer{Buffer: &inner}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
_, err := DiscardOversize(ctx, pb, 4, newlineFrameLen)
if err != nil {
t.Errorf("DiscardOversize: %v", err)
}
}()
deadline := time.After(time.Second)
for pb.Len() > 4 {
select {
case <-deadline:
t.Fatalf("Len=%d, want <= 4", pb.Len())
default:
time.Sleep(time.Millisecond)
}
}
cancel()
<-done
if pb.peeks != 1 {
t.Fatalf("peeks = %d, want 1 for multi-frame discard", pb.peeks)
}
if pb.Len() != 4 {
t.Fatalf("Len = %d, want 4", pb.Len())
}
}
type peekCountingBuffer struct {
Buffer
peeks int
}
func (p *peekCountingBuffer) Peek(b []byte) (int64, int64, error) {
p.peeks++
return p.Buffer.Peek(b)
}
func TestDiscardOversizeFrameTooLong(t *testing.T) {
var b VolatileBuffer
must.Get(b.Write([]byte("hello")))
frameLen := func([]byte) (int, error) { return 100, nil } // claims more than Len
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := DiscardOversize(ctx, &b, 1, frameLen)
if !errors.Is(err, ErrFrameLength) {
t.Fatalf("err = %v, want ErrFrameLength", err)
}
}
func TestDiscardOversizeIndeterminateFrame(t *testing.T) {
var b VolatileBuffer
must.Get(b.Write([]byte("no newline here")))
frameLen := func([]byte) (int, error) { return 0, nil } // never resolves
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_, err := DiscardOversize(ctx, &b, 1, frameLen)
if !errors.Is(err, ErrFrameLength) {
t.Fatalf("err = %v, want ErrFrameLength", err)
}
}
func TestDiscardOversizeLengthPrefixedCorruption(t *testing.T) {
// Length-prefixed framing cannot resynchronize after a bad length:
// a small corruption in the prefix can land the reader in the middle
// of a later frame. Delimiter-based formats (newlines, COBS nulls)
// recover at the next delimiter; a length prefix cannot.
// frameLen must reject implausible lengths as a hard error rather
// than trusting them and desynchronizing the rest of the stream.
const maxFrameSize = 8
var payload []byte
for _, s := range []string{"aaaa", "bbbb", "cccc", "dddd"} {
payload = binary.AppendUvarint(payload, uint64(len(s)))
payload = append(payload, s...)
}
// First uvarint was 4. Corrupt it to 12: larger than maxFrameSize,
// but 1+12 is still within the buffer, so a naive n+int(length)
// would discard into the middle of a subsequent frame.
payload[0] = 12
frameLen := func(b []byte) (int, error) {
length, n := binary.Uvarint(b)
if n < 0 || length > maxFrameSize {
return 0, ErrFrameLength
}
return n + int(length), nil
}
var buf VolatileBuffer
must.Get(buf.Write(payload))
wantLen := buf.Len()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
n, err := DiscardOversize(ctx, &buf, 1, frameLen)
if !errors.Is(err, ErrFrameLength) {
t.Fatalf("err = %v, want ErrFrameLength", err)
}
if n != 0 {
t.Fatalf("discarded %d bytes, want 0 (must not skip on a corrupted length)", n)
}
if buf.Len() != wantLen {
t.Fatalf("Len=%d, want %d (buffer must stay intact)", buf.Len(), wantLen)
}
}
func TestDiscardOversizeCloseWrite(t *testing.T) {
var b VolatileBuffer
must.Get(b.Write(bytes.Repeat([]byte("x"), 10)))
// Under maxSize; close write and ensure DiscardOversize returns.
must.Do(b.CloseWrite())
n, err := DiscardOversize(context.Background(), &b, 100, nil)
if err != nil || n != 0 {
t.Fatalf("DiscardOversize = (%d, %v), want (0, nil)", n, err)
}
}
func TestStreamReaderBasic(t *testing.T) {
var b VolatileBuffer
must.Get(b.Write([]byte("hello")))
must.Do(b.CloseWrite())
r := StreamReader(context.Background(), &b)
got, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("ReadAll = %q, want hello", got)
}
}
func TestStreamReaderBlocksUntilWrite(t *testing.T) {
var b VolatileBuffer
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r := StreamReader(ctx, &b)
type result struct {
n int
err error
buf []byte
}
ch := make(chan result, 1)
go func() {
p := make([]byte, 8)
n, err := r.Read(p)
ch <- result{n, err, p[:n]}
}()
select {
case <-ch:
t.Fatal("Read returned before Write")
case <-time.After(20 * time.Millisecond):
}
must.Get(b.Write([]byte("xy")))
select {
case res := <-ch:
if res.err != nil || string(res.buf) != "xy" {
t.Fatalf("Read = (%q, %v), want (xy, nil)", res.buf, res.err)
}
case <-time.After(time.Second):
t.Fatal("Read did not return after Write")
}
}
func TestStreamReaderCloseWriteEOF(t *testing.T) {
var b VolatileBuffer
must.Get(b.Write([]byte("ab")))
r := StreamReader(context.Background(), &b)
p := make([]byte, 8)
n, err := r.Read(p)
if err != nil || string(p[:n]) != "ab" {
t.Fatalf("Read = (%d, %v, %q)", n, err, p[:n])
}
must.Do(b.CloseWrite())
n, err = r.Read(p)
if n != 0 || !errors.Is(err, io.EOF) {
t.Fatalf("Read after CloseWrite = (%d, %v), want (0, EOF)", n, err)
}
}
func TestStreamReaderCancelMapsToEOF(t *testing.T) {
var b VolatileBuffer
ctx, cancel := context.WithCancel(context.Background())
r := StreamReader(ctx, &b)
done := make(chan error, 1)
go func() {
_, err := r.Read(make([]byte, 4))
done <- err
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, io.EOF) {
t.Fatalf("err = %v, want EOF", err)
}
case <-time.After(time.Second):
t.Fatal("Read did not return after cancel")
}
}
func TestStreamReaderDeadline(t *testing.T) {
var b VolatileBuffer
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
r := StreamReader(ctx, &b)
_, err := r.Read(make([]byte, 4))
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want DeadlineExceeded", err)
}
}
func TestStreamReaderZeroLength(t *testing.T) {
var b VolatileBuffer
r := StreamReader(context.Background(), &b)
n, err := r.Read(nil)
if n != 0 || err != nil {
t.Fatalf("Read(nil) = (%d, %v), want (0, nil)", n, err)
}
}