nodejs / nodejs/node

zlib: reset() after flush() silently produces an undecodable zstd stream

Đang mở
#66,087 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

Ngôn ngữ chính
JavaScript
Star
122k
Fork
37.4k
Merge trung bình
4 ngày 3 giờ
Pull request đã merge (30 ngày)
272

Mô tả

Version

v27.0.0-pre (built from main at 99f0ddef)

Platform
Linux x86_64 (Ubuntu 22.04, built with clang 20)
Reproduced identically on macOS arm64 with v26.8.1
Subsystem

zlib

What steps will reproduce the bug?

Call .flush() on a zstd compressor and then .reset() it. The stream keeps
producing output, but that output can no longer be decompressed, and nothing
throws
.

import * as zlib from 'node:zlib';

const stream = zlib.createZstdCompress();
const chunks = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('error', (err) => console.log('compress error:', err.code)); // never fires

stream.write(Buffer.from('hello'));
await new Promise((resolve) => stream.flush(resolve));  // <-- produces 14 bytes
stream.reset();                                          // <-- drops the frame state
stream.end(Buffer.from('world'));
await new Promise((resolve) => stream.on('end', resolve));

const output = Buffer.concat(chunks);
console.log('compressed bytes:', output.length);          // 28
console.log(zlib.zstdDecompressSync(output).toString());  // throws

Result:

compressed bytes: 28
Error: Data corruption detected (code: ZSTD_error_corruption_detected)
How often does it reproduce? Is there a required condition?

Always, as long as reset() is called after some bytes have already been
written out but before the frame is finished. A plain flush() (without
reset()) is fine, and so is reset() before any write — you need both calls
in that order.

What is the expected behavior? Why is that the expected behavior?

The compressor should either produce a stream that decompresses correctly, or
report an error. It currently does neither — it silently emits a stream that no
zstd decoder can read.

reset() is documented as "cancel the current frame and start a new session".
Cancelling a frame would be fine on its own, but the bytes that flush()
already handed to the output stream cannot be taken back, so the next frame gets
appended to a fragment of the cancelled one.

zstd itself is explicit that this is not allowed. From deps/zstd/lib/zstd.h
(ZSTD_compressStream2):

Before starting a new compression job, or changing compression parameters, it
is required to fully flush internal buffers.

flush() leaves the frame unfinished: ZSTD_compressStream2 returns non-zero
in that case, which is how a caller is supposed to know there is still work
pending.

What do you see instead?

flush() emits a fragment of the frame (14 bytes in the example above).
reset() throws that frame away, but the 14 bytes stay in the output. end()
then starts a brand-new frame on the reset session and emits 14 more bytes. The
result is a 28-byte stream made of fragment + complete frame, which decodes as
corruption.

The same amount of data without the reset() produces 22 bytes and decodes to
helloworld — because end() continues the existing frame (8 bytes) instead of
starting a new one (14 bytes).

Additional information

This is a source code level debugging observation

1. write("hello") → SetFlush, flush = 0 (ZSTD_e_continue)

The data goes into the compressor but nothing comes out yet.

breakpoint on SetFlush

2. DoThreadPoolWork with flush_ = ZSTD_e_continue

input_ holds the 5 bytes, output_.pos is still 0.

continue state

3. flush() → SetFlush, flush = 1 (ZSTD_e_flush)

Same breakpoint, now with the value that forces output.

flush triggered

4. DoThreadPoolWork with flush_ = ZSTD_e_flush, before the call

before output

5. After stepping over the call: output_.pos = 14

input_ is empty, but 14 bytes were flushed out — an unfinished frame.

14 bytes fragment

6. reset() → ResetStream runs ZSTD_CCtx_reset(session_only)

The 14 bytes are already out, but the frame state is about to be dropped.

reset drops frame

7. end() → DoThreadPoolWork with flush_ = ZSTD_e_end produces another 14 bytes

A fresh frame, not a continuation of the previous one. 14 + 14 = the 28 bytes
above.

end new frame

Other codecs are affected the same way. gzip and brotli both produce
undecodable output for the same flush() + reset() sequence, so this is not
specific to the zstd backend:

codec result of flush() + reset()
gzip Z_DATA_ERROR
brotli ERR__ERROR_FORMAT_PADDING_2
zstd ZSTD_error_corruption_detected

The zstd case is arguably the worst of the three because the compressor itself
reports nothing at all — with gzip/brotli the failure at least surfaces on the
same stream.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu bằng cách tái hiện ví dụ JavaScript với node:zlib, sau đó lần theo đường dẫn flush và reset xung quanh SetFlush, DoThreadPoolWork và ResetStream. Đọc hướng dẫn được trích dẫn về ZSTD_compressStream2 trong deps/zstd/lib/zstd.h và so sánh hành vi của zstd với gzip và brotli. Được xem là hoàn tất khi chuỗi flush/reset không âm thầm tạo ra đầu ra không thể giải mã, đồng thời có phạm vi kiểm thử hồi quy cho các trường hợp đã được báo cáo.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
javascript, nodejs
Lĩnh vực
backend
Loại issue
Lỗi
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
55/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.