fix(playback): close the serve-path monitoring and kill-switch gaps

Six defects on byte-serving paths, all of which made the PR's monitoring and
kill-switch claims narrower than documented.

#2/GAP-10 -- the ABS in-flight kill switch was a production no-op. accessLog
wraps every ABS route, and its statusRecorder implemented Write, WriteHeader,
Hijack and Flush but not Unwrap, so http.NewResponseController dead-ended and
SetWriteDeadline returned ErrNotSupported. A multi-GB audiobook pour survived a
RevokeUser. The existing test passed throughout because it called handlers
directly and never saw the middleware; the new test drives the mounted router
over a real socket, and both new assertions fail if Unwrap is removed again.

GAP-11 -- ebook, comic and PDF serving was invisible and un-killable: no meter,
no transfer record, no Refuse, no watcher, on a route that serves cbz/cbr/pdf
files routinely 100 MB-1 GB+. It now follows the ABS file-handler idiom. Note
guardRevocationCut is deliberately *not* reused here: it keys on a session_id
URL param and an ?st= token this route does not carry, so it would have
compiled and silently guarded nothing. Cap-exempt per decision A4 -- admission
is untouched and neither route consumes a stream slot.

#10 -- the no-proxy remote transcode hop forwarded segments through a bare
RollingDeadlineWriter, so bytes on the API hop went unaccounted for a supported
topology. Metering is scoped to media bodies; manifests are excluded so a
rewritten playlist is not counted as media, and a mid-copy failure is no longer
silently discarded.

