vibe_zstd 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2a7e3f6643d3fd14ebce10d77ba79770d44c11cd82775de3cf2c24eaa84f118
4
- data.tar.gz: de1a91cfa51e4e49e4759cf39449c309d28c985d2cf9085e1b6e8faed2b07e8c
3
+ metadata.gz: 6e06ac31dfbfb1db79c69375a831303710877a6c07564c687a8f9cb394454060
4
+ data.tar.gz: 269506fc73069dca0dec661f8eca5beadafe397f0dfd8209bca310b6930eedaa
5
5
  SHA512:
6
- metadata.gz: 8cb9a59f93e6167df9b7aa0a3ac466f37aae29aeb22aa191951714a694d6dc215f0f008e099f46f60c9e3941352d41c935e470e5301b9fa1cdefb980bf4372b8
7
- data.tar.gz: 63bb4d19e418c713335da4ed48a05b11ff1af0d8ca025729234bb3416726667a0f2f75ae516918f879811dad3d7d1a2675ed605858ccfcf3b1a0934260b7a138
6
+ metadata.gz: b836f11bfaae0728bf417f30b6b1b91f90a573b6ad052d7a58cb00644d6510575034ee3e27dfc6d65714e85ee6045f95a96e8303a0f3e5a45b9102b1702a3a45
7
+ data.tar.gz: 7e474b085f2e2614ec6fc23ec708e0f0796b319b8dfed10a7fb79099aa04fc0b2f3e56dd3a97a66cf3b3dbf77cecaf334b79bd4b475f3f126c16650783adc275
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.3.2] - 2026-10-27
11
+
12
+ ### Fixed
13
+ - Chilled string literals (Ruby 3.4+, files without `# frozen_string_literal`) no longer trigger "literal string will be frozen in the future" warnings when passed to `CCtx#compress`, `DCtx#decompress`, `VibeZstd.compress`/`.decompress`, or `CompressWriter#write`. 1.3.1 still called `rb_str_locktmp` on them, which Ruby counts as a write.
14
+ - `DCtx#decompress` now snapshots its input before parsing options, so a keyword argument whose `to_int` mutates the input string can no longer leave a stale source pointer.
15
+
16
+ ### Changed
17
+ - Source strings are no longer locked while the GVL is released. They are read from a frozen copy-on-write snapshot (`rb_str_new_frozen`) instead, the same approach `DecompressReader` already used for input chunks. Another thread (or an IO's `write`) mutating the caller's string mid-operation now succeeds, and the operation keeps reading the original bytes, instead of raising `RuntimeError` ("can't modify string; temporarily locked"). Likewise, a string another thread is concurrently reading into (`IO#read(n, buf)`) is no longer rejected; such a race now yields whatever bytes were in the buffer. Cost: the caller's string stays shared with the snapshot until its next mutation, so a buffer that is refilled and compressed in a loop (`io.read(n, buf); compress(buf)`) pays one copy of its contents per iteration. Fresh strings are unaffected.
18
+
10
19
  ## [1.3.1] - 2026-10-26
11
20
 
12
21
  ### Fixed
data/ext/vibe_zstd/cctx.c CHANGED
@@ -158,24 +158,23 @@ vibe_zstd_cctx_compress(int argc, VALUE* argv, VALUE self) {
158
158
  }
159
159
  }
160
160
 
161
- size_t srcSize = RSTRING_LEN(data);
161
+ // Read the source from a frozen snapshot (see vibe_zstd_source_snapshot):
162
+ // another Ruby thread mutating the caller's string cannot invalidate
163
+ // args.src while the GVL is released.
164
+ VALUE src_str = vibe_zstd_source_snapshot(data);
165
+ size_t srcSize = RSTRING_LEN(src_str);
162
166
  size_t dstCapacity = ZSTD_compressBound(srcSize);
163
167
  VALUE result_str = rb_str_new(NULL, dstCapacity);
164
168
  compress_args args = {
165
169
  .cctx = cctx->cctx,
166
- .src = RSTRING_PTR(data),
170
+ .src = RSTRING_PTR(src_str),
167
171
  .srcSize = srcSize,
168
172
  .dst = RSTRING_PTR(result_str),
169
173
  .dstCapacity = dstCapacity,
170
174
  .result = 0
171
175
  };
