Skip to content

Commit feb996b

Browse files
authored
Merge pull request #240 from lkinley-rythmos/v4
Fix Reader stale data, Writer buffer leak, and zero-length write
2 parents ace844d + 5dfa772 commit feb996b

4 files changed

Lines changed: 196 additions & 4 deletions

File tree

reader.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ func (r *Reader) read(buf []byte) (int, error) {
207207
}
208208
r.cum += uint32(len(dst))
209209
if direct {
210+
r.data = r.data[:0]
210211
return len(dst), nil
211212
}
212213
r.data = dst

reader_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,108 @@ func TestReader_WriteTo(t *testing.T) {
361361
t.Fatal("result does not match original")
362362
}
363363
}
364+
365+
// TestReader_DirectModeStaleData verifies that a zero-length uncompressed block
366+
// in direct mode does not cause stale pool data to be returned. Before the fix,
367+
// r.data was not cleared after a direct-mode decompress, so a subsequent
368+
// zero-length block would copy stale data from r.data.
369+
func TestReader_DirectModeStaleData(t *testing.T) {
370+
// Build a minimal LZ4 frame containing:
371+
// 1. A normal uncompressed block with known data
372+
// 2. A zero-length uncompressed block (0x80000000)
373+
// 3. End-of-stream marker (0x00000000)
374+
//
375+
// First, compress real data to get a valid frame, then append the
376+
// zero-length block before the end marker.
377+
payload := []byte("hello, world! this is test data for direct mode stale check.")
378+
var compressed bytes.Buffer
379+
zw := lz4.NewWriter(&compressed)
380+
if err := zw.Apply(lz4.ConcurrencyOption(-1), lz4.ChecksumOption(false)); err != nil {
381+
t.Fatal(err)
382+
}
383+
if _, err := zw.Write(payload); err != nil {
384+
t.Fatal(err)
385+
}
386+
if err := zw.Close(); err != nil {
387+
t.Fatal(err)
388+
}
389+
390+
// The frame ends with: [end mark: 00 00 00 00].
391+
// Insert a zero-length uncompressed block (0x80000000) before the end mark.
392+
raw := compressed.Bytes()
393+
endMark := raw[len(raw)-4:] // last 4 bytes = end mark
394+
prefix := raw[:len(raw)-4] // everything before end mark
395+
var frame bytes.Buffer
396+
frame.Write(prefix)
397+
frame.Write([]byte{0x00, 0x00, 0x00, 0x80}) // zero-length uncompressed block
398+
frame.Write(endMark) // end mark
399+
400+
// Read with a large buffer to force the direct path (buf >= block size)
401+
// and concurrency=1. The zero-length block triggers the bn==0 fallback
402+
// in Reader.Read, which would copy stale r.data if the fix is missing.
403+
zr := lz4.NewReader(&frame)
404+
if err := zr.Apply(lz4.ConcurrencyOption(-1)); err != nil {
405+
t.Fatal(err)
406+
}
407+
var result bytes.Buffer
408+
buf := make([]byte, 64*1024) // >= default block size (64KB)
409+
for {
410+
n, readErr := zr.Read(buf)
411+
if n > 0 {
412+
result.Write(buf[:n])
413+
}
414+
if readErr == io.EOF {
415+
break
416+
}
417+
if readErr != nil {
418+
t.Fatal(readErr)
419+
}
420+
}
421+
if !bytes.Equal(result.Bytes(), payload) {
422+
t.Fatalf("decompressed data mismatch: got %d bytes %q, want %d bytes %q",
423+
result.Len(), result.Bytes(), len(payload), payload)
424+
}
425+
}
426+
427+
func TestReader_BufferIssue(t *testing.T) {
428+
for _, opts := range [][]lz4.Option{
429+
nil,
430+
_o(lz4.ConcurrencyOption(2)),
431+
} {
432+
label := fmt.Sprintf("%v", opts)
433+
t.Run(label, func(t *testing.T) {
434+
pr, pw := io.Pipe()
435+
go func(w *io.PipeWriter) {
436+
defer w.Close()
437+
file, err := os.Open("testdata/bundle.00001.part00000.lz4")
438+
if err != nil {
439+
w.CloseWithError(err)
440+
return
441+
}
442+
defer file.Close()
443+
io.Copy(w, file)
444+
}(pw)
445+
data := make([]byte, 1024*1024*4)
446+
lz4Reader := lz4.NewReader(pr)
447+
if err := lz4Reader.Apply(opts...); err != nil {
448+
t.Fatal(err)
449+
}
450+
var total int
451+
for {
452+
n, readErr := lz4Reader.Read(data)
453+
if n > 0 {
454+
total += n
455+
}
456+
if readErr == io.EOF {
457+
break
458+
}
459+
if readErr != nil {
460+
t.Fatal(readErr)
461+
}
462+
}
463+
if total != 128*1024*1024 {
464+
t.Fatalf("incorrect number of bytes: got %d, want %d", total, 128*1024*1024)
465+
}
466+
})
467+
}
468+
}

