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.
This commit is contained in:
Deluan 2026-07-26 01:19:13 -04:00
parent 0ce5bd4148
commit 4b9d2178cc
2 changed files with 39 additions and 1 deletions

View File

@ -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) {

View File

@ -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")