172
- // Lock the source string for the duration of the GVL-released compression.
173
- // Without this, another Ruby thread holding the same String object could
174
- // modify or reallocate it while compression reads from its buffer, causing
175
- // a use-after-free read. The helper unlocks via rb_ensure so the string
176
- // is never left permanently locked, even if an async exception (e.g.
177
- // Timeout, Thread#raise) is delivered when the GVL is reacquired.
178
- vibe_zstd_nogvl_with_str_locked(compress_without_gvl, &args, data);
176
+ rb_thread_call_without_gvl(compress_without_gvl, &args, NULL, NULL);
177
+ RB_GC_GUARD(src_str);
179
178
 
180
179
  // Restore context state so repeated one-shot calls remain independent.
181
180
  if (cdict) ZSTD_CCtx_refCDict(cctx->cctx, NULL);
data/ext/vibe_zstd/dctx.c CHANGED
@@ -411,19 +411,17 @@ typedef struct {
411
411
  ZSTD_DCtx* dctx;
412
412
  ZSTD_DDict* ddict;
413
413
  decompress_stream_nogvl_args* args;
414
- VALUE data;
414
+ VALUE src_str; // frozen snapshot of the source (vibe_zstd_source_snapshot)
415
415
  size_t max_size;
416
416
  } dctx_stream_decompress_state;
417
417
 
418
- // Body: run the no-GVL stream loop (source string locked), check the outcome,
419
- // and build the result string. Raising here is safe: cleanup always runs.
418
+ // Body: run the no-GVL stream loop (source is a frozen snapshot), check the
419
+ // outcome, and build the result string. Raising here is safe: cleanup always runs.
420
420
  static VALUE
