fix(artwork): close the underlying stream from the tee; rename to tee_reader

The tee was built around io.NopCloser(r) and teeCachedStream.Close only closed the
tee, so the underlying CachedStream (an open cache-file fd, or the raw source stream
when the image cache is disabled) was never closed — one fd leaked per teed serve,
enough to exhaust the process limit during a client's initial cover sync. The tee now
wraps the stream directly and its Close propagates; teeCachedStream is gone (all
artwork handlers io.Copy, none Seek). The file is renamed to tee_reader.go since the
wrapper is generic, not blurhash-specific.
This commit is contained in:
Deluan 2026-07-17 21:13:03 -04:00
parent b63c9b095b
commit 7307fd716b
3 changed files with 20 additions and 16 deletions

View File

@ -104,9 +104,10 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa
if a.blurHashes != nil && size == 0 && !square && eligibleKind(artID) {
// Tee the served bytes: the blurhash is computed from exactly what the client downloads, so it
// changes precisely when the served cover changes. Placeholder bytes (playlist fallback) clear.
// The tee wraps r directly, so Close reaches the underlying stream (no fd leak).
version := capAtNow(artReader.LastUpdated())
reader = &teeCachedStream{CachedStream: r, tee: newTeeReader(io.NopCloser(r), maxTeeBytes,
func(data []byte) { a.blurHashes.EnqueueBytes(artID, data, version) })}
reader = newTeeReader(r, maxTeeBytes,
func(data []byte) { a.blurHashes.EnqueueBytes(artID, data, version) })
}
return reader, artReader.LastUpdated(), nil
}

View File

@ -3,13 +3,11 @@ package artwork
import (
"bytes"
"io"
"github.com/navidrome/navidrome/utils/cache"
)
// teeReader mirrors bytes read from src into buf, and on Close invokes onComplete with the captured
// bytes only if the stream was fully consumed (EOF) and stayed within maxBytes. Partial reads and
// oversized streams are skipped, so a hash is only ever computed from a complete, bounded image.
// oversized streams are skipped, so the callback only ever receives a complete, bounded payload.
type teeReader struct {
src io.ReadCloser
buf bytes.Buffer
@ -48,14 +46,3 @@ func (t *teeReader) Close() error {
}
return err
}
// teeCachedStream wraps a *cache.CachedStream so reads are teed for blurhash capture while callers
// still see a ReadCloser. Seek is intentionally dropped: blurhash-eligible serves are full reads
// (every artwork handler does io.Copy), so no caller Seeks a teed stream.
type teeCachedStream struct {
*cache.CachedStream
tee *teeReader
}
func (t *teeCachedStream) Read(p []byte) (int, error) { return t.tee.Read(p) }
func (t *teeCachedStream) Close() error { return t.tee.Close() }

View File

@ -8,7 +8,23 @@ import (
. "github.com/onsi/gomega"
)
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }
var _ = Describe("teeReader", func() {
It("closes the underlying source exactly once", func() {
src := &closeSpy{Reader: bytes.NewReader([]byte("hello"))}
tr := newTeeReader(src, 1024, func([]byte) {})
_, err := io.ReadAll(tr)
Expect(err).ToNot(HaveOccurred())
Expect(tr.Close()).To(Succeed())
Expect(src.closed).To(BeTrue(), "the source stream must be closed, or its fd leaks")
})
It("calls onComplete with the full bytes after a complete read+close", func() {
var got []byte
src := io.NopCloser(bytes.NewReader([]byte("hello world")))