mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
b63c9b095b
commit
7307fd716b
@ -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
|
||||
}
|
||||
|
||||
@ -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() }
|
||||
@ -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")))
|
||||
Loading…
x
Reference in New Issue
Block a user