#16 -- native, proxy and compat subtitle pours were entry-gated only. They now
carry a transport span, a meter and an in-flight watcher. Proxy subtitle bytes
are attributed only when a tracker record already exists: taking Track/Remove
lifecycle ownership per subtitle request would walk straight into the
overlapping-request defect (#1) that Batch 2 addresses. Compat subtitle
extraction is buffered and rejects bitmap formats, so a cut stops delivery but
not extraction already in progress.

M2 -- the proxy deferred tracker Remove with the request context, which is
already canceled on client disconnect, so the Redis DEL never happened and the
key lingered until TTL -- a false over-cap window that could get a legitimate
stream killed. Cleanup now uses a short bounded context.

GAP-13 -- mergeStreams took the freshest record wholesale and never merged
BytesServed, so a stream that poured 8 GiB at an edge could report 0. Merged as
a max, not a sum: the records are two observers of one pour. Fixed in
DedupeSessionInfos too, which had the same hole and feeds the admin view.

#11 needed no behaviour change -- that route was already metered, registered and
watched, and commit c24d8396 plus this Unwrap fix are what make its cut work.
Its comment claimed download-class exemption while the comment above it said the
?token= form is for iOS streaming; both facts and decision A4 are now stated.

Part of #305.
This commit is contained in:
CoffeeKnyte
2026-07-30 06:11:43 +00:00
parent c24d839681
commit 7adedf6429
18 changed files with 720 additions and 41 deletions
+9 -5
View File
@@ -81,11 +81,11 @@ func (h *DownloadHandler) SetTransferRegistry(registry *transfers.Registry) {
// Sessionless (downloads have no stream session), so only user-kind kills apply;
// the request entry time predates any future revocation, which is exactly the
// user-kill cutoff contract. Returns a stop func the caller must defer.
func (h *DownloadHandler) cutDownloadOnUserRevocation(w http.ResponseWriter, userID int) func() {
func (h *DownloadHandler) cutDownloadOnUserRevocation(ctx context.Context, w http.ResponseWriter, userID int, startedAt time.Time) func() {
if h.revocation == nil {
return func() {}
}
return h.revocation.WatchAndCut(w, "", userID, time.Now())
return h.revocation.WatchAndCutContext(ctx, w, "", userID, startedAt)
}
// downloadRequest represents the JSON body for POST /downloads.
@@ -441,15 +441,17 @@ func (h *DownloadHandler) HandleDownloadFile(w http.ResponseWriter, r *http.Requ
profileID, deviceID, deviceName, _ := managedIdentity(r)
filter := requestAccessFilter(r)
startedAt := time.Now()
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
// Full media downloads outlive the server's absolute WriteTimeout; roll
// the write deadline with progress instead.
sw := httpstream.NewRollingDeadlineWriter(w)
sw := httpstream.NewRollingDeadlineWriterCtx(r.Context(), w)
transfer := h.newTransfer(r, userID, profileID, deviceName, "native_download")
defer h.transfers.End(transfer.ID)
metered := playback.NewSessionMeteredWriter(sw, h.transfers, transfer.ID)
defer func() { _ = metered.Close() }()
// In-flight kill switch: a user revocation cuts this pour mid-transfer.
stop := h.cutDownloadOnUserRevocation(metered, userID)
stop := h.cutDownloadOnUserRevocation(r.Context(), metered, userID, startedAt)
defer stop()
if err := h.svc.ServeFile(r.Context(), metered, r, userID, profileID, deviceID, id, filter, transfer); err != nil {
@@ -497,12 +499,14 @@ func (h *DownloadHandler) HandleDirectDownload(w http.ResponseWriter, r *http.Re
filter := requestAccessFilter(r)
profileID, _, deviceName, _ := managedIdentity(r)
startedAt := time.Now()
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
transfer := h.newTransfer(r, userID, profileID, deviceName, "native_direct")
defer h.transfers.End(transfer.ID)
metered := playback.NewSessionMeteredWriter(w, h.transfers, transfer.ID)
defer func() { _ = metered.Close() }()
// In-flight kill switch: a user revocation cuts this pour mid-transfer.
stop := h.cutDownloadOnUserRevocation(metered, userID)
stop := h.cutDownloadOnUserRevocation(r.Context(), metered, userID, startedAt)
defer stop()
if err := h.svc.ServeDirect(r.Context(), metered, r, userID, fileID, r.URL.Query().Get("format"), filter, transfer); err != nil {
+1 -1
View File
@@ -157,7 +157,7 @@ func serveConvertedEpub(w http.ResponseWriter, r *http.Request, file *models.Med
name := strings.TrimSuffix(filepath.Base(file.FilePath), filepath.Ext(file.FilePath)) + ".epub"
setConvertedEpubHeaders(w, file, key)
http.ServeContent(httpstream.NewRollingDeadlineWriter(w), r, name, stat.ModTime(), f)
http.ServeContent(httpstream.NewRollingDeadlineWriterCtx(r.Context(), w), r, name, stat.ModTime(), f)
return nil
}
+49 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"mime"
"net/http"
"os"
@@ -20,8 +21,12 @@ import (
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/Silo-Server/silo-server/internal/transfers"
)
// EbookReaderProgress is the persisted reader position for one profile and ebook.
@@ -100,6 +105,8 @@ type EbookReaderHandler struct {
// Conversion, when set and enabled, transparently converts Kindle-family
// files to EPUB on read. Nil means the feature is off.
Conversion *EbookConversion
Transfers *transfers.Registry
Revocation *streamrevoke.Store
}
func NewEbookReaderHandler(authorizer *MediaFileAuthorizer) *EbookReaderHandler {
@@ -116,6 +123,10 @@ func (h *EbookReaderHandler) HandleReadFile(w http.ResponseWriter, r *http.Reque
writeError(w, http.StatusServiceUnavailable, "unavailable", "Ebook reader is not configured")
return
}
startedAt := time.Now()
// TODO(#305, A3): use the access token's iat as the revocation cutoff
// identity once credential identity is unified across serve surfaces.
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
contentID := strings.TrimSpace(chi.URLParam(r, "content_id"))
fileID, err := strconv.Atoi(chi.URLParam(r, "file_id"))
@@ -134,7 +145,43 @@ func (h *EbookReaderHandler) HandleReadFile(w http.ResponseWriter, r *http.Reque
return
}
if err := h.serveEbook(w, r, file); err != nil {
userID := apimw.GetUserID(r.Context())
if h.Revocation != nil && h.Revocation.Refuse(w, "", userID, startedAt) {
return
}
route := "ebook_read"
if h.Conversion.active(r.Context()) && isKindleReaderFile(file) {
route = "ebook_convert"
}
transfer := transfers.Transfer{
ID: uuid.NewString(),
UserID: userID,
ProfileID: apimw.GetProfileID(r.Context()),
MediaFileID: file.ID,
Route: route,
ClientIP: clientip.FromContext(r.Context()),
ClientName: r.UserAgent(),
StartedAt: startedAt,
}
if h.Transfers != nil {
if err := h.Transfers.Begin(transfer); err != nil {
slog.DebugContext(r.Context(), "ebook transfer not monitored",
"component", "ebooks",
"transfer_id", transfer.ID,
"error", err,
)
}
}
defer h.Transfers.End(transfer.ID)
metered := playback.NewSessionMeteredWriter(w, h.Transfers, transfer.ID)
defer func() { _ = metered.Close() }()
if h.Revocation != nil {
stop := h.Revocation.WatchAndCutContext(r.Context(), metered, "", userID, startedAt)
defer stop()
}
if err := h.serveEbook(metered, r, file); err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Ebook file not found")
return
@@ -623,7 +670,7 @@ func serveEbookInline(w http.ResponseWriter, r *http.Request, file *models.Media
// Ebook files are served inline on the API origin (and reachable via the
// ?token= query fallback), so browsers must never content-sniff them.
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(httpstream.NewRollingDeadlineWriter(w), r, stat.Name(), stat.ModTime(), f)
http.ServeContent(httpstream.NewRollingDeadlineWriterCtx(r.Context(), w), r, stat.Name(), stat.ModTime(), f)
return nil
}
+161
View File
@@ -4,11 +4,13 @@ import (
"context"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
@@ -18,6 +20,8 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/Silo-Server/silo-server/internal/transfers"
)
func newEbookReaderAuthRequest(method, path string) *http.Request {
@@ -202,6 +206,163 @@ func TestEbookReaderServesEbookInlineWithRangeSupport(t *testing.T) {
}
}
type blockingEbookWriter struct {
header http.Header
reached chan struct{}
release chan struct{}
once sync.Once
written int
}
func (w *blockingEbookWriter) Header() http.Header { return w.header }
func (w *blockingEbookWriter) WriteHeader(int) {}
func (w *blockingEbookWriter) SetWriteDeadline(time.Time) error {
return nil
}
func (w *blockingEbookWriter) Write(p []byte) (int, error) {
w.written += len(p)
if w.written >= (1<<20)+(64<<10) {
w.once.Do(func() { close(w.reached) })
<-w.release
}
return len(p), nil
}
func TestEbookReaderTracksBytesWhilePourIsActiveAndFlushesBeforeRemoval(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "large.epub")
if err := os.WriteFile(filePath, make([]byte, 2<<20), 0o644); err != nil {
t.Fatal(err)
}
registry := transfers.New()
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{file: &models.MediaFile{
ID: 42, ContentID: "ebook-1", FilePath: filePath, Container: "epub", BaseType: "ebook",
}},
ItemAccess: stubItemAccessChecker{},
})
handler.Transfers = registry
req := withEbookReaderRouteParams(newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read"), "ebook-1", "42")
writer := &blockingEbookWriter{
header: make(http.Header),
reached: make(chan struct{}),
release: make(chan struct{}),
}
done := make(chan struct{})
go func() {
defer close(done)
handler.HandleReadFile(writer, req)
}()
select {
case <-writer.reached:
case <-time.After(5 * time.Second):
t.Fatal("ebook pour did not reach the blocking writer")
}
active := registry.Snapshot()
if len(active) != 1 || active[0].Route != "ebook_read" || active[0].BytesServed == 0 {
t.Fatalf("active transfer = %+v, want metered ebook_read pour", active)
}
close(writer.release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("ebook pour did not finish")
}
if got := registry.Snapshot(); len(got) != 0 {
t.Fatalf("registry after completed pour = %+v, want empty", got)
}
}
type revokingEbookResolver struct {
file *models.MediaFile
revoke func()
}
func (r revokingEbookResolver) GetByID(context.Context, int) (*models.MediaFile, error) {
r.revoke()
return r.file, nil
}
func TestEbookReaderRefusesRevocationLandingBeforePour(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "book.epub")
store := streamrevoke.New(streamrevoke.Options{})
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: revokingEbookResolver{
file: &models.MediaFile{
ID: 42, ContentID: "ebook-1", FilePath: filePath, Container: "epub", BaseType: "ebook",
},
revoke: func() {
if err := store.RevokeUser(context.Background(), 1, "test"); err != nil {
t.Fatal(err)
}
},
},
ItemAccess: stubItemAccessChecker{},
})
handler.Revocation = store
req := withEbookReaderRouteParams(newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read"), "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("status = %d body = %q, want 403", rr.Code, rr.Body.String())
}
}
func TestEbookReaderRevocationCutsInFlightRealSocket(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "large-cut.epub")
if err := os.Truncate(filePath, 128<<20); err != nil {
t.Fatal(err)
}
store := streamrevoke.New(streamrevoke.Options{WatchInterval: 50 * time.Millisecond})
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{file: &models.MediaFile{
ID: 42, ContentID: "ebook-1", FilePath: filePath, Container: "epub", BaseType: "ebook",
}},
ItemAccess: stubItemAccessChecker{},
})
handler.Revocation = store
router := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), &auth.Claims{
UserID: 1, Role: "user", TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", "ebook-1")
routeCtx.URLParams.Add("file_id", "42")
ctx = context.WithValue(ctx, chi.RouteCtxKey, routeCtx)
handler.HandleReadFile(w, r.WithContext(ctx))
})
server := httptest.NewServer(router)
defer server.Close()
response, err := http.Get(server.URL + "/ebooks/ebook-1/files/42/read")
if err != nil {
if response != nil {
_ = response.Body.Close()
}
t.Fatal(err)
}
defer func() { _ = response.Body.Close() }()
if err := store.RevokeUser(context.Background(), 1, "test"); err != nil {
t.Fatal(err)
}
readDone := make(chan error, 1)
go func() {
_, readErr := io.Copy(io.Discard, response.Body)
readDone <- readErr
}()
select {
case readErr := <-readDone:
if readErr == nil {
t.Fatal("client reached EOF; ebook revocation did not hang up the socket")
}
case <-time.After(8 * time.Second):
t.Fatal("revocation did not cut the in-flight ebook pour")
}
}
func TestEbookReaderServesContainerMimeForMismatchedExtension(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "book.txt")
if err := os.WriteFile(filePath, []byte("<html>not really</html>"), 0o644); err != nil {
+19 -2
View File
@@ -3889,9 +3889,26 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
}
// Proxied transcode output can stream past the server's absolute
// WriteTimeout; roll the write deadline with progress instead.
sw := httpstream.NewRollingDeadlineWriter(w)
dst := w
var metered *playback.SessionMeteredWriter
if !strings.HasSuffix(path, ".m3u8") {
if err := h.sessionMgr.BeginTransport(sessionID); err == nil {
defer func() { _ = h.sessionMgr.EndTransport(sessionID) }()
}
recorder, _ := h.sessionMgr.(playback.ServedBytesRecorder)
metered = playback.NewSessionMeteredWriter(w, recorder, sessionID)
defer func() { _ = metered.Close() }()
dst = metered
}
sw := httpstream.NewRollingDeadlineWriterCtx(r.Context(), dst)
sw.WriteHeader(resp.StatusCode)
io.Copy(sw, resp.Body)
if _, copyErr := io.Copy(sw, resp.Body); copyErr != nil {
slog.DebugContext(r.Context(), "copy transcode node response",
"component", "api",
"playback_session_id", sessionID,
"error", copyErr,
)
}
}
// maybeStartThrottler reads throttle settings and starts the throttler if enabled.
+120
View File
@@ -1,17 +1,21 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
@@ -35,6 +39,32 @@ import (
"github.com/Silo-Server/silo-server/internal/watchsync"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
type recordingProxySessionManager struct {
*playback.SessionManager
events []string
}
func (m *recordingProxySessionManager) BeginTransport(sessionID string) error {
m.events = append(m.events, "begin")
return m.SessionManager.BeginTransport(sessionID)
}
func (m *recordingProxySessionManager) EndTransport(sessionID string) error {
m.events = append(m.events, "end")
return m.SessionManager.EndTransport(sessionID)
}
func (m *recordingProxySessionManager) AddServedBytes(sessionID string, n int64) error {
m.events = append(m.events, fmt.Sprintf("bytes:%d", n))
return m.SessionManager.AddServedBytes(sessionID, n)
}
type testUserStoreProvider struct {
store userstore.UserStore
}
@@ -1981,6 +2011,96 @@ func TestHandleChangeAudioTrack_RemoteTranscodeNodeUnreachableSurfacesError(t *t
}
}
func TestProxyToTranscodeNodeMetersOnlyMediaBodiesAndFlushesBeforeTransportEnd(t *testing.T) {
const body = "segment-bytes"
previousTransport := http.DefaultClient.Transport
http.DefaultClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBufferString(body)),
}, nil
})
t.Cleanup(func() { http.DefaultClient.Transport = previousTransport })
for _, tc := range []struct {
name string
path string
wantBytes int64
wantEvents []string
}{
{
name: "segment",
path: "/transcode/s/segment/part.m4s",
wantBytes: int64(len(body)),
wantEvents: []string{"begin", fmt.Sprintf("bytes:%d", len(body)), "end"},
},
{name: "playlist body", path: "/transcode/s/master.m3u8"},
} {
t.Run(tc.name, func(t *testing.T) {
manager := &recordingProxySessionManager{SessionManager: playback.NewSessionManager(0, 0)}
session, err := manager.StartSession(1, "profile-1", 42, playback.PlayTranscode, false)
if err != nil {
t.Fatal(err)
}
handler := NewPlaybackHandler(manager)
handler.JWTSecret = "test-secret"
req := httptest.NewRequest(http.MethodGet, "/stream/"+session.ID, nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", session.ID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handler.proxyToTranscodeNode(rec, req, "http://transcode.test", tc.path)
got, err := manager.GetSession(session.ID)
if err != nil {
t.Fatal(err)
}
if got.BytesServed != tc.wantBytes {
t.Fatalf("bytes served = %d, want %d", got.BytesServed, tc.wantBytes)
}
if !slices.Equal(manager.events, tc.wantEvents) {
t.Fatalf("events = %v, want %v", manager.events, tc.wantEvents)
}
})
}
}
type failingCopyBody struct{}
func (failingCopyBody) Read([]byte) (int, error) { return 0, errors.New("copy failed") }
func (failingCopyBody) Close() error { return nil }
func TestProxyToTranscodeNodeLogsCopyFailure(t *testing.T) {
previousTransport := http.DefaultClient.Transport
http.DefaultClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: failingCopyBody{},
}, nil
})
t.Cleanup(func() { http.DefaultClient.Transport = previousTransport })
var logs bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(previousLogger) })
handler := NewPlaybackHandler(failingSessionManager{})
req := httptest.NewRequest(http.MethodGet, "/stream/session-1", nil)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", "session-1")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
handler.proxyToTranscodeNode(httptest.NewRecorder(), req, "http://transcode.test", "/transcode/session-1/segment/part.m4s")
if !strings.Contains(logs.String(), "copy transcode node response") || !strings.Contains(logs.String(), "copy failed") {
t.Fatalf("copy failure log missing: %s", logs.String())
}
}
func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T) {
sessionMgr := playback.NewSessionManager(0, 0)
filePath := writePlaybackTestMediaFile(t, "movie.mkv")
+40 -6
View File
@@ -224,6 +224,20 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
return
}
beginTransport := func() (http.ResponseWriter, func()) {
transportStarted := false
if err := h.sessionMgr.BeginTransport(sessionID); err == nil {
transportStarted = true
}
recorder, _ := h.sessionMgr.(playback.ServedBytesRecorder)
metered := playback.NewSessionMeteredWriter(w, recorder, sessionID)
return metered, func() {
_ = metered.Close()
if transportStarted {
_ = h.sessionMgr.EndTransport(sessionID)
}
}
}
externalCount := len(file.ExternalSubtitles)
if trackIndex < externalCount {
@@ -237,7 +251,9 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
"Failed to load external subtitle")
return
}
playback.ServeSubtitle(w, data, "ass")
streamWriter, finish := beginTransport()
defer finish()
playback.ServeSubtitle(streamWriter, data, "ass")
return
}
@@ -247,7 +263,9 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
"Failed to load external subtitle")
return
}
playback.ServeSubtitle(w, vttData, "vtt")
streamWriter, finish := beginTransport()
defer finish()
playback.ServeSubtitle(streamWriter, vttData, "vtt")
return
}
@@ -270,7 +288,9 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
// demuxed, so the first byte lands within ~1s even on network
// storage. Works identically for direct-play, remux, and
// transcode because it doesn't depend on any other ffmpeg.
h.streamEmbeddedSubtitle(w, r, file, embeddedIndex, session, requestedFormat)
streamWriter, finish := beginTransport()
defer finish()
h.streamEmbeddedSubtitle(streamWriter, r, file, embeddedIndex, session, requestedFormat)
return
}
@@ -302,13 +322,17 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
// Serve ASS/SSA downloaded subtitles as raw data.
if playback.IsASS(string(dl.Format)) && requestedFormat != "vtt" {
playback.ServeSubtitle(w, data, "ass")
streamWriter, finish := beginTransport()
defer finish()
playback.ServeSubtitle(streamWriter, data, "ass")
return
}
// If the subtitle is already VTT, serve directly.
if dl.Format == subtitles.FormatVTT {
playback.ServeSubtitle(w, data, "vtt")
streamWriter, finish := beginTransport()
defer finish()
playback.ServeSubtitle(streamWriter, data, "vtt")
return
}
@@ -318,7 +342,9 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
return
}
playback.ServeSubtitle(w, vttData, "vtt")
streamWriter, finish := beginTransport()
defer finish()
playback.ServeSubtitle(streamWriter, vttData, "vtt")
return
}
}
@@ -426,6 +452,14 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque
return
}
if err := h.sessionMgr.BeginTransport(sessionID); err == nil {
defer func() { _ = h.sessionMgr.EndTransport(sessionID) }()
}
recorder, _ := h.sessionMgr.(playback.ServedBytesRecorder)
metered := playback.NewSessionMeteredWriter(w, recorder, sessionID)
defer func() { _ = metered.Close() }()
w = metered
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Cache-Control", "no-store")
+8 -3
View File
@@ -36,6 +36,7 @@ import (
"github.com/Silo-Server/silo-server/internal/downloads"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/intromarkers"
"github.com/Silo-Server/silo-server/internal/invitations"
"github.com/Silo-Server/silo-server/internal/libraryingest"
@@ -687,6 +688,8 @@ func NewRouter(deps Dependencies) chi.Router {
if conv := buildEbookConversion(deps, settingsRepo); conv != nil {
ebookReaderHandler.Conversion = conv
}
ebookReaderHandler.Transfers = deps.TransferRegistry
ebookReaderHandler.Revocation = deps.RevocationStore
}
catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler)
catalogHandler = handlers.NewCatalogHandler(
@@ -2540,8 +2543,8 @@ func NewRouter(deps Dependencies) chi.Router {
if streamHandler != nil {
r.Get("/stream/{session_id}", guardRevocationCut(deps.RevocationStore, configJWTSecret(deps), streamHandler.HandleStream))
r.Head("/stream/{session_id}", guardRevocationCut(deps.RevocationStore, configJWTSecret(deps), streamHandler.HandleStream))
r.Get("/stream/{session_id}/subtitles/{track}", guardRevocation(deps.RevocationStore, configJWTSecret(deps), streamHandler.HandleSubtitle))
r.Get("/stream/{session_id}/subtitles/{track}/fonts", guardRevocation(deps.RevocationStore, configJWTSecret(deps), streamHandler.HandleSubtitleFonts))
r.Get("/stream/{session_id}/subtitles/{track}", guardRevocationCut(deps.RevocationStore, configJWTSecret(deps), streamHandler.HandleSubtitle))
r.Get("/stream/{session_id}/subtitles/{track}/fonts", guardRevocationCut(deps.RevocationStore, configJWTSecret(deps), streamHandler.HandleSubtitleFonts))
}
// Download routes.
@@ -3566,10 +3569,12 @@ func guardRevocationCut(store *streamrevoke.Store, secret string, next http.Hand
return func(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "session_id")
userID, startedAt := streamRequestIdentity(r, secret)
latch := &httpstream.CutLatch{}
r = r.WithContext(httpstream.WithCutLatch(r.Context(), latch))
if store.Refuse(w, sessionID, userID, startedAt) {
return
}
stop := store.WatchAndCut(w, sessionID, userID, startedAt)
stop := store.WatchAndCutContext(r.Context(), w, sessionID, userID, startedAt)
defer stop()
next(w, r)
}
+8
View File
@@ -87,6 +87,14 @@ type statusRecorder struct {
bytes int
}
// Unwrap exposes the wrapped ResponseWriter to http.NewResponseController so
// write-deadline control — the revocation in-flight cut — reaches the real
// socket. Without this, SetWriteDeadline returns ErrNotSupported and a
// RevokeUser cannot hang up an in-flight multi-GB audiobook pour.
func (s *statusRecorder) Unwrap() http.ResponseWriter {
return s.ResponseWriter
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
+9 -7
View File
@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/transfers"
)
@@ -56,11 +57,10 @@ func trackInoFor(contentID string, fileIdx int) string {
// - Set Content-Disposition: attachment on /download paths to encourage
// browser save-to-disk / mobile offline-save behaviour.
//
// This is a download-class route: like native and Jellyfin-compatible
// downloads it is exempt from the live-stream cap and never enters
// streammonitor. Its active pour is exposed through the separate process-local
// transfer registry. It is not yet covered by a download quota and remains
// revocable before and during the file pour.
// Both forms serve real playback bytes, but per A4 they are deliberately
// exempt from the live-stream cap and absent from streammonitor. Each active
// pour is still metered, registered in the process-local transfer registry,
// and revocable before and during transfer.
func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
@@ -72,6 +72,7 @@ func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
if h.deps.Revocation != nil && h.deps.Revocation.Refuse(w, "", userID, a.IssuedAt) {
return
}
@@ -156,7 +157,7 @@ func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) {
defer func() { _ = metered.Close() }()
if h.deps.Revocation != nil {
stop := h.deps.Revocation.WatchAndCut(metered, "", userID, a.IssuedAt)
stop := h.deps.Revocation.WatchAndCutContext(r.Context(), metered, "", userID, a.IssuedAt)
defer stop()
}
if err := playback.ServeDirectPlay(metered, r, mediaFile.FilePath); err != nil {
@@ -221,6 +222,7 @@ func (h *Handler) handlePublicTrack(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session owner invalid", http.StatusForbidden)
return
}
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
if h.deps.Revocation != nil && h.deps.Revocation.Refuse(w, sid, userID, sess.StartedAt) {
return
}
@@ -251,7 +253,7 @@ func (h *Handler) handlePublicTrack(w http.ResponseWriter, r *http.Request) {
recorder, _ := h.deps.NativeSessions.(playback.ServedBytesRecorder)
metered := playback.NewSessionMeteredWriter(w, recorder, sid)
defer func() { _ = metered.Close() }()
stop := h.deps.Revocation.WatchAndCut(metered, sid, userID, sess.StartedAt)
stop := h.deps.Revocation.WatchAndCutContext(r.Context(), metered, sid, userID, sess.StartedAt)
defer stop()
_ = playback.ServeDirectPlay(metered, r, mediaFile.FilePath)
}
@@ -2,17 +2,91 @@ package abs
import (
"context"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
)
var _ interface{ Unwrap() http.ResponseWriter } = (*statusRecorder)(nil)
func TestStatusRecorderLetsResponseControllerReachSocketDeadline(t *testing.T) {
base := &deadlineResponseRecorder{ResponseRecorder: httptest.NewRecorder()}
wrapped := &statusRecorder{ResponseWriter: base}
if err := http.NewResponseController(wrapped).SetWriteDeadline(time.Now()); err != nil {
t.Fatalf("SetWriteDeadline through statusRecorder: %v", err)
}
if base.deadlines != 1 {
t.Fatalf("underlying deadline calls = %d, want 1", base.deadlines)
}
}
func TestMountedAccessLogMiddlewareLetsRevocationCutRealSocket(t *testing.T) {
store := streamrevoke.New(streamrevoke.Options{WatchInterval: 50 * time.Millisecond})
startedAt := time.Now()
started := make(chan struct{})
var startedOnce sync.Once
h := &Handler{}
router := chi.NewRouter()
router.Use(h.accessLog)
router.Get("/pour", func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
stop := store.WatchAndCutContext(r.Context(), w, "abs-mounted", 1, startedAt)
defer stop()
sw := httpstream.NewRollingDeadlineWriterCtx(r.Context(), w)
sw.WriteHeader(http.StatusOK)
chunk := make([]byte, 32<<10)
for {
if _, err := sw.Write(chunk); err != nil {
return
}
sw.Flush()
startedOnce.Do(func() { close(started) })
time.Sleep(10 * time.Millisecond)
}
})
server := httptest.NewServer(router)
defer server.Close()
response, err := http.Get(server.URL + "/pour")
if err != nil {
if response != nil {
_ = response.Body.Close()
}
t.Fatal(err)
}
defer func() { _ = response.Body.Close() }()
select {
case <-started:
case <-time.After(3 * time.Second):
t.Fatal("mounted ABS pour did not start")
}
if err := store.RevokeSession(context.Background(), "abs-mounted", "test"); err != nil {
t.Fatal(err)
}
readDone := make(chan error, 1)
go func() {
_, readErr := io.Copy(io.Discard, response.Body)
readDone <- readErr
}()
select {
case readErr := <-readDone:
if readErr == nil {
t.Fatal("client reached EOF; revocation did not hang up the socket")
}
case <-time.After(8 * time.Second):
t.Fatal("revocation did not cut the mounted ABS pour")
}
}
func TestBearerAuthCarriesJWTTimestampForUserCutoff(t *testing.T) {
const accessTokenType = "access"
secret := []byte("test-secret-32-bytes-aaaaaaaaaaaaa")
+3 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/google/uuid"
"github.com/oklog/ulid/v2"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/transfers"
@@ -232,6 +233,7 @@ func (h *Handler) handlePublicFeedFile(w http.ResponseWriter, r *http.Request) {
http.Error(w, "feed not found", http.StatusNotFound)
return
}
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
if h.deps.Revocation != nil && h.deps.Revocation.Refuse(w, "", userID, f.CreatedAt) {
return
}
@@ -266,7 +268,7 @@ func (h *Handler) handlePublicFeedFile(w http.ResponseWriter, r *http.Request) {
defer func() { _ = metered.Close() }()
if h.deps.Revocation != nil {
stop := h.deps.Revocation.WatchAndCut(metered, "", userID, f.CreatedAt)
stop := h.deps.Revocation.WatchAndCutContext(r.Context(), metered, "", userID, f.CreatedAt)
defer stop()
}
http.ServeFile(metered, r, mf.FilePath)
+17 -7
View File
@@ -22,6 +22,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -56,6 +57,7 @@ func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Reque
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
startedAt := time.Now()
routeID := chiURLParam(r, "id")
mediaSourceID := firstNonEmpty(r.URL.Query().Get("mediaSourceId"), r.URL.Query().Get("MediaSourceId"))
@@ -97,7 +99,7 @@ func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Reque
// stream URL. (User-kind kills don't need this: they match by user id either
// side of the ensure.)
if h.Revocation != nil && playSession.UpstreamSessionID != "" &&
h.Revocation.Refuse(w, playSession.UpstreamSessionID, session.StreamAppUserID, time.Now()) {
h.Revocation.Refuse(w, playSession.UpstreamSessionID, session.StreamAppUserID, startedAt) {
return
}
@@ -114,7 +116,7 @@ func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Reque
// request only gets here on a live compat login, and user revocation deletes
// all compat logins, so reaching this line post-revocation means fresh auth.
if h.Revocation != nil && playSession.UpstreamSessionID != "" &&
h.Revocation.Refuse(w, playSession.UpstreamSessionID, session.StreamAppUserID, time.Now()) {
h.Revocation.Refuse(w, playSession.UpstreamSessionID, session.StreamAppUserID, startedAt) {
return
}
@@ -155,8 +157,9 @@ func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Reque
// In-flight cut: hang up a long local direct-play/remux pour the moment it is
// revoked. The Refuse check above only covers new/reconnect requests; a single
// long GET needs the connection cut to stop mid-stream.
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
if h.Revocation != nil && playSession.UpstreamSessionID != "" {
stop := h.Revocation.WatchAndCut(w, playSession.UpstreamSessionID, session.StreamAppUserID, time.Now())
stop := h.Revocation.WatchAndCutContext(r.Context(), w, playSession.UpstreamSessionID, session.StreamAppUserID, startedAt)
defer stop()
}
recorder, _ := h.sessionMgr.(playback.ServedBytesRecorder)
@@ -258,7 +261,8 @@ func (h *PlaybackHandler) HandleDownload(w http.ResponseWriter, r *http.Request)
// the entry time predates any future revocation (the user-kill cutoff), so a
// revocation issued mid-transfer hangs this connection up.
if h.Revocation != nil {
stop := h.Revocation.WatchAndCut(metered, "", session.StreamAppUserID, transfer.StartedAt)
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
stop := h.Revocation.WatchAndCutContext(r.Context(), metered, "", session.StreamAppUserID, transfer.StartedAt)
defer stop()
}
@@ -680,13 +684,19 @@ func (h *PlaybackHandler) HandleSubtitleStream(w http.ResponseWriter, r *http.Re
return
}
startedAt := time.Now()
// Kill switch: a revoked session must not keep pulling subtitle bytes (or
// triggering server-side ffmpeg subtitle extraction below). Mirrors the
// guarded native subtitle routes.
// triggering server-side ffmpeg subtitle extraction below). Compat
// extraction is buffered, so a socket cut stops response delivery but does
// not interrupt extraction already in progress.
if h.Revocation != nil && playSession != nil && playSession.UpstreamSessionID != "" &&
h.Revocation.Refuse(w, playSession.UpstreamSessionID, session.StreamAppUserID, time.Now()) {
h.Revocation.Refuse(w, playSession.UpstreamSessionID, session.StreamAppUserID, startedAt) {
return
}
if h.Revocation != nil && playSession != nil && playSession.UpstreamSessionID != "" {
stop := h.Revocation.WatchAndCutContext(r.Context(), w, playSession.UpstreamSessionID, session.StreamAppUserID, startedAt)
defer stop()
}
if h.fileResolver == nil {
writeError(w, http.StatusInternalServerError, "ServerError", "File resolver not available")
+63
View File
@@ -0,0 +1,63 @@
package proxy
import (
"context"
"net"
"testing"
"github.com/Silo-Server/silo-server/internal/nodesessions"
"github.com/redis/go-redis/v9"
)
type deleteCaptureHook struct {
called bool
contextErr error
}
func (h *deleteCaptureHook) DialHook(next redis.DialHook) redis.DialHook {
return next
}
func (h *deleteCaptureHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
if cmd.Name() == "del" {
h.called = true
h.contextErr = ctx.Err()
return nil
}
return next(ctx, cmd)
}
}
func (h *deleteCaptureHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return next
}
func TestRemoveTrackedUsesLiveContextAfterRequestCancellation(t *testing.T) {
rdb := redis.NewClient(&redis.Options{
Dialer: func(context.Context, string, string) (net.Conn, error) {
t.Fatal("DEL should be intercepted before dialing")
return nil, nil
},
})
t.Cleanup(func() { _ = rdb.Close() })
hook := &deleteCaptureHook{}
rdb.AddHook(hook)
server := &Server{tracker: nodesessions.NewTracker(rdb, "http://node", "node", "proxy")}
requestCtx, cancel := context.WithCancel(context.Background())
cancel()
if requestCtx.Err() == nil {
t.Fatal("request context was not canceled")
}
func() {
defer server.removeTracked("session-1")
}()
if !hook.called {
t.Fatal("Redis DEL was not attempted")
}
if hook.contextErr != nil {
t.Fatalf("DEL context error = %v, want live bounded cleanup context", hook.contextErr)
}
}
+60
View File
@@ -0,0 +1,60 @@
package proxy
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/nodesessions"
"github.com/Silo-Server/silo-server/internal/streamtoken"
)
type latchCapturingRevocationStore struct {
latch *httpstream.CutLatch
}
func (s *latchCapturingRevocationStore) IsRevoked(string, int, time.Time) bool {
return false
}
func (s *latchCapturingRevocationStore) Refuse(http.ResponseWriter, string, int, time.Time) bool {
return false
}
func (s *latchCapturingRevocationStore) WatchAndCutContext(ctx context.Context, _ http.ResponseWriter, _ string, _ int, _ time.Time) func() {
s.latch = httpstream.CutLatchFrom(ctx)
return func() {}
}
func TestHandleRemuxCarriesCutLatchToRevocationWatcher(t *testing.T) {
const secret = "proxy-revocation-test-secret"
watcher := nodeconfig.NewWatcher(nil, nil, nil, nodeconfig.BootstrapOverrides{})
cfg := &config.Config{}
cfg.Auth.JWTSecret = secret
watcher.SetConfigForTest(cfg)
server := NewServer(watcher, nodesessions.NewTracker(nil, "http://proxy", "proxy", "proxy"))
revocation := &latchCapturingRevocationStore{}
server.SetRevocationStore(revocation)
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "remux-latch",
MediaPath: t.TempDir() + "/missing.mkv",
}, secret, time.Minute)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/stream/remux/"+token, nil)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
if revocation.latch == nil {
t.Fatal("remux revocation watcher request context has no cut latch")
}
}
+32 -7
View File
@@ -1,6 +1,7 @@
package proxy
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -14,6 +15,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/nodesessions"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -26,7 +28,7 @@ import (
type revocationStore interface {
IsRevoked(sessionID string, userID int, startedAt time.Time) bool
Refuse(w http.ResponseWriter, sessionID string, userID int, startedAt time.Time) bool
WatchAndCut(w http.ResponseWriter, sessionID string, userID int, startedAt time.Time) func()
WatchAndCutContext(ctx context.Context, w http.ResponseWriter, sessionID string, userID int, startedAt time.Time) func()
}
// Server is the HTTP handler for proxy mode.
@@ -172,14 +174,25 @@ func (s *Server) verifyToken(w http.ResponseWriter, r *http.Request) *streamtoke
// cutOnRevocation watches a long-lived pour (direct play / remux) and hangs up
// the socket the moment the session/user is revoked, via the shared store helper.
// Returns a stop func to cancel the watcher when the request finishes normally.
func (s *Server) cutOnRevocation(w http.ResponseWriter, claims *streamtoken.Claims) func() {
func (s *Server) cutOnRevocation(ctx context.Context, w http.ResponseWriter, claims *streamtoken.Claims) func() {
if s.revocation == nil {
return func() {}
}
return s.revocation.WatchAndCut(w, claims.SessionID, claims.UserID, claims.IssuedTime())
return s.revocation.WatchAndCutContext(ctx, w, claims.SessionID, claims.UserID, claims.IssuedTime())
}
// removeTracked deletes the edge record with a bounded background context. The
// request context is already canceled by the time this defer runs on a client
// disconnect, which would skip the Redis DEL and leave a phantom session until
// TTL — a false over-cap window.
func (s *Server) removeTracked(sessionID string) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
s.tracker.Remove(ctx, sessionID)
}
func (s *Server) handleDirectPlay(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
claims := s.verifyToken(w, r)
if claims == nil {
return
@@ -187,12 +200,12 @@ func (s *Server) handleDirectPlay(w http.ResponseWriter, r *http.Request) {
info := sessionInfo(s.tracker, claims, "direct_play", edgeClientIP(r))
s.tracker.Track(r.Context(), info)
defer s.tracker.Remove(r.Context(), claims.SessionID)
defer s.removeTracked(claims.SessionID)
sw := &sessionByteWriter{ResponseWriter: w, tracker: s.tracker, sessionID: claims.SessionID}
defer sw.flush()
stop := s.cutOnRevocation(sw, claims)
stop := s.cutOnRevocation(r.Context(), sw, claims)
defer stop()
http.ServeFile(sw, r, claims.MediaPath)
@@ -272,6 +285,7 @@ func (w *sessionByteWriter) Unwrap() http.ResponseWriter {
}
func (s *Server) handleRemux(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
claims := s.verifyToken(w, r)
if claims == nil {
return
@@ -279,12 +293,12 @@ func (s *Server) handleRemux(w http.ResponseWriter, r *http.Request) {
info := sessionInfo(s.tracker, claims, "remux", edgeClientIP(r))
s.tracker.Track(r.Context(), info)
defer s.tracker.Remove(r.Context(), claims.SessionID)
defer s.removeTracked(claims.SessionID)
sw := &sessionByteWriter{ResponseWriter: w, tracker: s.tracker, sessionID: claims.SessionID}
defer sw.flush()
stop := s.cutOnRevocation(sw, claims)
stop := s.cutOnRevocation(r.Context(), sw, claims)
defer stop()
seekSeconds := 0.0
@@ -369,10 +383,21 @@ func edgeClientIP(r *http.Request) string {
}
func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
claims := s.verifyToken(w, r)
if claims == nil {
return
}
// Attribute subtitle bytes only when the session already has a tracker
// record. Do not take lifecycle ownership here: Track/Remove per subtitle
// request can delete a concurrently active media request until Batch 2 adds
// reference-counted edge tracking.
metered := &sessionByteWriter{ResponseWriter: w, tracker: s.tracker, sessionID: claims.SessionID}
defer metered.flush()
stop := s.cutOnRevocation(r.Context(), metered, claims)
defer stop()
w = metered
cfg := s.watcher.Config()
trackParam := chi.URLParam(r, "track")
trackIndex, requestedFormat, err := playback.ParseSubtitleTrackParam(trackParam)
+3
View File
@@ -227,6 +227,8 @@ func mergeStreams(streams []LiveStream) []LiveStream {
if winner.Position == 0 {
winner.Position = other.Position
}
// These are observers of one pour, not independent byte sources.
winner.BytesServed = max(winner.BytesServed, other.BytesServed)
bySession[st.SessionID] = winner
}
out := make([]LiveStream, 0, len(bySession))
@@ -278,6 +280,7 @@ func DedupeSessionInfos(infos []nodesessions.SessionInfo) []nodesessions.Session
if winner.Position == 0 {
winner.Position = other.Position
}
winner.BytesServed = max(winner.BytesServed, other.BytesServed)
bySession[in.SessionID] = winner
}
out := make([]nodesessions.SessionInfo, 0, len(bySession))
+44
View File
@@ -213,6 +213,27 @@ func TestMergeStreamsBackfillsAttribution(t *testing.T) {
}
}
func TestMergeStreamsKeepsLargestObservedByteTotal(t *testing.T) {
for _, tc := range []struct {
name string
older, newer int64
want int64
}{
{name: "freshest has zero", older: 8192, newer: 0, want: 8192},
{name: "stale has zero", older: 0, newer: 8192, want: 8192},
} {
t.Run(tc.name, func(t *testing.T) {
out := mergeStreams([]LiveStream{
{SessionID: "s", BytesServed: tc.older, LastServedAt: time.Unix(100, 0)},
{SessionID: "s", BytesServed: tc.newer, LastServedAt: time.Unix(200, 0)},
})
if len(out) != 1 || out[0].BytesServed != tc.want {
t.Fatalf("merge = %+v, want bytes %d", out, tc.want)
}
})
}
}
func TestFuncSourceNilFn(t *testing.T) {
snap, err := NewFuncSource(nil).Snapshot(context.Background())
if err != nil {
@@ -274,3 +295,26 @@ func TestDedupeSessionInfos(t *testing.T) {
t.Errorf("s2 mangled by dedupe: %+v", s2)
}
}
func TestDedupeSessionInfosKeepsLargestObservedByteTotal(t *testing.T) {
older := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano)
newer := time.Now().UTC().Format(time.RFC3339Nano)
for _, tc := range []struct {
name string
older, newer int64
want int64
}{
{name: "freshest has zero", older: 4096, newer: 0, want: 4096},
{name: "stale has zero", older: 0, newer: 4096, want: 4096},
} {
t.Run(tc.name, func(t *testing.T) {
out := DedupeSessionInfos([]nodesessions.SessionInfo{
{SessionID: "s", BytesServed: tc.older, LastServedAt: older},
{SessionID: "s", BytesServed: tc.newer, LastServedAt: newer},
})
if len(out) != 1 || out[0].BytesServed != tc.want {
t.Fatalf("dedupe = %+v, want bytes %d", out, tc.want)
}
})
}
}