writer.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ func (w *Writer) Close() error {
188188
//
189189
// w.Close must be called before Reset or pending data may be dropped.
190190
func (w *Writer) Reset(writer io.Writer) {
191+
lz4block.Put(w.data)
192+
w.data = nil
191193
w.frame.Reset(w.num)
192194
w.state.reset()
193195
w.src = writer
@@ -225,11 +227,13 @@ func (w *Writer) ReadFrom(r io.Reader) (n int64, err error) {
225227
return
226228
}
227229
n += int64(rn)
228-
err = w.write(data[:rn], true)
229-
if err != nil {
230-
return
230+
if rn > 0 {
231+
err = w.write(data[:rn], true)
232+
if err != nil {
233+
return
234+
}
235+
w.handler(rn)
231236
}
232-
w.handler(rn)
233237
if !done && !w.isNotConcurrent() {
234238
// The buffer will be returned automatically by go routines (safe=true)
235239
// so get a new one fo the next round.

writer_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,85 @@ func TestWriterConcurrency(t *testing.T) {
380380
t.Fatal(err)
381381
}
382382
}
383+
384+
// TestWriter_ResetWithoutClose verifies that Reset returns the internal buffer
385+
// to the pool even when Close was not called. Before the fix, the buffer would
386+
// leak because Reset did not call lz4block.Put.
387+
func TestWriter_ResetWithoutClose(t *testing.T) {
388+
data := []byte(strings.Repeat("hello world ", 1000))
389+
buf := new(bytes.Buffer)
390+
zw := lz4.NewWriter(buf)
391+
392+
// Write some data but do NOT close.
393+
if _, err := zw.Write(data); err != nil {
394+
t.Fatal(err)
395+
}
396+
397+
// Reset without Close — this should not panic or leak.
398+
buf.Reset()
399+
zw.Reset(buf)
400+
401+
// The writer should still be fully functional after Reset.
402+
if _, err := zw.Write(data); err != nil {
403+
t.Fatal(err)
404+
}
405+
if err := zw.Close(); err != nil {
406+
t.Fatal(err)
407+
}
408+
409+
// Verify the output decompresses correctly.
410+
out := new(bytes.Buffer)
411+
if _, err := io.Copy(out, lz4.NewReader(buf)); err != nil {
412+
t.Fatal(err)
413+
}
414+
if !bytes.Equal(out.Bytes(), data) {
415+
t.Fatal("decompressed data does not match original after Reset without Close")
416+
}
417+
}
418+
419+
// zeroThenDataReader returns 0, nil on the first Read call, then delegates
420+
// to the underlying reader. This simulates an io.Reader that occasionally
421+
// returns zero bytes without error (allowed by the io.Reader contract).
422+
type zeroThenDataReader struct {
423+
r io.Reader
424+
zeros int
425+
}
426+
427+
func (z *zeroThenDataReader) Read(p []byte) (int, error) {
428+
if z.zeros > 0 {
429+
z.zeros--
430+
return 0, nil
431+
}
432+
return z.r.Read(p)
433+
}
434+
435+
// TestWriter_ReadFromZeroLengthRead verifies that ReadFrom correctly handles
436+
// an io.Reader that returns 0 bytes without error. Before the fix, a zero-length
437+
// read would still call write and handler with empty data.
438+
func TestWriter_ReadFromZeroLengthRead(t *testing.T) {
439+
data := []byte(strings.Repeat("test data for ReadFrom ", 500))
440+
441+
buf := new(bytes.Buffer)
442+
zw := lz4.NewWriter(buf)
443+
src := &zeroThenDataReader{r: bytes.NewReader(data), zeros: 3}
444+
445+
n, err := zw.ReadFrom(src)
446+
if err != nil {
447+
t.Fatal(err)
448+
}
449+
if int(n) != len(data) {
450+
t.Fatalf("ReadFrom byte count: got %d, want %d", n, len(data))
451+
}
452+
if err := zw.Close(); err != nil {
453+
t.Fatal(err)
454+
}
455+
456+
// Verify decompressed output matches.
457+
out := new(bytes.Buffer)
458+
if _, err := io.Copy(out, lz4.NewReader(buf)); err != nil {
459+
t.Fatal(err)
460+
}
461+
if !bytes.Equal(out.Bytes(), data) {
462+
t.Fatal("decompressed data does not match original after ReadFrom with zero-length reads")
463+
}
464+
}

0 commit comments

Comments
 (0)