From 4b9d2178ccd2185e6c8efa8705a46907cccaf255 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 26 Jul 2026 01:19:13 -0400 Subject: [PATCH] fix(cache): give a re-created cache entry its own file Create re-opened the path with O_TRUNC, which shrinks the file out from under an older stream that may still be serving readers. stream.Reader then hits EOF from the OS at the new, shorter length while the broadcaster still reports the original size, so Wait() reports 'more data exists' and the reader retries forever - a tight pread loop that burns a core and never releases its handle, which in turn blocks Stream.Remove() indefinitely. Unlink first and create with O_EXCL so the new entry gets a fresh inode. Existing readers keep their descriptor on the old inode, see its full contents, and reach a clean EOF. Note this does not address the deferred unlink deleting the re-created file, which is handled separately by the re-fetch in fileCache.Get. --- utils/cache/spread_fs.go | 7 ++++++- utils/cache/spread_fs_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/utils/cache/spread_fs.go b/utils/cache/spread_fs.go index 647439790..131f8a708 100644 --- a/utils/cache/spread_fs.go +++ b/utils/cache/spread_fs.go @@ -128,7 +128,12 @@ func (sfs *spreadFS) Create(name string) (stream.File, error) { if err != nil { return nil, err } - return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + // Unlink instead of truncating: an older stream may still be serving readers from + // this path, and shrinking the file under them spins them at a premature EOF. + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + return nil, err + } + return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) } func (sfs *spreadFS) Open(name string) (stream.File, error) { diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go index 0f88d3a58..ae2d86f05 100644 --- a/utils/cache/spread_fs_test.go +++ b/utils/cache/spread_fs_test.go @@ -1,10 +1,12 @@ package cache import ( + "io" "os" "path/filepath" "strings" + "github.com/djherbis/stream" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -39,6 +41,37 @@ var _ = Describe("Spread FS", func() { }) }) + Describe("Create", func() { + It("leaves an already-open reader's bytes intact", func() { + // A re-created entry must not shrink the file an older stream is still + // serving: its reader would spin forever at the premature EOF. + name := filepath.Join(rootDir, "aa", "bb", "data") + s, err := stream.NewStream(name, fs) + Expect(err).To(BeNil()) + _, err = s.Write([]byte("PARTIAL")) + Expect(err).To(BeNil()) + r, err := s.NextReader() + Expect(err).To(BeNil()) + Expect(s.Close()).To(Succeed()) + + f, err := fs.Create(name) + Expect(err).To(BeNil()) + _, err = f.Write([]byte("GOOD")) + Expect(err).To(BeNil()) + Expect(f.Close()).To(Succeed()) + + done := make(chan []byte, 1) + go func() { + b, _ := io.ReadAll(r) + done <- b + }() + Eventually(done).Should(Receive(Equal([]byte("PARTIAL")))) + Expect(r.Close()).To(Succeed()) + + Expect(os.ReadFile(name)).To(Equal([]byte("GOOD"))) + }) + }) + Describe("MarkComplete / Remove markers", func() { It("creates a .complete marker for a data file", func() { data := fs.KeyMapper("song1")