Compare commits
3
Commits
dfca772035
...
e02267a659
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e02267a659 | ||
|
|
2f238236bd | ||
|
|
f489321e3a |
@@ -36,6 +36,8 @@ DEBUG=routewatch ./routewatch
|
||||
|
||||
## HTTP Endpoints
|
||||
|
||||
Every route answers `HEAD` as it answers `GET`, without the body.
|
||||
|
||||
### Web Interface
|
||||
|
||||
- `GET /` - Home page (HTML): headline figures, and lookups by AS number and by
|
||||
@@ -90,8 +92,11 @@ and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`.
|
||||
An AS number, prefix or mask length that does not parse answers 400, as does a
|
||||
mask length out of range for its IP version. An AS the database does not hold,
|
||||
or a prefix with no live route, answers 404. The HTML pages give these errors as
|
||||
plain text. A 500 answer says only `Internal server error`, and a host name that
|
||||
does not resolve only `could not resolve host name`; the detail goes to the log.
|
||||
plain text. Under `/api/v1` and `/ip/`, a path no route matches answers 404 with
|
||||
the message `Not found`, and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`,
|
||||
`TRACE` or `CONNECT` answers 405 with `Method not allowed`. A 500 answer says
|
||||
only `Internal server error`, and a host name that does not resolve only
|
||||
`could not resolve host name`; the detail goes to the log.
|
||||
|
||||
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
|
||||
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
|
||||
@@ -190,10 +195,11 @@ routewatch/
|
||||
- **ASHandler**: Tracks all ASNs seen in AS paths
|
||||
- **PeerHandler**: Records BGP peer information
|
||||
- **PrefixHandler**: Maintains live routing table (upserts on announcement,
|
||||
deletes on withdrawal, deletes all of a peer's routes when its BGP session
|
||||
goes down). Routes are kept per peer IP, so when one peer IP has sessions
|
||||
on two collectors, a session going down on one also removes the routes the
|
||||
other still carries, until they are announced again.
|
||||
deletes on withdrawal or on an announcement whose AS path is empty or ends
|
||||
in AS 0, deletes all of a peer's routes when its BGP session goes down).
|
||||
Routes are kept per peer IP, so when one peer IP has sessions on two
|
||||
collectors, a session going down on one also removes the routes the other
|
||||
still carries, until they are announced again.
|
||||
- **PeeringHandler**: Extracts AS peering relationships from AS paths
|
||||
|
||||
5. **HTTP Request Flow**
|
||||
|
||||
@@ -116,17 +116,18 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start ASN WHOIS fetcher for background updates. The HTTP handlers read
|
||||
// the server's fetcher without a lock, so it is set before they run.
|
||||
rw.asnFetcher = NewASNFetcher(rw.db, rw.logger.Logger)
|
||||
rw.asnFetcher.Start(ctx)
|
||||
rw.server.SetASNFetcher(rw.asnFetcher)
|
||||
|
||||
// Start HTTP server
|
||||
err = rw.server.Start(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start ASN WHOIS fetcher for background updates
|
||||
rw.asnFetcher = NewASNFetcher(rw.db, rw.logger.Logger)
|
||||
rw.asnFetcher.Start(ctx)
|
||||
rw.server.SetASNFetcher(rw.asnFetcher)
|
||||
|
||||
// Start database maintenance goroutine
|
||||
rw.dbMaintainer = NewDBMaintainer(rw.db, rw.logger.Logger)
|
||||
rw.dbMaintainer.Start(ctx)
|
||||
|
||||
@@ -44,8 +44,9 @@ const (
|
||||
)
|
||||
|
||||
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the
|
||||
// database. Routes are added on announcement and deleted on withdrawal, and all
|
||||
// routes of a peer are deleted when its BGP session goes down.
|
||||
// database. Routes are added on announcement and deleted on withdrawal or on an
|
||||
// announcement whose AS path is empty or ends in AS 0, and all routes of a peer
|
||||
// are deleted when its BGP session goes down.
|
||||
type PrefixHandler struct {
|
||||
db database.Store
|
||||
logger *logger.Logger
|
||||
@@ -371,7 +372,11 @@ func (h *PrefixHandler) flushBatch() {
|
||||
for _, update := range lastUpdates {
|
||||
// An announcement is stored when its path gives an origin, or when it
|
||||
// ends in an AS set of several members: that route is stored with
|
||||
// origin 0 (none). An announcement with an empty path is not stored.
|
||||
// origin 0 (none). Any other update deletes the peer's route for the
|
||||
// prefix: a withdrawal, or an announcement whose path is empty or ends
|
||||
// in AS 0. Such an announcement replaces the peer's route, as any
|
||||
// announcement does, with none (RFC 7607 treats AS 0 in a path as a
|
||||
// withdrawal).
|
||||
if update.messageType == "announcement" &&
|
||||
(update.originASN > 0 || update.endsInSet) {
|
||||
// Create live route for batch upsert
|
||||
@@ -379,11 +384,11 @@ func (h *PrefixHandler) flushBatch() {
|
||||
if route != nil {
|
||||
routesToUpsert = append(routesToUpsert, route)
|
||||
}
|
||||
} else if update.messageType == "withdrawal" {
|
||||
} else {
|
||||
// Parse CIDR to get IP version
|
||||
_, ipVersion, err := parseCIDR(update.prefix)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to parse CIDR for withdrawal",
|
||||
h.logger.Error("Failed to parse CIDR for route deletion",
|
||||
"prefix", update.prefix, "error", err)
|
||||
|
||||
continue
|
||||
|
||||
@@ -378,6 +378,68 @@ func TestPrefixHandlerDoesNotStoreAnAnnouncementWithAnEmptyPath(t *testing.T) {
|
||||
assertPeers(t, db, map[string][]string{testV4Prefix: nil})
|
||||
}
|
||||
|
||||
// TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin runs, through
|
||||
// the prefix handler into a real database, a peer's announcement of a prefix
|
||||
// with origin AS testASNB and then the peer's announcement of the prefix with
|
||||
// an AS path that gives no origin: one whose last AS is 0, or an empty one. As
|
||||
// any later announcement from the peer does, the second replaces the peer's
|
||||
// route, here with none, whether it comes in a later batch or follows a
|
||||
// withdrawal in one batch. The in-memory route count and prefix distribution
|
||||
// must match the route table.
|
||||
func TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
const prefixMaskLength = 24
|
||||
|
||||
noOrigin := func(path ristypes.ASPath) *ristypes.RISMessage {
|
||||
msg := peerAnnouncement(testPeerIP, testV4Prefix)
|
||||
msg.Path = path
|
||||
|
||||
return msg
|
||||
}
|
||||
endsInZero := ristypes.ASPath{Sequence: []int{testASNA, 0}}
|
||||
withdrawal := &ristypes.RISMessage{
|
||||
Type: ristypes.MessageTypeUpdate,
|
||||
Peer: testPeerIP,
|
||||
ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
Withdrawals: []string{testV4Prefix},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
// Handled and flushed as one batch after the announcement with origin
|
||||
// AS testASNB.
|
||||
batch []*ristypes.RISMessage
|
||||
}{
|
||||
{
|
||||
name: "a path whose last AS is 0",
|
||||
batch: []*ristypes.RISMessage{noOrigin(endsInZero)},
|
||||
},
|
||||
{
|
||||
name: "an empty path",
|
||||
batch: []*ristypes.RISMessage{noOrigin(ristypes.ASPath{})},
|
||||
},
|
||||
{
|
||||
name: "a withdrawal and then a path whose last AS is 0",
|
||||
batch: []*ristypes.RISMessage{withdrawal, noOrigin(endsInZero)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := handleBatches(t, [][]*ristypes.RISMessage{
|
||||
{peerAnnouncement(testPeerIP, testV4Prefix)},
|
||||
tt.batch,
|
||||
})
|
||||
checkLivePeers(t, db, testV4Prefix, prefixMaskLength, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateMessagesGoToThePrefixHandlerNotThePeerHandler checks that STATE
|
||||
// messages go to the prefix handler, which acts on a down, and not to the peer
|
||||
// handler, which would record a peer whose session never comes up as active.
|
||||
|
||||
@@ -142,8 +142,9 @@ func deadlinePassed(ctx context.Context) bool {
|
||||
//
|
||||
// If a response is not valid JSON or is empty when JSON is expected, the middleware
|
||||
// returns a properly formatted JSON error response. If the request's time limit
|
||||
// has passed, it answers 504 with the error message "Request timeout". For other
|
||||
// errors, it returns "Internal server error" with status 500 if the original
|
||||
// has passed, it answers 504 with the error message "Request timeout". A 4xx
|
||||
// keeps its status, with the status's standard text as the message. For other
|
||||
// errors, it returns "Internal server error", with status 500 if the original
|
||||
// status was 200.
|
||||
//
|
||||
// Non-JSON responses are passed through unchanged.
|
||||
@@ -203,8 +204,13 @@ func JSONValidationMiddleware(next http.Handler) http.Handler {
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
errorMsg := internalErrorMessage
|
||||
if statusCode == http.StatusGatewayTimeout {
|
||||
|
||||
switch {
|
||||
case statusCode == http.StatusGatewayTimeout:
|
||||
errorMsg = requestTimeoutMessage
|
||||
case statusCode >= http.StatusBadRequest &&
|
||||
statusCode < http.StatusInternalServerError:
|
||||
errorMsg = http.StatusText(statusCode)
|
||||
}
|
||||
|
||||
response := map[string]any{
|
||||
|
||||
@@ -58,6 +58,34 @@ func TestResponsesCarrySecurityHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSONValidationKeeps4xxWithoutBody wraps a handler that answers 400
|
||||
// without a body in JSONValidationMiddleware and checks that the JSON error
|
||||
// body keeps the 400 and gives the status's own text. Before the fix it said
|
||||
// "Internal server error"
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/111).
|
||||
func TestJSONValidationKeeps4xxWithoutBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := JSONValidationMiddleware(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil))
|
||||
|
||||
const name = "400 without a body"
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
name, rec.Code, http.StatusBadRequest, rec.Body)
|
||||
}
|
||||
|
||||
checkJSONErrorCode(t, name, rec, http.StatusBadRequest)
|
||||
checkJSONErrorMessage(t, name, rec, http.StatusText(http.StatusBadRequest))
|
||||
}
|
||||
|
||||
// TestPagesCarryTheCSPNonce checks that every script and style block of each
|
||||
// page carries the nonce its Content-Security-Policy header names, so that the
|
||||
// browser runs and applies them, that each answer has a nonce of its own, and
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// The error messages the JSON routes under /api/v1 and /ip answer with when no
|
||||
// route matches the path, and when none takes the method.
|
||||
const (
|
||||
notFoundMessage = "Not found"
|
||||
methodNotAllowedMessage = "Method not allowed"
|
||||
|
||||
// jsonRoutesAllow is the Allow header of their 405 answer: every one of
|
||||
// them takes GET and HEAD, and no other method.
|
||||
jsonRoutesAllow = "GET, HEAD"
|
||||
)
|
||||
|
||||
// setupRoutes configures the HTTP routes
|
||||
func (s *Server) setupRoutes() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Middleware
|
||||
r.Use(DecodedPathMiddleware)
|
||||
// GetHead serves HEAD with the GET handler. It looks the route up by the
|
||||
// path DecodedPathMiddleware sets, so it comes after it.
|
||||
r.Use(middleware.GetHead)
|
||||
r.Use(SecurityHeadersMiddleware)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(RealIPMiddleware(s.trustedProxies))
|
||||
@@ -40,16 +55,34 @@ func (s *Server) setupRoutes() {
|
||||
r.Get("/prefixlength/{length}", s.handlePrefixLength())
|
||||
r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
|
||||
|
||||
// The JSON routes answer a path or method they do not take with the JSON
|
||||
// error body, as they answer everything else.
|
||||
jsonNotFound := func(w http.ResponseWriter, _ *http.Request) {
|
||||
s.writeJSONError(w, http.StatusNotFound, notFoundMessage)
|
||||
}
|
||||
jsonMethodNotAllowed := func(w http.ResponseWriter, _ *http.Request) {
|
||||
// chi's own 405 answer sets Allow; one set in its place must too.
|
||||
w.Header().Set("Allow", jsonRoutesAllow)
|
||||
s.writeJSONError(w, http.StatusMethodNotAllowed, methodNotAllowedMessage)
|
||||
}
|
||||
|
||||
// IP info JSON endpoints (replaces old /ip redirect)
|
||||
r.Route("/ip", func(r chi.Router) {
|
||||
r.Use(JSONValidationMiddleware)
|
||||
r.Get("/", s.handleIPInfo()) // Client IP
|
||||
r.NotFound(jsonNotFound)
|
||||
r.MethodNotAllowed(jsonMethodNotAllowed)
|
||||
r.Get("/", s.handleIPInfo()) // Client IP
|
||||
// GetHead does not reach this route: mounting the group registers
|
||||
// /ip and /ip/ for every method, so HEAD arrives here as HEAD.
|
||||
r.Head("/", s.handleIPInfo())
|
||||
r.Get("/{addr}", s.handleIPInfo()) // Specified IP
|
||||
})
|
||||
|
||||
// API routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(JSONValidationMiddleware)
|
||||
r.NotFound(jsonNotFound)
|
||||
r.MethodNotAllowed(jsonMethodNotAllowed)
|
||||
r.Get("/stats", s.handleStats())
|
||||
r.Get("/ip/{ip}", s.handleIPLookup())
|
||||
r.Get("/as/{asn}", s.handleASDetailJSON())
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRoutesAnswerHEAD sends HEAD for a page and JSON routes through the
|
||||
// server's router and checks that each answers as GET does. /ip and /ip/, the
|
||||
// lookup of the caller's own address, are in the list because chi's GetHead
|
||||
// does not reach them. Before the fix every route answered HEAD with 405
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/111).
|
||||
func TestRoutesAnswerHEAD(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
|
||||
for _, path := range []string{"/", statsPath, "/ip", "/ip/"} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodHead, path, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("HEAD %s: status %d, want %d; body %s",
|
||||
path, rec.Code, http.StatusOK, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors sends a method the JSON
|
||||
// routes do not take, and a path none of them matches, to each group of JSON
|
||||
// routes through the server's router. Each must answer with the JSON error
|
||||
// body, and a 405 must name the methods the routes take in its Allow header.
|
||||
// Before the fix a 405 said "Internal server error" and a 404 was chi's plain
|
||||
// text answer (https://git.eeqj.de/sneak/routewatch/issues/111).
|
||||
func TestJSONRoutesAnswerUnmatchedRequestsWithJSONErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
wantCode int
|
||||
wantMsg string
|
||||
}{
|
||||
{http.MethodPost, statsPath, http.StatusMethodNotAllowed, methodNotAllowedMessage},
|
||||
{http.MethodPost, "/ip/", http.StatusMethodNotAllowed, methodNotAllowedMessage},
|
||||
{http.MethodGet, "/api/v1/nothing", http.StatusNotFound, notFoundMessage},
|
||||
{http.MethodGet, "/ip/192.0.2.1/nothing", http.StatusNotFound, notFoundMessage},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
name := tt.method + " " + tt.path
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), tt.method, tt.path, nil))
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
name, rec.Code, tt.wantCode, rec.Body)
|
||||
}
|
||||
|
||||
checkJSONErrorCode(t, name, rec, tt.wantCode)
|
||||
checkJSONErrorMessage(t, name, rec, tt.wantMsg)
|
||||
|
||||
allow := rec.Header().Get("Allow")
|
||||
if tt.wantCode == http.StatusMethodNotAllowed && allow != jsonRoutesAllow {
|
||||
t.Errorf("%s: Allow %q, want %q", name, allow, jsonRoutesAllow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkJSONErrorMessage checks that rec holds the JSON error body with the
|
||||
// given message.
|
||||
func checkJSONErrorMessage(
|
||||
t *testing.T, name string, rec *httptest.ResponseRecorder, want string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
var body struct {
|
||||
Error struct {
|
||||
Msg string `json:"msg"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Errorf("%s: decoding the answer %q: %v", name, rec.Body, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if body.Error.Msg != want {
|
||||
t.Errorf("%s: error message %q, want %q", name, body.Error.Msg, want)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,8 @@ func (s *Server) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups.
|
||||
// SetASNFetcher sets the ASN WHOIS fetcher for on-demand lookups. Call it
|
||||
// before Start: the handlers read the fetcher without a lock.
|
||||
func (s *Server) SetASNFetcher(fetcher ASNFetcher) {
|
||||
s.asnFetcher = fetcher
|
||||
}
|
||||
|
||||
@@ -106,12 +106,12 @@ type RawMessageHandler func(line string)
|
||||
// handlerMetrics tracks performance metrics for a handler
|
||||
type handlerMetrics struct {
|
||||
processedCount uint64 // Total messages processed
|
||||
droppedCount uint64 // Total messages dropped
|
||||
droppedCount atomic.Uint64 // Total messages dropped; not under mu
|
||||
totalTime time.Duration // Total processing time (for average calculation)
|
||||
minTime time.Duration // Minimum processing time
|
||||
maxTime time.Duration // Maximum processing time
|
||||
queueHighWaterMark int // Maximum queue length seen
|
||||
mu sync.Mutex // Protects the metrics
|
||||
mu sync.Mutex // Protects the other metrics
|
||||
}
|
||||
|
||||
// handlerInfo wraps a handler with its queue and metrics
|
||||
@@ -371,7 +371,7 @@ func (s *Streamer) GetHandlerStats() []HandlerStats {
|
||||
QueueCapacity: cap(info.queue),
|
||||
QueueHighWaterMark: info.metrics.queueHighWaterMark,
|
||||
ProcessedCount: info.metrics.processedCount,
|
||||
DroppedCount: info.metrics.droppedCount,
|
||||
DroppedCount: info.metrics.droppedCount.Load(),
|
||||
MinProcessTime: info.metrics.minTime,
|
||||
MaxProcessTime: info.metrics.maxTime,
|
||||
}
|
||||
@@ -488,7 +488,7 @@ func (s *Streamer) logMetrics() {
|
||||
"queue_len", len(info.queue),
|
||||
"queue_cap", cap(info.queue),
|
||||
"processed", info.metrics.processedCount,
|
||||
"dropped", info.metrics.droppedCount,
|
||||
"dropped", info.metrics.droppedCount.Load(),
|
||||
"avg_time", avgTime,
|
||||
"min_time", info.metrics.minTime,
|
||||
"max_time", info.metrics.maxTime,
|
||||
@@ -858,7 +858,7 @@ func (s *Streamer) dispatchMessage(
|
||||
}
|
||||
|
||||
if !s.queueMessage(info, msg) {
|
||||
atomic.AddUint64(&info.metrics.droppedCount, 1)
|
||||
info.metrics.droppedCount.Add(1)
|
||||
s.totalDropped.Add(1)
|
||||
|
||||
continue
|
||||
|
||||
@@ -621,6 +621,68 @@ func TestStreamReadsLongLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// blockedHandler wants UPDATE messages, has room on its queue for one, and
|
||||
// does not return from HandleMessage until release is closed.
|
||||
type blockedHandler struct {
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (blockedHandler) WantsMessage(messageType string) bool {
|
||||
return messageType == ristypes.MessageTypeUpdate
|
||||
}
|
||||
|
||||
func (blockedHandler) QueueCapacity() int { return 1 }
|
||||
|
||||
func (h blockedHandler) HandleMessage(*ristypes.RISMessage) { <-h.release }
|
||||
|
||||
// TestHandlerStatsReadWhileMessagesAreDropped has the server send a few UPDATE
|
||||
// lines to a blockedHandler, so the read loop drops all but the one the
|
||||
// handler holds and the one on its queue, while the test reads the handler's
|
||||
// statistics. The drop count must reach the number dropped, and the race
|
||||
// detector must find the count read and written safely.
|
||||
func TestHandlerStatsReadWhileMessagesAreDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
lines = 5
|
||||
wantDropped = lines - 2
|
||||
maxWait = 5 * time.Second
|
||||
)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, strings.Repeat(updateLine, lines))
|
||||
|
||||
flush(w)
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := New(logger.New(), metrics.New())
|
||||
s.url = srv.URL
|
||||
handler := blockedHandler{release: make(chan struct{})}
|
||||
s.RegisterHandler(handler)
|
||||
|
||||
err := s.Start(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("Start returned error: %v", err)
|
||||
}
|
||||
defer s.Stop(t.Context())
|
||||
defer close(handler.release)
|
||||
|
||||
// The test polls instead of waiting for a signal from the read loop: a
|
||||
// signal would order the drops before the reads here, and the race
|
||||
// detector would have nothing to report.
|
||||
deadline := time.Now().Add(maxWait)
|
||||
|
||||
for s.GetHandlerStats()[0].DroppedCount < wantDropped {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("fewer than %d messages counted as dropped within %s",
|
||||
wantDropped, maxWait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeerCountCountsEveryPeerHeardFrom has the server send an UPDATE from
|
||||
// stateDownPeer and a KEEPALIVE from another peer, with no OPEN from either,
|
||||
// and then a message that ends stateDownPeer's session: a NOTIFICATION, or a
|
||||
|
||||
Reference in New Issue
Block a user