Files
Felis/cmd/felis/nano_test.go
T
flyemoji 1d6c73007e fix(nano): drain in-flight logins on shutdown
The installer and the config template tell the operator to run
systemctl restart felis-nano after editing the source list. nano had no
signal handling, so SIGTERM killed it mid-request: a login waiting on an
upstream had its connection reset, and Velocity disconnected that
player with "authentication servers are down". felis api already drains
on shutdown; nano did not.

nano now listens itself, serves until SIGINT or SIGTERM, then shuts the
server down gracefully with a 30-second limit. That outlasts the source
scan of any realistic list, at five seconds per source, and stays well
inside systemd's default 90-second stop timeout.

The new test holds a request inside the handler, cancels the serve
context, and checks that serveNano is still running 200 ms later, that
the held request then gets its answer, and that serveNano returns 0.
Replacing the graceful shutdown with Close fails it.
2026-09-22 13:28:59 +09:00

83 lines
2.5 KiB
Go

package main
import (
"bytes"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"unicode/utf8"
)
// A stop signal that lands while a login is waiting on an upstream must let that login
// finish: the request is answered, and serveNano returns only afterwards.
func TestNanoDrainsInFlightLoginOnShutdown(t *testing.T) {
entered, release := make(chan struct{}), make(chan struct{})
srv := newAPIServer("", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(entered)
<-release
w.WriteHeader(http.StatusNoContent)
}))
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, stop := context.WithCancel(context.Background())
done := make(chan int, 1)
go func() { done <- serveNano(ctx, srv, ln, io.Discard) }()
got := make(chan int, 1)
go func() {
resp, err := http.Get("http://" + ln.Addr().String() + "/session/minecraft/hasJoined")
if err != nil {
got <- -1
return
}
resp.Body.Close()
got <- resp.StatusCode
}()
<-entered
stop()
select {
case <-done:
t.Fatal("serveNano returned while a login was still in flight")
case <-time.After(200 * time.Millisecond):
}
close(release)
if code := <-got; code != http.StatusNoContent {
t.Fatalf("in-flight login got %d, want its answer (204)", code)
}
if rc := <-done; rc != 0 {
t.Fatalf("serveNano = %d after a clean drain, want 0", rc)
}
}
// The request log prints text the caller chose. A bidi override must not reorder the line,
// an invalid byte must not make journald store the entry as a blob, and a huge query must
// not become a huge log line. serverId is left out so the handler answers without asking
// any source.
func TestNanoRequestLogIsQuotedAndCapped(t *testing.T) {
const rlo = rune(0x202e) // RIGHT-TO-LEFT OVERRIDE
var log bytes.Buffer
h := nanoHandler(nil, &log)
target := "/session/minecraft/hasJoined?username=" + string(rlo) + "evil" + string([]byte{0x9b}) + "31m" + strings.Repeat("a", 4096)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
line := log.String()
if strings.ContainsRune(line, rlo) || !utf8.ValidString(line) {
t.Fatalf("raw caller bytes reached the log: %q", line)
}
if escaped := strings.Trim(strconv.QuoteRune(rlo), "'"); !strings.Contains(line, escaped) {
t.Fatalf("log line %q should show the override escaped as %s", line, escaped)
}
if len(line) > 2*nanoLogURIMax {
t.Fatalf("log line is %d bytes for a %d-byte URI; want it capped", len(line), len(target))
}
}