421
421
  vibe_zstd_dctx_stream_decompress_body(VALUE p) {
422
422
  dctx_stream_decompress_state* state = (dctx_stream_decompress_state*)p;
423
423
 
424
- // Lock the source string while the GVL is released: another Ruby thread
425
- // holding the same string must not mutate or GC it mid-decompression.
426
- vibe_zstd_nogvl_with_str_locked(decompress_stream_without_gvl, state->args, state->data);
424
+ rb_thread_call_without_gvl(decompress_stream_without_gvl, state->args, NULL, NULL);
427
425
 
428
426
  if (state->args->limit_exceeded) {
429
427
  rb_raise(rb_eDecompressedSizeExceeded,
@@ -443,10 +441,12 @@ vibe_zstd_dctx_stream_decompress_body(VALUE p) {
443
441
  }
444
442
 
445
443
  // Cleanup: free the C output buffer and return the context to no-dictionary
446
- // mode so subsequent calls on this DCtx are not affected.
444
+ // mode so subsequent calls on this DCtx are not affected. The snapshot is
445
+ // guarded here so it outlives every read of it in the body.
447
446
  static VALUE
448
447
  vibe_zstd_dctx_stream_decompress_cleanup(VALUE p) {
449
448
  dctx_stream_decompress_state* state = (dctx_stream_decompress_state*)p;
449
+ RB_GC_GUARD(state->src_str);
450
450
  if (state->args->dst) {
451
451
  free(state->args->dst);
452
452
  state->args->dst = NULL;
@@ -493,8 +493,12 @@ vibe_zstd_dctx_decompress(int argc, VALUE* argv, VALUE self) {
493
493
  vibe_zstd_dctx* dctx;
494
494
  TypedData_Get_Struct(self, vibe_zstd_dctx, &vibe_zstd_dctx_type, dctx);
495
495
  StringValue(data);
496
- const char* src = RSTRING_PTR(data);
497
- size_t srcSize = RSTRING_LEN(data);
496
+ // Snapshot the source before taking src: option parsing below can run
497
+ // Ruby code (to_int), and the GVL is released during decompression
498
+ // (see vibe_zstd_source_snapshot).
499
+ VALUE src_str = vibe_zstd_source_snapshot(data);
500
+ const char* src = RSTRING_PTR(src_str);
501
+ size_t srcSize = RSTRING_LEN(src_str);
498
502
  size_t offset = 0;
499
503
 
500
504
  // Magicless frames (format = ZSTD_f_zstd1_magicless) carry no magic number,
@@ -642,7 +646,7 @@ vibe_zstd_dctx_decompress(int argc, VALUE* argv, VALUE self) {
642
646
  .dctx = dctx->dctx,
643
647
  .ddict = ddict,
644
648
  .args = &stream_args,
645
- .data = data,
649
+ .src_str = src_str,
646
650
  .max_size = max_size
647
651
  };
648
652
  return rb_ensure(vibe_zstd_dctx_stream_decompress_body, (VALUE)&state,
@@ -665,11 +669,10 @@ vibe_zstd_dctx_decompress(int argc, VALUE* argv, VALUE self) {
665
669
  .dstCapacity = contentSize,
666
670
  .result = 0
667
671
  };
668
- // Lock the source string while the GVL is released: another Ruby thread
669
- // holding the same string must not mutate or GC it mid-decompression.
670
- // The helper unlocks via rb_ensure so an async exception cannot leave
671
- // the string permanently locked.
672
- vibe_zstd_nogvl_with_str_locked(decompress_without_gvl, &args, data);
672
+ // src points into the frozen snapshot src_str, so it stays valid while
673
+ // the GVL is released even if the caller's string is mutated.
674
+ rb_thread_call_without_gvl(decompress_without_gvl, &args, NULL, NULL);
675
+ RB_GC_GUARD(src_str);
673
676
  if (ZSTD_isError(args.result)) {
674
677
  rb_raise(rb_eRuntimeError, "Decompression failed: %s", ZSTD_getErrorName(args.result));
675
678
  }
@@ -14,12 +14,6 @@ static VALUE vibe_zstd_reader_initialize(int argc, VALUE *argv, VALUE self);
14
14
  static VALUE vibe_zstd_reader_read(int argc, VALUE *argv, VALUE self);
15
15
  static VALUE vibe_zstd_reader_eof(VALUE self);
16
16
 
17
- // State struct for rb_ensure-based string lock/unlock in vibe_zstd_writer_write
18
- typedef struct {
19
- vibe_zstd_cstream* cstream;
20
- VALUE data;
21
- } vibe_zstd_write_state;
22
-
23
17
  // TypedData types - defined in vibe_zstd.c
24
18
  extern rb_data_type_t vibe_zstd_cstream_type;
25
19
  extern rb_data_type_t vibe_zstd_dstream_type;
@@ -106,19 +100,22 @@ vibe_zstd_writer_initialize(int argc, VALUE *argv, VALUE self) {
106
100
  return self;
107
101
  }
108
102
 
109
- // Body of the rb_ensure wrapper: runs the compress loop with data locked
110
103
  static VALUE
111
- vibe_zstd_writer_write_body(VALUE arg) {
112
- vibe_zstd_write_state* state = (vibe_zstd_write_state*)arg;
113
- vibe_zstd_cstream* cstream = state->cstream;
114
- VALUE data = state->data;
104
+ vibe_zstd_writer_write(VALUE self, VALUE data) {
105
+ Check_Type(data, T_STRING);
106
+
107
+ vibe_zstd_cstream* cstream;
108
+ TypedData_Get_Struct(self, vibe_zstd_cstream, &vibe_zstd_cstream_type, cstream);
109
+
110
+ // io.write (called inside the loop) can run Ruby code that mutates or
111
+ // resizes the caller's string. Reading from a frozen snapshot keeps
112
+ // input.src valid regardless (see vibe_zstd_source_snapshot).
113
+ VALUE src_str = vibe_zstd_source_snapshot(data);
115
114
 
116
115
  // Input buffer: pos advances as ZSTD consumes data.
117
- // data is locked (rb_str_locktmp) for the duration of this call so that
118
- // RSTRING_PTR remains valid even when rb_funcall runs arbitrary Ruby code.
119
116
  ZSTD_inBuffer input = {
120
- .src = RSTRING_PTR(data),
121
- .size = RSTRING_LEN(data),
117
+ .src = RSTRING_PTR(src_str),
118
+ .size = RSTRING_LEN(src_str),
122
119
  .pos = 0
123
120
  };
124
121
 
@@ -149,43 +146,12 @@ vibe_zstd_writer_write_body(VALUE arg) {
149
146
  if (output.pos > 0) {
150
147
  rb_str_set_len(outBuffer, output.pos);
151
148
  // rb_funcall may run arbitrary Ruby code, but input.src stays valid
152
- // because data is locked against mutation/reallocation
149
+ // because it points into the frozen snapshot src_str
153
150
  rb_funcall(cstream->io, id_write, 1, outBuffer);
154
151
  }
155
152
  }
156
153
 
157
- return Qnil;
158
- }
159
-
160
- // Ensure function: always unlocks data regardless of raise/return
161
- static VALUE
162
- vibe_zstd_writer_write_unlock(VALUE arg) {
163
- rb_str_unlocktmp((VALUE)arg);
164
- return Qnil;
165
- }
166
-
167
- static VALUE
168
- vibe_zstd_writer_write(VALUE self, VALUE data) {
169
- Check_Type(data, T_STRING);
170
-
171
- vibe_zstd_cstream* cstream;
172
- TypedData_Get_Struct(self, vibe_zstd_cstream, &vibe_zstd_cstream_type, cstream);
173
-
174
- // Lock data for the duration of the compress loop so that RSTRING_PTR(data)
175
- // stays valid even when io.write (called inside the loop) runs Ruby code that
176
- // could otherwise mutate or resize the string. rb_str_locktmp raises if the
177
- // string is already locked; the ensure always unlocks it. Frozen strings
178
- // are already immutable and Ruby 4.0+ refuses to lock them, so skip it.
179
- vibe_zstd_write_state state = { cstream, data };
180
- if (OBJ_FROZEN(data)) {
181
- vibe_zstd_writer_write_body((VALUE)&state);
182
- return self;
183
- }
184
-
185
- rb_str_locktmp(data);
186
- rb_ensure(vibe_zstd_writer_write_body, (VALUE)&state,
187
- vibe_zstd_writer_write_unlock, data);
188
-
154
+ RB_GC_GUARD(src_str);
189
155
  return self;
190
156
  }
191
157
 
@@ -280,41 +280,32 @@ vibe_zstd_default_c_level(VALUE self) {
280
280
  return INT2NUM(ZSTD_defaultCLevel());
281
281
  }
282
282
 
283
- // Run func(arg) without the GVL while str is locked against mutation.
284
- // rb_thread_call_without_gvl can deliver a pending async exception (e.g.
285
- // Thread#raise, Timeout) after reacquiring the GVL, so the unlock must go
286
- // through rb_ensure or the string would be left permanently locked.
283
+ // Source strings are read while the GVL is released (compress/decompress) or
284
+ // while arbitrary Ruby code runs (CompressWriter#write calling io.write), so
285
+ // the caller's String must not be mutated, resized or freed under us.
286
+ // rb_str_locktmp did that through 1.3.1, but Ruby 4.0 raises FrozenError on
287
+ // frozen strings and treats the lock as a write on chilled string literals
288
+ // ("literal string will be frozen in the future"). Instead, borrow the bytes
289
+ // through a frozen snapshot that shares the caller's buffer copy-on-write: a
290
+ // mutation unshares the caller's string and the snapshot keeps the original.
287
291
  //
288
- // Frozen strings are skipped: they cannot be mutated or resized, so the
289
- // pointer is already stable, and Ruby 4.0+ raises FrozenError from
290
- // rb_str_locktmp on a frozen string.
291
- typedef struct {
292
- void* (*func)(void*);
293
- void* arg;
294
- } nogvl_locked_call;
295
-
296
- static VALUE
297
- nogvl_locked_body(VALUE p) {
298
- nogvl_locked_call* call = (nogvl_locked_call*)p;
299
- rb_thread_call_without_gvl(call->func, call->arg, NULL, NULL);
300
- return Qnil;
301
- }
302
-
303
- static VALUE
304
- nogvl_locked_unlock(VALUE str) {
305
- rb_str_unlocktmp(str);
306
- return Qnil;
307
- }
308
-
309
- static void
310
- vibe_zstd_nogvl_with_str_locked(void* (*func)(void*), void* arg, VALUE str) {
311
- nogvl_locked_call call = { func, arg };
312
- if (OBJ_FROZEN(str)) {
313
- nogvl_locked_body((VALUE)&call);
314
- return;
315
- }
316
- rb_str_locktmp(str);
317
- rb_ensure(nogvl_locked_body, (VALUE)&call, nogvl_locked_unlock, str);
292
+ // The snapshot is a visible frozen String (rb_str_new_frozen) that owns the
293
+ // buffer for as long as anything references it, so a dup/clone of the
294
+ // caller's string taken mid-operation simply shares the same root. The
295
+ // caller's string stays copy-on-write shared until its next mutation, which
296
+ // then costs one copy; that is the price of this approach.
297
+ //
298
+ // Do NOT "optimize" this with rb_str_tmp_frozen_acquire/release (the IO#write
299
+ // primitive) to hand the buffer back afterwards: on Ruby 3.3+ String#dup of
300
+ // a string sharing a hidden root does not mark the root as borrowed, so
301
+ // release un-shares the caller's string and empties the root while the dup
302
+ // still points into the buffer. Any dup taken in io.write, or from another
303
+ // thread during compression, then aliases freed memory (reproduced).
304
+ //
305
+ // Callers must RB_GC_GUARD the snapshot after their last read of it.
306
+ static inline VALUE
307
+ vibe_zstd_source_snapshot(VALUE str) {
308
+ return rb_str_new_frozen(str);
318
309
  }
319
310
 
320
311
  // Include the split implementation files
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VibeZstd
4
- VERSION = "1.3.1"
4
+ VERSION = "1.3.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vibe_zstd
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kelley Reynolds