erste module drin.

zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
br committed 2026-10-01 22:22:13 +02:00
1 parent 9d5789c5d7
commit c8df9d7651
41 files changed
+6455 -36

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
package stream
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"time"
)
var (
// ErrUnexpectedResponse: Der Server antwortet nicht mit 200 und text/event-stream.
ErrUnexpectedResponse = errors.New("stream: unerwartete Server-Antwort")
// ErrIdleTimeout: Innerhalb von IdleTimeout kam nichts an, auch kein Keepalive.
ErrIdleTimeout = errors.New("stream: Server schweigt (Idle-Timeout)")
// ErrStreamClosed: Der Server hat den Stream beendet.
ErrStreamClosed = errors.New("stream: vom Server beendet")
)
// Client ist die Agent-Seite des Streams.
type Client struct {
// URL des Stream-Endpunkts.
URL string
// HTTPClient für die Verbindung (Default http.DefaultClient). Er darf
// kein Timeout gesetzt haben, sonst bricht er den langlebigen Stream ab;
// die Erkennung toter Verbindungen übernimmt IdleTimeout.
HTTPClient *http.Client
// Prepare wird vor jedem Verbindungsaufbau mit dem Request aufgerufen,
// z. B. um ihn zu signieren.
Prepare func(*http.Request) error
// IdleTimeout: Kommt so lange gar nichts an, gilt die Verbindung als tot
// (Default 90s = 2 × Server-Keepalive).
IdleTimeout time.Duration
// MinBackoff und MaxBackoff begrenzen die Wartezeit zwischen
// Verbindungsversuchen in Run (Default 1s bzw. 1min).
MinBackoff, MaxBackoff time.Duration
// OnError wird in Run vor jeder Wartezeit mit dem Grund aufgerufen.
OnError func(err error, retryIn time.Duration)
sleep func(ctx context.Context, d time.Duration) error // für Tests ersetzbar
}
func (c *Client) idleTimeout() time.Duration { return orDefault(c.IdleTimeout, 90*time.Second) }
func (c *Client) minBackoff() time.Duration { return orDefault(c.MinBackoff, time.Second) }
func (c *Client) maxBackoff() time.Duration { return orDefault(c.MaxBackoff, time.Minute) }
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return http.DefaultClient
}
// handlerError markiert Fehler des Handlers, damit Run sie nicht als
// Verbindungsproblem behandelt.
type handlerError struct{ err error }
func (e *handlerError) Error() string { return e.err.Error() }
func (e *handlerError) Unwrap() error { return e.err }
// Connect baut genau eine Verbindung auf und ruft handle für jedes Event auf,
// bis die Verbindung endet. Gibt handle einen Fehler zurück, endet Connect
// mit diesem Fehler.
func (c *Client) Connect(ctx context.Context, handle func(Event) error) error {
_, err := c.connect(ctx, handle)
var he *handlerError
if errors.As(err, &he) {
return he.err
}
return err
}
// connect meldet zusätzlich, ob der Stream zustande kam (für das
// Zurücksetzen des Backoffs).
func (c *Client) connect(ctx context.Context, handle func(Event) error) (connected bool, err error) {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.URL, nil)
if err != nil {
return false, err
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
if c.Prepare != nil {
if err := c.Prepare(req); err != nil {
return false, err
}
}
resp, err := c.httpClient().Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("%w: Status %d", ErrUnexpectedResponse, resp.StatusCode)
}
if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt != "text/event-stream" {
return false, fmt.Errorf("%w: Content-Type %q", ErrUnexpectedResponse, resp.Header.Get("Content-Type"))
}
idle := c.idleTimeout()
timer := time.AfterFunc(idle, func() { cancel(ErrIdleTimeout) })
defer timer.Stop()
dec := NewDecoder(&activityReader{r: resp.Body, timer: timer, idle: idle})
for {
ev, err := dec.Next()
if err != nil {
if ctx.Err() != nil {
return true, context.Cause(ctx)
}
if errors.Is(err, io.EOF) {
return true, ErrStreamClosed
}
return true, err
}
if err := handle(ev); err != nil {
return true, &handlerError{err}
}
}
}
// activityReader setzt den Idle-Timer bei jedem empfangenen Byte zurück –
// Keepalives zählen also mit.
type activityReader struct {
r io.Reader
timer *time.Timer
idle time.Duration
}
func (a *activityReader) Read(p []byte) (int, error) {
n, err := a.r.Read(p)
if n > 0 {
a.timer.Reset(a.idle)
}
return n, err
}
// Run hält die Verbindung dauerhaft aufrecht: Nach jedem Verbindungsende
// wird mit exponentiellem Backoff neu verbunden; nach einer erfolgreich
// aufgebauten Verbindung beginnt das Backoff wieder bei MinBackoff. Run endet
// nur, wenn ctx endet (ctx.Err()) oder handle einen Fehler liefert.
func (c *Client) Run(ctx context.Context, handle func(Event) error) error {
sleep := c.sleep
if sleep == nil {
sleep = sleepCtx
}
attempt := 0
for {
connected, err := c.connect(ctx, handle)
if ctx.Err() != nil {
return ctx.Err()
}
var he *handlerError
if errors.As(err, &he) {
return he.err
}
if connected {
attempt = 0
}
d := backoff(attempt, c.minBackoff(), c.maxBackoff())
attempt++
if c.OnError != nil {
c.OnError(err, d)
}
if err := sleep(ctx, d); err != nil {
return err
}
}
}
// backoff liefert minD · 2^attempt, höchstens maxD.
func backoff(attempt int, minD, maxD time.Duration) time.Duration {
d := minD
for range attempt {
if d >= maxD/2 {
return maxD
}
d *= 2
}
return min(d, maxD)
}
func sleepCtx(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
+333
View File
@@ -0,0 +1,333 @@
package stream
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestBackoff(t *testing.T) {
const minD, maxD = time.Second, 60 * time.Second
tests := map[int]time.Duration{
0: time.Second,
1: 2 * time.Second,
5: 32 * time.Second,
6: 60 * time.Second,
100: 60 * time.Second,
}
for attempt, want := range tests {
if got := backoff(attempt, minD, maxD); got != want {
t.Errorf("backoff(%d) = %v, erwartet %v", attempt, got, want)
}
}
if got := backoff(3, 5*time.Second, 5*time.Second); got != 5*time.Second {
t.Errorf("min == max: %v", got)
}
}
func TestClient_Defaults(t *testing.T) {
var c Client
if got := c.idleTimeout(); got != 90*time.Second {
t.Errorf("IdleTimeout-Default = %v, erwartet 90s (2 × Keepalive)", got)
}
if got := c.minBackoff(); got != time.Second {
t.Errorf("MinBackoff-Default = %v", got)
}
if got := c.maxBackoff(); got != time.Minute {
t.Errorf("MaxBackoff-Default = %v", got)
}
if c.httpClient() != http.DefaultClient {
t.Error("HTTPClient-Default sollte http.DefaultClient sein")
}
}
// Ende-zu-Ende: Client verbindet sich über Run mit einem Hub, Prepare setzt
// Authentifizierung, ein vom Hub gesendetes Event kommt beim Handler an.
func TestClient_ReceivesEvents(t *testing.T) {
h := &Hub{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer geheim" {
http.Error(w, "nein", http.StatusUnauthorized)
return
}
if r.Header.Get("Accept") != "text/event-stream" {
http.Error(w, "falscher Accept", http.StatusNotAcceptable)
return
}
h.Serve(w, r, "node-1")
}))
t.Cleanup(srv.Close)
t.Cleanup(h.Close)
c := &Client{
URL: srv.URL,
Prepare: func(r *http.Request) error { r.Header.Set("Authorization", "Bearer geheim"); return nil },
}
got := make(chan Event, 1)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- c.Run(ctx, func(ev Event) error { got <- ev; return nil }) }()
eventually(t, "Client verbunden", func() bool { return h.Connected("node-1") })
if err := h.Send("node-1", Event{Type: EventNodeCommand}); err != nil {
t.Fatal(err)
}
select {
case ev := <-got:
if ev.Type != EventNodeCommand || ev.NodeID != "node-1" {
t.Errorf("unerwartetes Event: %+v", ev)
}
case <-time.After(2 * time.Second):
t.Fatal("kein Event empfangen")
}
cancel()
if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
t.Errorf("Run: erwartet context.Canceled, bekommen %v", err)
}
}
func TestClient_UnexpectedResponse(t *testing.T) {
tests := map[string]http.HandlerFunc{
"Status 401": func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nein", http.StatusUnauthorized)
},
"falscher Content-Type": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("hallo"))
},
}
for name, handler := range tests {
t.Run(name, func(t *testing.T) {
srv := httptest.NewServer(handler)
defer srv.Close()
c := &Client{URL: srv.URL}
err := c.Connect(context.Background(), func(Event) error { return nil })
if !errors.Is(err, ErrUnexpectedResponse) {
t.Errorf("erwartet ErrUnexpectedResponse, bekommen %v", err)
}
})
}
}
// sseHandler sendet nur die Header und hält die Verbindung ohne Daten offen.
func silentSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
<-r.Context().Done()
}
func TestClient_IdleTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(silentSSE))
defer srv.Close()
c := &Client{URL: srv.URL, IdleTimeout: 50 * time.Millisecond}
start := time.Now()
err := c.Connect(context.Background(), func(Event) error { return nil })
if !errors.Is(err, ErrIdleTimeout) {
t.Fatalf("erwartet ErrIdleTimeout, bekommen %v", err)
}
if d := time.Since(start); d > time.Second {
t.Errorf("Timeout griff erst nach %v", d)
}
}
func TestClient_KeepAlivePreventsIdleTimeout(t *testing.T) {
h := &Hub{KeepAlive: 10 * time.Millisecond}
srv, _ := hubServer(t, h)
c := &Client{URL: srv.URL + "?node=n", IdleTimeout: 60 * time.Millisecond}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- c.Connect(ctx, func(Event) error { return nil }) }()
select {
case err := <-done:
t.Fatalf("Verbindung trotz Keepalives beendet: %v", err)
case <-time.After(300 * time.Millisecond):
}
cancel()
if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
t.Errorf("erwartet context.Canceled, bekommen %v", err)
}
}
func TestClient_ServerClosesStream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Write([]byte("data: " + evJSON + "\n\n"))
}))
defer srv.Close()
var got []Event
err := (&Client{URL: srv.URL}).Connect(context.Background(), func(ev Event) error {
got = append(got, ev)
return nil
})
if !errors.Is(err, ErrStreamClosed) {
t.Errorf("erwartet ErrStreamClosed, bekommen %v", err)
}
if !reflect.DeepEqual(got, []Event{evWant}) {
t.Errorf("Events: %+v", got)
}
}
func TestClient_PrepareError(t *testing.T) {
errSign := errors.New("Signatur fehlgeschlagen")
c := &Client{URL: "http://127.0.0.1:1", Prepare: func(*http.Request) error { return errSign }}
if err := c.Connect(context.Background(), nil); !errors.Is(err, errSign) {
t.Errorf("erwartet Prepare-Fehler, bekommen %v", err)
}
}
func TestClient_ConnectionRefused(t *testing.T) {
srv := httptest.NewServer(http.NotFoundHandler())
url := srv.URL
srv.Close()
if err := (&Client{URL: url}).Connect(context.Background(), nil); err == nil {
t.Error("erwartet Verbindungsfehler")
}
}
func TestClient_HandlerErrorStopsRun(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: " + evJSON + "\n\n"))
}))
defer srv.Close()
errStop := errors.New("Handler bricht ab")
err := (&Client{URL: srv.URL}).Run(context.Background(), func(Event) error { return errStop })
if !errors.Is(err, errStop) {
t.Errorf("erwartet Handler-Fehler, bekommen %v", err)
}
if n := requests.Load(); n != 1 {
t.Errorf("%d Verbindungsversuche, erwartet 1 (kein Reconnect nach Handler-Fehler)", n)
}
}
// Ablauf: 503, 503, erfolgreicher Stream (endet), danach nur noch 503.
// Erwartet: exponentielles Backoff, Reset nach erfolgreicher Verbindung,
// Deckelung bei MaxBackoff.
func TestClient_RunRetriesWithBackoff(t *testing.T) {
var requests atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1) == 3 {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: " + evJSON + "\n\n"))
return
}
http.Error(w, "später", http.StatusServiceUnavailable)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var mu sync.Mutex
var delays []time.Duration
var reported int
var events atomic.Int32
c := &Client{
URL: srv.URL,
MinBackoff: 10 * time.Millisecond,
MaxBackoff: 40 * time.Millisecond,
OnError: func(err error, retryIn time.Duration) {
mu.Lock()
defer mu.Unlock()
if err == nil {
t.Error("OnError ohne Fehler aufgerufen")
}
reported++
},
sleep: func(ctx context.Context, d time.Duration) error {
mu.Lock()
defer mu.Unlock()
delays = append(delays, d)
if len(delays) == 6 {
cancel()
return ctx.Err()
}
return nil
},
}
err := c.Run(ctx, func(Event) error { events.Add(1); return nil })
if !errors.Is(err, context.Canceled) {
t.Fatalf("erwartet context.Canceled, bekommen %v", err)
}
ms := time.Millisecond
want := []time.Duration{10 * ms, 20 * ms, 10 * ms, 20 * ms, 40 * ms, 40 * ms}
mu.Lock()
defer mu.Unlock()
if !reflect.DeepEqual(delays, want) {
t.Errorf("Backoff-Folge = %v, erwartet %v", delays, want)
}
if reported != 6 {
t.Errorf("OnError %d-mal aufgerufen, erwartet 6", reported)
}
if events.Load() != 1 {
t.Errorf("%d Events empfangen, erwartet 1", events.Load())
}
}
func TestClient_ConnectReturnsHandlerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: " + evJSON + "\n\n"))
}))
defer srv.Close()
errStop := errors.New("stopp")
err := (&Client{URL: srv.URL}).Connect(context.Background(), func(Event) error { return errStop })
if err != errStop {
t.Errorf("erwartet den unverpackten Handler-Fehler, bekommen %v", err)
}
}
func TestClient_CustomHTTPClient(t *testing.T) {
var used atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
}))
defer srv.Close()
hc := srv.Client()
base := hc.Transport
hc.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
used.Store(true)
return base.RoundTrip(r)
})
err := (&Client{URL: srv.URL, HTTPClient: hc}).Connect(context.Background(), nil)
if !errors.Is(err, ErrStreamClosed) {
t.Errorf("erwartet ErrStreamClosed, bekommen %v", err)
}
if !used.Load() {
t.Error("eigener HTTPClient wurde nicht verwendet")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestSleepCtx(t *testing.T) {
if err := sleepCtx(context.Background(), time.Millisecond); err != nil {
t.Errorf("abgelaufener Timer: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
if err := sleepCtx(ctx, time.Hour); !errors.Is(err, context.Canceled) {
t.Errorf("erwartet context.Canceled, bekommen %v", err)
}
if time.Since(start) > time.Second {
t.Error("sleepCtx hat trotz Abbruch gewartet")
}
}
+80
View File
@@ -0,0 +1,80 @@
package stream
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
)
// MaxEventSize begrenzt die Datenmenge eines einzelnen Events.
const MaxEventSize = 1 << 20
// ErrEventTooLarge meldet ein Event über MaxEventSize.
var ErrEventTooLarge = errors.New("stream: Event zu groß")
// Decoder liest Events aus einem SSE-Stream. Kommentarzeilen (Keepalives)
// und unbekannte Felder werden übersprungen; maßgeblich ist allein das JSON
// in den data-Zeilen.
type Decoder struct {
sc *bufio.Scanner
}
// NewDecoder liest aus r.
func NewDecoder(r io.Reader) *Decoder {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 4096), MaxEventSize+1024)
return &Decoder{sc: sc}
}
// Next liefert das nächste Event. Am sauberen Stream-Ende kommt io.EOF, endet
// der Stream mitten in einem Event io.ErrUnexpectedEOF.
func (d *Decoder) Next() (Event, error) {
var data strings.Builder
pending := false
for d.sc.Scan() {
line := d.sc.Text() // ScanLines entfernt auch ein \r vor dem \n
switch {
case line == "":
if !pending {
continue
}
return parseEvent(data.String())
case strings.HasPrefix(line, ":"):
// Kommentar / Keepalive
case strings.HasPrefix(line, "data:"):
v := strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")
if pending {
data.WriteByte('\n')
}
data.WriteString(v)
pending = true
if data.Len() > MaxEventSize {
return Event{}, ErrEventTooLarge
}
}
}
if err := d.sc.Err(); err != nil {
if errors.Is(err, bufio.ErrTooLong) {
return Event{}, ErrEventTooLarge
}
return Event{}, err
}
if pending {
return Event{}, io.ErrUnexpectedEOF
}
return Event{}, io.EOF
}
func parseEvent(data string) (Event, error) {
var ev Event
if err := json.Unmarshal([]byte(data), &ev); err != nil {
return Event{}, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
}
if ev.Type == "" {
return Event{}, fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
}
return ev, nil
}
+110
View File
@@ -0,0 +1,110 @@
package stream
import (
"encoding/json"
"errors"
"io"
"reflect"
"strings"
"testing"
)
const evJSON = `{"event_id":"evt_1","timestamp":1,"type":"node_command","node_id":"n1","payload":{"action":"drain"}}`
var evWant = Event{
ID: "evt_1",
Timestamp: 1,
Type: EventNodeCommand,
NodeID: "n1",
Payload: json.RawMessage(`{"action":"drain"}`),
}
func decodeAll(t *testing.T, input string) ([]Event, error) {
t.Helper()
dec := NewDecoder(strings.NewReader(input))
var out []Event
for {
ev, err := dec.Next()
if err != nil {
return out, err
}
out = append(out, ev)
}
}
func TestDecoder(t *testing.T) {
tests := []struct {
name string
input string
want []Event
}{
{"einzelnes Event", "id: evt_1\nevent: node_command\ndata: " + evJSON + "\n\n", []Event{evWant}},
{"Keepalives werden übersprungen", ":\n\n:\n\ndata: " + evJSON + "\n\n:\n\n", []Event{evWant}},
{"CRLF-Zeilenenden", "id: evt_1\r\ndata: " + evJSON + "\r\n\r\n", []Event{evWant}},
{"data ohne Leerzeichen", "data:" + evJSON + "\n\n", []Event{evWant}},
{
"mehrere data-Zeilen",
"data: {\"event_id\":\"evt_1\",\"timestamp\":1,\ndata: \"type\":\"node_command\",\"node_id\":\"n1\",\ndata: \"payload\":{\"action\":\"drain\"}}\n\n",
[]Event{evWant},
},
{"unbekannte Felder ignoriert", "retry: 1000\nfoo: bar\ndata: " + evJSON + "\n\n", []Event{evWant}},
{"Leerzeilen ohne Daten", "\n\n\nevent: x\n\n", nil},
{"zwei Events", "data: " + evJSON + "\n\ndata: " + evJSON + "\n\n", []Event{evWant, evWant}},
{"leerer Stream", "", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := decodeAll(t, tt.input)
if !errors.Is(err, io.EOF) {
t.Fatalf("erwartet io.EOF am Ende, bekommen %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Events weichen ab\n got: %+v\nwant: %+v", got, tt.want)
}
})
}
}
func TestDecoder_Errors(t *testing.T) {
tests := []struct {
name string
input string
want error
}{
{"ungültiges JSON", "data: {kaputt\n\n", ErrInvalidEvent},
{"Event ohne Typ", "data: {\"event_id\":\"evt_1\"}\n\n", ErrInvalidEvent},
{"abgeschnittenes Event", "data: " + evJSON + "\n", io.ErrUnexpectedEOF},
{"Zeile zu lang", "data: " + strings.Repeat("x", MaxEventSize+1) + "\n\n", ErrEventTooLarge},
{"Summe der data-Zeilen zu lang", strings.Repeat("data: "+strings.Repeat("x", MaxEventSize/4)+"\n", 5) + "\n", ErrEventTooLarge},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := decodeAll(t, tt.input)
if !errors.Is(err, tt.want) {
t.Errorf("erwartet %v, bekommen %v", tt.want, err)
}
})
}
}
func TestEncodeDecodeRoundTrip(t *testing.T) {
ev, err := NewEvent(EventServiceState, map[string]any{"labels": map[string]string{"traefik.enable": "true"}})
if err != nil {
t.Fatal(err)
}
ev.NodeID = "n1"
frame, err := encode(ev)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(frame), "id: "+ev.ID+"\nevent: service_state\ndata: ") || !strings.HasSuffix(string(frame), "\n\n") {
t.Errorf("unerwarteter SSE-Rahmen: %q", frame)
}
got, err := NewDecoder(strings.NewReader(string(frame))).Next()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, ev) {
t.Errorf("Round-Trip weicht ab\n got: %+v\nwant: %+v", got, ev)
}
}
+78
View File
@@ -0,0 +1,78 @@
// Package stream implementiert den langlebigen Kanal vom Sultan-Server zum
// Agenten als Server-Sent Events (SSE) über eine ausgehende HTTP-Verbindung
// des Agenten.
//
// Der Server (Hub) hält pro Knoten einen offenen Stream, schickt Events sofort
// und sendet in Ruhephasen alle 45s die SSE-Kommentarzeile ":\n\n" als
// Keepalive, damit NAT-/Conntrack-Einträge nicht verfallen. Der Agent (Client)
// liest den Stream, erkennt einen toten Server, wenn zu lange gar nichts
// ankommt (auch kein Keepalive), und verbindet sich mit exponentiellem
// Backoff neu.
//
// Die Gegenrichtung (Agent -> Server, z. B. service_state) ist nicht Teil
// dieses Streams; sie läuft über eigene Requests.
package stream
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
)
// EventType ist der Typ eines Events gemäß Spec Abschnitt 4.2.
type EventType string
const (
EventServiceState EventType = "service_state"
EventLeaseRenew EventType = "lease_renew"
EventNodeCommand EventType = "node_command"
EventError EventType = "error"
)
// ErrInvalidEvent meldet ein Event, das sich nicht senden oder lesen lässt.
var ErrInvalidEvent = errors.New("stream: ungültiges Event")
// Event ist die Nachricht auf dem Stream.
type Event struct {
ID string `json:"event_id"`
Timestamp int64 `json:"timestamp"`
Type EventType `json:"type"`
NodeID string `json:"node_id"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewEvent erzeugt ein Event mit neuer ID, aktuellem Zeitstempel und der als
// JSON serialisierten payload (nil ergibt keine Payload). NodeID setzt der Hub
// beim Senden.
func NewEvent(t EventType, payload any) (Event, error) {
if t == "" {
return Event{}, fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
}
ev := Event{ID: newEventID(), Timestamp: time.Now().Unix(), Type: t}
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return Event{}, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
}
ev.Payload = b
}
return ev, nil
}
func newEventID() string {
var b [16]byte
rand.Read(b[:])
return "evt_" + hex.EncodeToString(b[:])
}
// encode erzeugt den SSE-Rahmen für ev.
func encode(ev Event) ([]byte, error) {
data, err := json.Marshal(ev)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
}
return fmt.Appendf(nil, "id: %s\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data), nil
}
+84
View File
@@ -0,0 +1,84 @@
package stream
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
func TestNewEvent(t *testing.T) {
before := time.Now().Unix()
ev, err := NewEvent(EventNodeCommand, map[string]string{"action": "drain"})
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if !strings.HasPrefix(ev.ID, "evt_") || len(ev.ID) <= len("evt_") {
t.Errorf("ID = %q, erwartet Präfix evt_", ev.ID)
}
if ev.Timestamp < before || ev.Timestamp > time.Now().Unix() {
t.Errorf("Timestamp = %d liegt nicht im Testzeitraum", ev.Timestamp)
}
if ev.Type != EventNodeCommand {
t.Errorf("Type = %q", ev.Type)
}
if ev.NodeID != "" {
t.Errorf("NodeID = %q, erwartet leer (setzt der Hub)", ev.NodeID)
}
if string(ev.Payload) != `{"action":"drain"}` {
t.Errorf("Payload = %s", ev.Payload)
}
}
func TestNewEvent_NilPayload(t *testing.T) {
ev, err := NewEvent(EventLeaseRenew, nil)
if err != nil {
t.Fatalf("unerwarteter Fehler: %v", err)
}
if ev.Payload != nil {
t.Errorf("Payload = %s, erwartet nil", ev.Payload)
}
}
func TestNewEvent_Errors(t *testing.T) {
if _, err := NewEvent("", nil); !errors.Is(err, ErrInvalidEvent) {
t.Errorf("leerer Typ: erwartet ErrInvalidEvent, bekommen %v", err)
}
if _, err := NewEvent(EventError, make(chan int)); !errors.Is(err, ErrInvalidEvent) {
t.Errorf("nicht serialisierbare Payload: erwartet ErrInvalidEvent, bekommen %v", err)
}
}
func TestNewEvent_UniqueIDs(t *testing.T) {
seen := map[string]bool{}
for range 1000 {
ev, err := NewEvent(EventError, nil)
if err != nil {
t.Fatal(err)
}
if seen[ev.ID] {
t.Fatalf("doppelte ID %q", ev.ID)
}
seen[ev.ID] = true
}
}
// Feldnamen gemäß Spec Abschnitt 4.2.
func TestEvent_JSONFieldNames(t *testing.T) {
ev := Event{
ID: "evt_1",
Timestamp: 1727618531,
Type: EventServiceState,
NodeID: "node-proxmox-vm-42",
Payload: json.RawMessage(`{"a":1}`),
}
b, err := json.Marshal(ev)
if err != nil {
t.Fatal(err)
}
want := `{"event_id":"evt_1","timestamp":1727618531,"type":"service_state","node_id":"node-proxmox-vm-42","payload":{"a":1}}`
if string(b) != want {
t.Errorf("JSON weicht ab\n got: %s\nwant: %s", b, want)
}
}
+271
View File
@@ -0,0 +1,271 @@
package stream
import (
"errors"
"fmt"
"net/http"
"slices"
"sync"
"time"
)
var (
// ErrNodeOffline: Für den Knoten ist kein Stream offen.
ErrNodeOffline = errors.New("stream: Knoten nicht verbunden")
// ErrBufferFull: Der Knoten liest nicht schnell genug; das Event wurde verworfen.
ErrBufferFull = errors.New("stream: Sendepuffer voll")
// ErrHubClosed: Der Hub wurde geschlossen.
ErrHubClosed = errors.New("stream: Hub geschlossen")
// ErrReplaced: Derselbe Knoten hat sich neu verbunden; der alte Stream endet.
ErrReplaced = errors.New("stream: durch neue Verbindung ersetzt")
// ErrStreamingUnsupported: Der ResponseWriter kann nicht flushen.
ErrStreamingUnsupported = errors.New("stream: ResponseWriter unterstützt kein Streaming")
// ErrInvalidNodeID: Serve wurde ohne Knoten-ID aufgerufen.
ErrInvalidNodeID = errors.New("stream: Knoten-ID fehlt")
// ErrDisconnected: Der Server hat den Stream per Disconnect beendet.
ErrDisconnected = errors.New("stream: vom Server getrennt")
)
// Hub hält die offenen Streams aller Knoten. Der Nullwert ist einsatzbereit.
// Felder dürfen nach der ersten Verwendung nicht mehr geändert werden.
type Hub struct {
// KeepAlive ist das Intervall der Keepalive-Kommentare (Default 45s).
KeepAlive time.Duration
// WriteTimeout begrenzt jeden einzelnen Schreibvorgang (Default 10s).
// Hängt ein Schreibvorgang länger, gilt der Knoten als tot.
WriteTimeout time.Duration
// BufferSize ist die Zahl der Events, die pro Knoten gepuffert werden (Default 16).
BufferSize int
// OnConnect und OnDisconnect werden aufgerufen, wenn ein Knoten online
// bzw. offline geht. Ersetzt ein Knoten seine eigene Verbindung, gibt es
// nur ein weiteres OnConnect, kein OnDisconnect.
OnConnect func(nodeID string)
OnDisconnect func(nodeID string)
mu sync.Mutex
conns map[string]*conn
closed bool
}
type conn struct {
frames chan []byte
done chan struct{}
once sync.Once
reason error
}
func (c *conn) stop(reason error) {
c.once.Do(func() {
c.reason = reason
close(c.done)
})
}
func (h *Hub) keepAlive() time.Duration { return orDefault(h.KeepAlive, 45*time.Second) }
func (h *Hub) writeTimeout() time.Duration { return orDefault(h.WriteTimeout, 10*time.Second) }
func (h *Hub) bufferSize() int {
if h.BufferSize > 0 {
return h.BufferSize
}
return 16
}
// Serve betreibt den Stream für nodeID auf w, bis der Client die Verbindung
// trennt, ein Schreibvorgang fehlschlägt, derselbe Knoten sich neu verbindet
// (ErrReplaced) oder der Hub geschlossen wird (ErrHubClosed). Authentifizierung
// ist Sache des Aufrufers: nodeID muss bereits geprüft sein.
func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error {
if nodeID == "" {
return ErrInvalidNodeID
}
h.mu.Lock()
closed := h.closed
h.mu.Unlock()
if closed {
return ErrHubClosed
}
rc := http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // Reverse-Proxies nicht puffern lassen
w.WriteHeader(http.StatusOK)
if err := rc.Flush(); err != nil {
if errors.Is(err, http.ErrNotSupported) {
return ErrStreamingUnsupported
}
return err
}
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
if err := h.register(nodeID, c); err != nil {
return err
}
defer h.unregister(nodeID, c)
ticker := time.NewTicker(h.keepAlive())
defer ticker.Stop()
write := func(p []byte) error {
err := rc.SetWriteDeadline(time.Now().Add(h.writeTimeout()))
if err != nil && !errors.Is(err, http.ErrNotSupported) {
return err
}
if _, err := w.Write(p); err != nil {
return err
}
return rc.Flush()
}
keepalive := []byte(":\n\n")
for {
select {
case <-r.Context().Done():
return r.Context().Err()
case <-c.done:
return c.reason
case frame := <-c.frames:
if err := write(frame); err != nil {
return err
}
ticker.Reset(h.keepAlive())
case <-ticker.C:
if err := write(keepalive); err != nil {
return err
}
}
}
}
func (h *Hub) register(nodeID string, c *conn) error {
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return ErrHubClosed
}
if h.conns == nil {
h.conns = map[string]*conn{}
}
if old := h.conns[nodeID]; old != nil {
old.stop(ErrReplaced)
}
h.conns[nodeID] = c
h.mu.Unlock()
if h.OnConnect != nil {
h.OnConnect(nodeID)
}
return nil
}
func (h *Hub) unregister(nodeID string, c *conn) {
h.mu.Lock()
current := h.conns[nodeID] == c
if current {
delete(h.conns, nodeID)
}
h.mu.Unlock()
c.stop(ErrNodeOffline)
if current && h.OnDisconnect != nil {
h.OnDisconnect(nodeID)
}
}
// Send stellt ev in den Stream von nodeID, ohne zu blockieren. NodeID wird
// immer auf nodeID gesetzt, ID und Timestamp nur ergänzt, wenn sie fehlen.
func (h *Hub) Send(nodeID string, ev Event) error {
if ev.Type == "" {
return fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
}
if ev.ID == "" {
ev.ID = newEventID()
}
if ev.Timestamp == 0 {
ev.Timestamp = time.Now().Unix()
}
ev.NodeID = nodeID
frame, err := encode(ev)
if err != nil {
return err
}
h.mu.Lock()
closed, c := h.closed, h.conns[nodeID]
h.mu.Unlock()
if closed {
return ErrHubClosed
}
if c == nil {
return ErrNodeOffline
}
select {
case <-c.done:
return ErrNodeOffline
default:
}
select {
case c.frames <- frame:
return nil
case <-c.done:
return ErrNodeOffline
default:
return ErrBufferFull
}
}
// Disconnect beendet den Stream von nodeID sofort, z. B. wenn Mandant oder
// Knoten gesperrt wurden. Der Knoten gilt ab Rückkehr als offline,
// OnDisconnect wird aufgerufen, Serve endet mit ErrDisconnected.
func (h *Hub) Disconnect(nodeID string) error {
h.mu.Lock()
c := h.conns[nodeID]
if c != nil {
delete(h.conns, nodeID)
}
h.mu.Unlock()
if c == nil {
return ErrNodeOffline
}
c.stop(ErrDisconnected)
if h.OnDisconnect != nil {
h.OnDisconnect(nodeID)
}
return nil
}
// Connected meldet, ob für nodeID ein Stream offen ist.
func (h *Hub) Connected(nodeID string) bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.conns[nodeID] != nil
}
// Nodes liefert die verbundenen Knoten, sortiert.
func (h *Hub) Nodes() []string {
h.mu.Lock()
nodes := make([]string, 0, len(h.conns))
for n := range h.conns {
nodes = append(nodes, n)
}
h.mu.Unlock()
slices.Sort(nodes)
return nodes
}
// Close beendet alle Streams; danach liefern Serve und Send ErrHubClosed.
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
h.closed = true
for _, c := range h.conns {
c.stop(ErrHubClosed)
}
}
func orDefault(d, def time.Duration) time.Duration {
if d > 0 {
return d
}
return def
}
+406
View File
@@ -0,0 +1,406 @@
package stream
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// eventually wartet, bis cond wahr ist, höchstens zwei Sekunden.
func eventually(t *testing.T, msg string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("Bedingung nicht erfüllt: %s", msg)
}
time.Sleep(2 * time.Millisecond)
}
}
// hubServer startet einen Testserver, der Verbindungen an h übergibt. Die
// Knoten-ID kommt aus ?node=. Rückgabewerte von Serve landen in errs.
func hubServer(t *testing.T, h *Hub) (srv *httptest.Server, errs chan error) {
t.Helper()
errs = make(chan error, 16)
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
errs <- h.Serve(w, r, r.URL.Query().Get("node"))
}))
t.Cleanup(srv.Close)
t.Cleanup(h.Close)
return srv, errs
}
func openStream(t *testing.T, ctx context.Context, srv *httptest.Server, node string) *http.Response {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"?node="+node, nil)
if err != nil {
t.Fatal(err)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("Verbindung fehlgeschlagen: %v", err)
}
t.Cleanup(func() { resp.Body.Close() })
return resp
}
func waitErr(t *testing.T, errs <-chan error) error {
t.Helper()
select {
case err := <-errs:
return err
case <-time.After(2 * time.Second):
t.Fatal("Serve ist nicht zurückgekehrt")
return nil
}
}
func TestHub_SendOffline(t *testing.T) {
var h Hub
if err := h.Send("node-1", Event{Type: EventNodeCommand}); !errors.Is(err, ErrNodeOffline) {
t.Errorf("erwartet ErrNodeOffline, bekommen %v", err)
}
}
func TestHub_Defaults(t *testing.T) {
var h Hub
if got := h.keepAlive(); got != 45*time.Second {
t.Errorf("KeepAlive-Default = %v, erwartet 45s (Spec)", got)
}
if got := h.writeTimeout(); got != 10*time.Second {
t.Errorf("WriteTimeout-Default = %v", got)
}
if got := h.bufferSize(); got != 16 {
t.Errorf("BufferSize-Default = %d", got)
}
}
func TestHub_DeliversEvent(t *testing.T) {
h := &Hub{}
srv, _ := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "node-1")
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("Content-Type = %q", ct)
}
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Errorf("Cache-Control = %q", cc)
}
eventually(t, "node-1 verbunden", func() bool { return h.Connected("node-1") })
if err := h.Send("node-1", Event{Type: EventNodeCommand, Payload: json.RawMessage(`{"action":"drain"}`)}); err != nil {
t.Fatalf("Send: %v", err)
}
ev, err := NewDecoder(resp.Body).Next()
if err != nil {
t.Fatalf("Next: %v", err)
}
if ev.Type != EventNodeCommand || ev.NodeID != "node-1" || string(ev.Payload) != `{"action":"drain"}` {
t.Errorf("unerwartetes Event: %+v", ev)
}
if !strings.HasPrefix(ev.ID, "evt_") || ev.Timestamp == 0 {
t.Errorf("ID/Timestamp nicht ergänzt: %+v", ev)
}
}
func TestHub_SendKeepsIDAndTimestamp(t *testing.T) {
h := &Hub{}
srv, _ := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "n")
eventually(t, "verbunden", func() bool { return h.Connected("n") })
want := Event{ID: "evt_fix", Timestamp: 42, Type: EventLeaseRenew, NodeID: "n"}
if err := h.Send("n", Event{ID: "evt_fix", Timestamp: 42, Type: EventLeaseRenew, NodeID: "anderer"}); err != nil {
t.Fatal(err)
}
got, err := NewDecoder(resp.Body).Next()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, erwartet %+v (NodeID wird immer vom Hub gesetzt)", got, want)
}
}
func TestHub_SendInvalid(t *testing.T) {
h := &Hub{}
srv, _ := hubServer(t, h)
openStream(t, context.Background(), srv, "n")
eventually(t, "verbunden", func() bool { return h.Connected("n") })
if err := h.Send("n", Event{}); !errors.Is(err, ErrInvalidEvent) {
t.Errorf("leerer Typ: erwartet ErrInvalidEvent, bekommen %v", err)
}
if err := h.Send("n", Event{Type: EventError, Payload: json.RawMessage(`{`)}); !errors.Is(err, ErrInvalidEvent) {
t.Errorf("kaputte Payload: erwartet ErrInvalidEvent, bekommen %v", err)
}
}
func TestHub_KeepAlive(t *testing.T) {
h := &Hub{KeepAlive: 10 * time.Millisecond}
srv, _ := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "n")
buf := make([]byte, 6)
if _, err := io.ReadFull(resp.Body, buf); err != nil {
t.Fatalf("lesen: %v", err)
}
if string(buf) != ":\n\n:\n\n" {
t.Errorf("erwartet zwei Keepalives \":\\n\\n\", bekommen %q", buf)
}
}
func TestHub_DisconnectDetected(t *testing.T) {
var mu sync.Mutex
var connects, disconnects []string
h := &Hub{
OnConnect: func(n string) { mu.Lock(); connects = append(connects, n); mu.Unlock() },
OnDisconnect: func(n string) { mu.Lock(); disconnects = append(disconnects, n); mu.Unlock() },
}
srv, errs := hubServer(t, h)
ctx, cancel := context.WithCancel(context.Background())
openStream(t, ctx, srv, "node-1")
eventually(t, "verbunden", func() bool { return h.Connected("node-1") })
cancel()
if err := waitErr(t, errs); !errors.Is(err, context.Canceled) {
t.Errorf("Serve: erwartet context.Canceled, bekommen %v", err)
}
if h.Connected("node-1") {
t.Error("node-1 noch verbunden")
}
if err := h.Send("node-1", Event{Type: EventError}); !errors.Is(err, ErrNodeOffline) {
t.Errorf("erwartet ErrNodeOffline, bekommen %v", err)
}
mu.Lock()
defer mu.Unlock()
if !reflect.DeepEqual(connects, []string{"node-1"}) || !reflect.DeepEqual(disconnects, []string{"node-1"}) {
t.Errorf("Callbacks: connects=%v disconnects=%v", connects, disconnects)
}
}
func TestHub_ReconnectReplacesOld(t *testing.T) {
var connects, disconnects atomic.Int32
h := &Hub{
OnConnect: func(string) { connects.Add(1) },
OnDisconnect: func(string) { disconnects.Add(1) },
}
srv, errs := hubServer(t, h)
old := openStream(t, context.Background(), srv, "n")
eventually(t, "erste Verbindung", func() bool { return connects.Load() == 1 })
fresh := openStream(t, context.Background(), srv, "n")
if err := waitErr(t, errs); !errors.Is(err, ErrReplaced) {
t.Errorf("alte Verbindung: erwartet ErrReplaced, bekommen %v", err)
}
if _, err := io.ReadAll(old.Body); err != nil {
t.Errorf("alter Stream sollte sauber enden: %v", err)
}
if err := h.Send("n", Event{Type: EventLeaseRenew}); err != nil {
t.Fatal(err)
}
ev, err := NewDecoder(fresh.Body).Next()
if err != nil || ev.Type != EventLeaseRenew {
t.Errorf("neue Verbindung: ev=%+v err=%v", ev, err)
}
if c, d := connects.Load(), disconnects.Load(); c != 2 || d != 0 {
t.Errorf("Callbacks: connects=%d disconnects=%d, erwartet 2/0 (Ersatz ist kein Offline)", c, d)
}
}
func TestHub_Nodes(t *testing.T) {
h := &Hub{}
srv, _ := hubServer(t, h)
openStream(t, context.Background(), srv, "b")
openStream(t, context.Background(), srv, "a")
eventually(t, "beide verbunden", func() bool { return len(h.Nodes()) == 2 })
if got := h.Nodes(); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Errorf("Nodes = %v, erwartet sortiert [a b]", got)
}
}
func TestHub_Close(t *testing.T) {
h := &Hub{}
srv, errs := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "n")
eventually(t, "verbunden", func() bool { return h.Connected("n") })
h.Close()
if err := waitErr(t, errs); !errors.Is(err, ErrHubClosed) {
t.Errorf("Serve: erwartet ErrHubClosed, bekommen %v", err)
}
if _, err := io.ReadAll(resp.Body); err != nil {
t.Errorf("Stream sollte sauber enden: %v", err)
}
if err := h.Send("n", Event{Type: EventError}); !errors.Is(err, ErrHubClosed) {
t.Errorf("Send nach Close: erwartet ErrHubClosed, bekommen %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
if err := h.Serve(httptest.NewRecorder(), req, "neu"); !errors.Is(err, ErrHubClosed) {
t.Errorf("Serve nach Close: erwartet ErrHubClosed, bekommen %v", err)
}
h.Close() // zweimal schließen darf nicht paniken
}
// blockingWriter simuliert einen Client, der nicht mehr liest.
type blockingWriter struct {
header http.Header
release chan struct{}
}
func (w *blockingWriter) Header() http.Header { return w.header }
func (w *blockingWriter) WriteHeader(int) {}
func (w *blockingWriter) Flush() {}
func (w *blockingWriter) Write(p []byte) (int, error) { <-w.release; return len(p), nil }
func TestHub_BufferFull(t *testing.T) {
h := &Hub{BufferSize: 2}
w := &blockingWriter{header: http.Header{}, release: make(chan struct{})}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- h.Serve(w, httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx), "n") }()
eventually(t, "verbunden", func() bool { return h.Connected("n") })
var full bool
for range 4 {
if err := h.Send("n", Event{Type: EventError}); errors.Is(err, ErrBufferFull) {
full = true
}
}
if !full {
t.Error("erwartet ErrBufferFull bei vollem Puffer")
}
cancel()
close(w.release)
if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
t.Errorf("Serve: erwartet context.Canceled, bekommen %v", err)
}
}
type failingWriter struct{ header http.Header }
var errWrite = errors.New("Verbindung weg")
func (w *failingWriter) Header() http.Header { return w.header }
func (w *failingWriter) WriteHeader(int) {}
func (w *failingWriter) Flush() {}
func (w *failingWriter) Write([]byte) (int, error) { return 0, errWrite }
func TestHub_WriteErrorEndsServe(t *testing.T) {
h := &Hub{KeepAlive: 5 * time.Millisecond}
err := h.Serve(&failingWriter{header: http.Header{}}, httptest.NewRequest(http.MethodGet, "/", nil), "n")
if !errors.Is(err, errWrite) {
t.Errorf("erwartet Schreibfehler, bekommen %v", err)
}
if h.Connected("n") {
t.Error("Knoten nach Schreibfehler noch verbunden")
}
}
type noFlushWriter struct{ header http.Header }
func (w *noFlushWriter) Header() http.Header { return w.header }
func (w *noFlushWriter) WriteHeader(int) {}
func (w *noFlushWriter) Write(p []byte) (int, error) { return len(p), nil }
func TestHub_StreamingUnsupported(t *testing.T) {
var h Hub
err := h.Serve(&noFlushWriter{header: http.Header{}}, httptest.NewRequest(http.MethodGet, "/", nil), "n")
if !errors.Is(err, ErrStreamingUnsupported) {
t.Errorf("erwartet ErrStreamingUnsupported, bekommen %v", err)
}
if h.Connected("n") {
t.Error("Knoten darf nicht registriert sein")
}
}
func TestHub_ConcurrentSend(t *testing.T) {
const senders, perSender = 20, 50
h := &Hub{}
srv, _ := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "n")
eventually(t, "verbunden", func() bool { return h.Connected("n") })
var wg sync.WaitGroup
for range senders {
wg.Go(func() {
for range perSender {
for {
err := h.Send("n", Event{Type: EventServiceState})
if err == nil {
break
}
if !errors.Is(err, ErrBufferFull) {
t.Errorf("Send: %v", err)
return
}
time.Sleep(time.Millisecond)
}
}
})
}
dec := NewDecoder(resp.Body)
for i := range senders * perSender {
if _, err := dec.Next(); err != nil {
t.Fatalf("Event %d: %v", i, err)
}
}
wg.Wait()
}
func TestHub_ServeRejectsEmptyNodeID(t *testing.T) {
var connects atomic.Int32
h := &Hub{OnConnect: func(string) { connects.Add(1) }}
rec := httptest.NewRecorder()
err := h.Serve(rec, httptest.NewRequest(http.MethodGet, "/", nil), "")
if !errors.Is(err, ErrInvalidNodeID) {
t.Errorf("erwartet ErrInvalidNodeID, bekommen %v", err)
}
if rec.Body.Len() != 0 || rec.Header().Get("Content-Type") == "text/event-stream" {
t.Error("bei ungültiger ID darf kein Stream begonnen werden")
}
if connects.Load() != 0 || len(h.Nodes()) != 0 {
t.Error("leere ID wurde registriert")
}
}
func TestHub_Disconnect(t *testing.T) {
var disconnects atomic.Int32
h := &Hub{OnDisconnect: func(string) { disconnects.Add(1) }}
srv, errs := hubServer(t, h)
resp := openStream(t, context.Background(), srv, "n")
eventually(t, "verbunden", func() bool { return h.Connected("n") })
if err := h.Disconnect("n"); err != nil {
t.Fatalf("Disconnect: %v", err)
}
// Sofort wirksam, nicht erst wenn Serve zurückkehrt:
if h.Connected("n") {
t.Error("Knoten nach Disconnect noch verbunden")
}
if err := h.Send("n", Event{Type: EventError}); !errors.Is(err, ErrNodeOffline) {
t.Errorf("Send nach Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
}
if err := waitErr(t, errs); !errors.Is(err, ErrDisconnected) {
t.Errorf("Serve: erwartet ErrDisconnected, bekommen %v", err)
}
if _, err := io.ReadAll(resp.Body); err != nil {
t.Errorf("Stream sollte sauber enden: %v", err)
}
if n := disconnects.Load(); n != 1 {
t.Errorf("OnDisconnect %d-mal aufgerufen, erwartet 1", n)
}
if err := h.Disconnect("n"); !errors.Is(err, ErrNodeOffline) {
t.Errorf("zweites Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
}
}