a73x

internal/agent/syncclient/leak_test.go

Ref:   Size: 4.2 KiB   History

package syncclient

import (
	"context"
	"runtime"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/pb"
	"github.com/a73x/eitri/internal/transport"
	"github.com/stretchr/testify/require"
)

// settleGoroutines forces GC and polls until the live goroutine count drops to
// target or the deadline passes, returning the last observed count. It lets any
// per-session goroutines (recv/worker/console-accept + QUIC internals) unwind
// before we sample, so the assertion isn't racing normal teardown.
func settleGoroutines(target int, d time.Duration) int {
	deadline := time.Now().Add(d)
	n := runtime.NumGoroutine()
	for time.Now().Before(deadline) {
		runtime.GC()
		n = runtime.NumGoroutine()
		if n <= target {
			return n
		}
		time.Sleep(20 * time.Millisecond)
	}
	return n
}

// newDropServer runs a minimal QUIC server that, for every connection, completes
// the sync handshake (reads Hello, opens the down-stream, pushes one snapshot so
// the client marks the session connected and exercises a step) and then drops
// the connection — exactly the production reconnect trigger: the session ends
// because the down-stream read errors, NOT because the parent ctx was cancelled.
// It returns the listen addr, the cert fingerprint to pin, and a stop func.
func newDropServer(t *testing.T) (addr, fp string, stop func()) {
	t.Helper()
	var certPEM, keyPEM []byte
	certPEM, keyPEM, fp = genTestCert(t)
	lis := listenTestQUIC(t, "127.0.0.1:0", certPEM, keyPEM)
	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		for {
			conn, err := lis.Accept(ctx)
			if err != nil {
				return
			}
			go func() {
				up, err := conn.AcceptStream(ctx)
				if err != nil {
					return
				}
				var first pb.AgentMessage
				if err := transport.ReadMsg(up, &first, transport.DefaultMaxFrame); err != nil {
					return
				}
				down, err := conn.OpenStreamSync(ctx)
				if err != nil {
					return
				}
				// One snapshot makes the client mark the session connected and run a
				// step; then drop so the client's recv goroutine errors and the
				// session ends the way a real server drop / idle timeout ends it.
				_ = transport.WriteMsg(down, &pb.ServerMessage{
					Msg: &pb.ServerMessage_Snapshot{Snapshot: &pb.Snapshot{}}})
				time.Sleep(30 * time.Millisecond)
				_ = conn.CloseWithError(0, "drop")
			}()
		}
	}()
	return lis.Addr().String(), fp, func() { cancel(); lis.Close() }
}

// TestSessionNoWorkerLeakAcrossReconnects drives many session start/stop cycles
// (each ends by dropping the server, exactly like a production reconnect/deploy)
// and asserts the live goroutine count returns to its post-warmup baseline.
//
// The regression it guards: the up-stream worker goroutine selects only on the
// long-lived parent ctx, a dead stepSignal, and a ticker that session stops on
// return — so when a session ends because the RECV goroutine errored (server
// drop / idle timeout), the worker has no signal to exit and leaks one
// goroutine per reconnect. Over long uptime these accumulate and the server-side
// sync eventually wedges.
func TestSessionNoWorkerLeakAcrossReconnects(t *testing.T) {
	addr, fp, stop := newDropServer(t)
	defer stop()
	c := newClient(t, addr, fp, "host-x", "host-x.deadbeef")

	ctx := t.Context()

	// Each session connects, gets one snapshot, then the server drops it, so
	// session() returns promptly. The parent ctx is never cancelled — precisely
	// the condition under which the worker goroutine cannot observe session end.
	runOne := func() {
		done := make(chan error, 1)
		go func() { done <- c.session(ctx) }()
		select {
		case <-done:
		case <-time.After(5 * time.Second):
			t.Fatal("session did not return after server drop")
		}
	}

	// Warm up to a steady goroutine state before sampling the baseline.
	runOne()
	runOne()
	base := settleGoroutines(0, 3*time.Second) // target 0 => returns settled count

	const cycles = 10
	for range cycles {
		runOne()
	}

	// With the leak, each cycle strands one worker goroutine, so the count sits
	// near base+cycles and never settles back. A small slack absorbs QUIC jitter.
	got := settleGoroutines(base+2, 5*time.Second)
	require.LessOrEqualf(t, got, base+2,
		"goroutine count grew across %d reconnects (base=%d, got=%d): a worker goroutine leaks per session",
		cycles, base, got)
}