vibe_zstd 1.1.1 → 1.3.0

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.
@@ -14,6 +14,12 @@ 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
+
17
23
  // TypedData types - defined in vibe_zstd.c
18
24
  extern rb_data_type_t vibe_zstd_cstream_type;
19
25
  extern rb_data_type_t vibe_zstd_dstream_type;
@@ -89,6 +95,9 @@ vibe_zstd_writer_initialize(int argc, VALUE *argv, VALUE self) {
89
95
  if (ZSTD_isError(result)) {
90
96
  rb_raise(rb_eRuntimeError, "Failed to set dictionary: %s", ZSTD_getErrorName(result));
91
97
  }
98
+ // Retain the CDict object so GC won't free it while the stream holds a raw
99
+ // pointer to its internal ZSTD_CDict (ZSTD_CCtx_refCDict stores no Ruby ref)
100
+ rb_ivar_set(self, rb_intern("@dict"), dict);
92
101
  }
93
102
 
94
103
  // Allocate reusable output buffer (write barrier for WB_PROTECTED)
@@ -97,14 +106,16 @@ vibe_zstd_writer_initialize(int argc, VALUE *argv, VALUE self) {
97
106
  return self;
98
107
  }
99
108
 
109
+ // Body of the rb_ensure wrapper: runs the compress loop with data locked
100
110
  static VALUE
101
- vibe_zstd_writer_write(VALUE self, VALUE data) {
102
- Check_Type(data, T_STRING);
103
-
104
- vibe_zstd_cstream* cstream;
105
- TypedData_Get_Struct(self, vibe_zstd_cstream, &vibe_zstd_cstream_type, cstream);
106
-
107
- // Input buffer: pos advances as ZSTD consumes data
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;
115
+
116
+ // 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.
108
119
  ZSTD_inBuffer input = {
109
120
  .src = RSTRING_PTR(data),
110
121
  .size = RSTRING_LEN(data),
@@ -137,10 +148,39 @@ vibe_zstd_writer_write(VALUE self, VALUE data) {
137
148
  // Write any compressed output that was produced
138
149
  if (output.pos > 0) {
139
150
  rb_str_set_len(outBuffer, output.pos);
151
+ // rb_funcall may run arbitrary Ruby code, but input.src stays valid
152
+ // because data is locked against mutation/reallocation
140
153
  rb_funcall(cstream->io, id_write, 1, outBuffer);
141
154
  }
142
155
  }
143
156
 
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.
178
+ rb_str_locktmp(data);
179
+
180
+ vibe_zstd_write_state state = { cstream, data };
181
+ rb_ensure(vibe_zstd_writer_write_body, (VALUE)&state,
182
+ vibe_zstd_writer_write_unlock, data);
183
+
144
184
  return self;
145
185
  }
146
186
 
@@ -275,6 +315,9 @@ vibe_zstd_reader_initialize(int argc, VALUE *argv, VALUE self) {
275
315
  if (ZSTD_isError(result)) {
276
316
  rb_raise(rb_eRuntimeError, "Failed to set dictionary: %s", ZSTD_getErrorName(result));
277
317
  }
318
+ // Retain the DDict object so GC won't free it while the stream holds a raw
319
+ // pointer to its internal ZSTD_DDict (ZSTD_DCtx_refDDict stores no Ruby ref)
320
+ rb_ivar_set(self, rb_intern("@dict"), dict);
278
321
  }
279
322
 
280
323
  // Initialize input buffer management
@@ -298,10 +341,18 @@ vibe_zstd_reader_initialize(int argc, VALUE *argv, VALUE self) {
298
341
  // - Maintains internal compressed input buffer that refills from IO as needed
299
342
  // - Calls ZSTD_decompressStream incrementally to produce output
300
343
  // - Tracks EOF state based on IO exhaustion and frame completion
344
+ // - Input chunks are stored as frozen copies so that IOs which mutate/reuse
345
+ // the returned string cannot invalidate dstream->input.src between calls
301
346
  //
302
347
  // EOF handling:
303
348
  // - Returns nil when no more data available
304
349
  // - Sets eof flag when: IO returns nil, frame complete (ret==0), or no progress made
350
+ // - read(0) always returns "" immediately without touching stream state
351
+ //
352
+ // Allocation strategy:
353
+ // - Initial buffer is capped at ZSTD_DStreamOutSize() to avoid gigabyte
354
+ // allocations for large size arguments on small streams
355
+ // - Buffer grows geometrically (doubling) up to requested_size as needed
305
356
  //
306
357
  // This implements proper streaming semantics for incremental decompression
307
358
  // of arbitrarily large files without loading everything into memory.
@@ -313,6 +364,11 @@ vibe_zstd_reader_read(int argc, VALUE *argv, VALUE self) {
313
364
  vibe_zstd_dstream* dstream;
314
365
  TypedData_Get_Struct(self, vibe_zstd_dstream, &vibe_zstd_dstream_type, dstream);
315
366
 
367
+ // read(0): per IO semantics, always return "" without touching stream state
368
+ if (!NIL_P(size_arg) && NUM2SIZET(size_arg) == 0) {
369
+ return rb_str_new(NULL, 0);
370
+ }
371
+
316
372
  if (dstream->eof) {
317
373
  return Qnil;
318
374
  }
@@ -323,8 +379,12 @@ vibe_zstd_reader_read(int argc, VALUE *argv, VALUE self) {
323
379
  size_t requested_size = NIL_P(size_arg) ? default_chunk_size : NUM2SIZET(size_arg);
324
380
  size_t inBufferSize = ZSTD_DStreamInSize();
325
381
 
326
- // Preallocate buffer for requested size
327
- VALUE result = rb_str_buf_new(requested_size);
382
+ // Cap the initial allocation to avoid multi-gigabyte pre-allocations when
383
+ // the caller passes a huge size argument for a small stream. The buffer
384
+ // grows geometrically below as output accumulates.
385
+ size_t default_out_size = ZSTD_DStreamOutSize();
386
+ size_t initial_alloc = (requested_size < default_out_size) ? requested_size : default_out_size;
387
+ VALUE result = rb_str_buf_new((long)initial_alloc);
328
388
 
329
389
  size_t total_read = 0;
330
390
  int made_progress = 0;
@@ -341,10 +401,21 @@ vibe_zstd_reader_read(int argc, VALUE *argv, VALUE self) {
341
401
  break;
342
402
  }
343
403
 
404
+ // The IO is duck-typed: read may return anything. Convert via to_str
405
+ // (raising TypeError otherwise) so RSTRING below never sees a non-String.
406
+ StringValue(chunk);
407
+
408
+ // Store a private frozen copy so that an IO that reuses/mutates its
409
+ // returned buffer string cannot invalidate dstream->input.src between
410
+ // successive read() calls. rb_str_new_frozen is cheap (copy-on-write
411
+ // snapshot) when the string is already frozen, and allocates a
412
+ // separate copy otherwise.
413
+ VALUE frozen_chunk = rb_str_new_frozen(chunk);
414
+
344
415
  // Reset input buffer with new data (write barrier for WB_PROTECTED)
345
- RB_OBJ_WRITE(self, &dstream->input_data, chunk);
346
- dstream->input.src = RSTRING_PTR(chunk);
347
- dstream->input.size = RSTRING_LEN(chunk);
416
+ RB_OBJ_WRITE(self, &dstream->input_data, frozen_chunk);
417
+ dstream->input.src = RSTRING_PTR(frozen_chunk);
418
+ dstream->input.size = RSTRING_LEN(frozen_chunk);
348
419
  dstream->input.pos = 0;
349
420
  }
350
421
 
@@ -353,7 +424,22 @@ vibe_zstd_reader_read(int argc, VALUE *argv, VALUE self) {
353
424
  break;
354
425
  }
355
426
 
356
- size_t space_left = requested_size - total_read;
427
+ // Grow the output buffer geometrically when it is full, capped at
428
+ // requested_size. We must recompute RSTRING_PTR after any resize
429
+ // because the backing allocation may move.
430
+ size_t current_capacity = (size_t)rb_str_capacity(result);
431
+ if (total_read >= current_capacity) {
432
+ size_t new_capacity = current_capacity * 2;
433
+ if (new_capacity > requested_size) new_capacity = requested_size;
434
+ rb_str_resize(result, (long)new_capacity);
435
+ }
436
+
437
+ // Cap space_left at (requested_size - total_read) to ensure read(n) never
438
+ // returns more than n bytes: rb_str_capacity may exceed the requested size
439
+ // due to malloc's internal size-class rounding (e.g. request 100, get 135).
440
+ size_t effective_capacity = (size_t)rb_str_capacity(result);
441
+ if (effective_capacity > requested_size) effective_capacity = requested_size;
442
+ size_t space_left = effective_capacity - total_read;
357
443
 
358
444
  ZSTD_outBuffer output = {
359
445
  .dst = RSTRING_PTR(result) + total_read,
@@ -210,6 +210,7 @@ vibe_zstd_dctx_alloc(VALUE klass) {
210
210
  rb_raise(rb_eRuntimeError, "Failed to create ZSTD_DCtx");
211
211
  }
212
212
  dctx->initial_capacity = 0; // 0 = use class default
213
+ dctx->max_decompressed_size = 0; // 0 = inherit class default
213
214
  return TypedData_Wrap_Struct(klass, &vibe_zstd_dctx_type, dctx);
214
215
  }
215
216
 
@@ -279,6 +280,35 @@ vibe_zstd_default_c_level(VALUE self) {
279
280
  return INT2NUM(ZSTD_defaultCLevel());
280
281
  }
281
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.
287
+ typedef struct {
288
+ void* (*func)(void*);
289
+ void* arg;
290
+ } nogvl_locked_call;
291
+
292
+ static VALUE
293
+ nogvl_locked_body(VALUE p) {
294
+ nogvl_locked_call* call = (nogvl_locked_call*)p;
295
+ rb_thread_call_without_gvl(call->func, call->arg, NULL, NULL);
296
+ return Qnil;
297
+ }
298
+
299
+ static VALUE
300
+ nogvl_locked_unlock(VALUE str) {
301
+ rb_str_unlocktmp(str);
302
+ return Qnil;
303
+ }
304
+
305
+ static void
306
+ vibe_zstd_nogvl_with_str_locked(void* (*func)(void*), void* arg, VALUE str) {
307
+ nogvl_locked_call call = { func, arg };
308
+ rb_str_locktmp(str);
309
+ rb_ensure(nogvl_locked_body, (VALUE)&call, nogvl_locked_unlock, str);
310
+ }
311
+
282
312
  // Include the split implementation files
283
313
  #include "cctx.c"
284
314
  #include "dctx.c"
@@ -13,6 +13,7 @@ typedef struct {
13
13
  typedef struct {
14
14
  ZSTD_DCtx* dctx;
15
15
  size_t initial_capacity; // Initial capacity for unknown-size decompression (0 = use class default)
16
+ size_t max_decompressed_size; // Output size limit (0 = inherit class default; class default 0 = unlimited)
16
17
  } vibe_zstd_dctx;
17
18
 
18
19
  typedef struct {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VibeZstd
4
- VERSION = "1.1.1"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/vibe_zstd.rb CHANGED
@@ -7,18 +7,33 @@ require_relative "vibe_zstd/constants"
7
7
  module VibeZstd
8
8
  class Error < StandardError; end
9
9
 
10
- # Convenience method for one-off compression
11
- # Supports all CCtx#compress options: level, dict, pledged_size
10
+ # Keyword options handled per-operation by CCtx#compress / DCtx#decompress.
11
+ # Any other keyword is treated as a context (sticky) parameter and applied via
12
+ # the constructor, so e.g. VibeZstd.compress(data, checksum_flag: true) and
13
+ # VibeZstd.decompress(data, format: 1) work through the convenience methods.
14
+ # An unknown keyword raises NoMethodError from the corresponding setter.
15
+ COMPRESS_CALL_OPTIONS = %i[level dict pledged_size].freeze
16
+ DECOMPRESS_CALL_OPTIONS = %i[dict initial_capacity max_decompressed_size max_size].freeze
17
+ private_constant :COMPRESS_CALL_OPTIONS, :DECOMPRESS_CALL_OPTIONS
18
+
19
+ # Convenience method for one-off compression.
20
+ # Per-call options (level, dict, pledged_size) are passed to #compress; any
21
+ # other keyword is a context parameter (e.g. checksum_flag:, window_log:,
22
+ # workers:, format:) applied to a fresh CCtx.
12
23
  def self.compress(data, **options)
13
- cctx = CCtx.new
14
- cctx.compress(data, **options)
24
+ call_opts = options.slice(*COMPRESS_CALL_OPTIONS)
25
+ ctx_opts = options.except(*COMPRESS_CALL_OPTIONS)
26
+ CCtx.new(**ctx_opts).compress(data, **call_opts)
15
27
  end
16
28
 
17
- # Convenience method for one-off decompression
18
- # Supports all DCtx#decompress options: dict, initial_capacity
29
+ # Convenience method for one-off decompression.
30
+ # Per-call options (dict, initial_capacity, max_decompressed_size/max_size) are
31
+ # passed to #decompress; any other keyword is a context parameter (e.g.
32
+ # format:, window_log_max:) applied to a fresh DCtx.
19
33
  def self.decompress(data, **options)
20
- dctx = DCtx.new
21
- dctx.decompress(data, **options)
34
+ call_opts = options.slice(*DECOMPRESS_CALL_OPTIONS)
35
+ ctx_opts = options.except(*DECOMPRESS_CALL_OPTIONS)
36
+ DCtx.new(**ctx_opts).decompress(data, **call_opts)
22
37
  end
23
38
 
24
39
  # Get the decompressed content size from a compressed frame
@@ -88,6 +103,10 @@ module VibeZstd
88
103
  # Memory footprint: ~128KB per DCtx × unique dictionaries × threads
89
104
  # Example: 3 dicts × 5 Puma threads = 1.9MB total
90
105
  #
106
+ # Storage: uses Thread#thread_variable_get/set (true thread-local) so that
107
+ # fiber-based servers (Falcon, async) share one pool per OS thread rather
108
+ # than allocating a fresh pool for every fiber.
109
+ #
91
110
  # Note: Only supports per-operation parameters (level, dict, pledged_size, initial_capacity)
92
111
  # Does NOT support context-level settings (nb_workers, checksum_flag, etc.)
93
112
  module ThreadLocal
@@ -103,9 +122,10 @@ module VibeZstd
103
122
  # Key by dictionary ID, or :default if no dict
104
123
  key = dict ? dict.dict_id : :default
105
124
 
106
- # Get or create thread-local context pool
107
- Thread.current[:vibe_zstd_cctx_pool] ||= {}
108
- cctx = Thread.current[:vibe_zstd_cctx_pool][key] ||= VibeZstd::CCtx.new
125
+ # Get or create thread-local context pool (true thread-local, not fiber-local)
126
+ pool = Thread.current.thread_variable_get(:vibe_zstd_cctx_pool) || {}
127
+ cctx = pool[key] ||= VibeZstd::CCtx.new
128
+ Thread.current.thread_variable_set(:vibe_zstd_cctx_pool, pool)
109
129
 
110
130
  # Build options hash
111
131
  options = {}
@@ -122,18 +142,21 @@ module VibeZstd
122
142
  # @param data [String] Data to decompress
123
143
  # @param dict [DDict] Decompression dictionary (optional)
124
144
  # @param initial_capacity [Integer] Initial buffer size for unknown-size frames (optional)
145
+ # @param max_decompressed_size [Integer] Output-size limit; raises DecompressedSizeExceeded if exceeded (optional)
125
146
  # @return [String] Decompressed data
126
- def self.decompress(data, dict: nil, initial_capacity: nil)
147
+ def self.decompress(data, dict: nil, initial_capacity: nil, max_decompressed_size: nil)
127
148
  key = dict ? dict.dict_id : :default
128
149
 
129
- # Get or create thread-local context pool
130
- Thread.current[:vibe_zstd_dctx_pool] ||= {}
131
- dctx = Thread.current[:vibe_zstd_dctx_pool][key] ||= VibeZstd::DCtx.new
150
+ # Get or create thread-local context pool (true thread-local, not fiber-local)
151
+ pool = Thread.current.thread_variable_get(:vibe_zstd_dctx_pool) || {}
152
+ dctx = pool[key] ||= VibeZstd::DCtx.new
153
+ Thread.current.thread_variable_set(:vibe_zstd_dctx_pool, pool)
132
154
 
133
155
  # Build options hash
134
156
  options = {}
135
157
  options[:dict] = dict if dict
136
158
  options[:initial_capacity] = initial_capacity if initial_capacity
159
+ options[:max_decompressed_size] = max_decompressed_size if max_decompressed_size
137
160
 
138
161
  # C code will validate dict matches frame requirements
139
162
  dctx.decompress(data, **options)
@@ -142,19 +165,21 @@ module VibeZstd
142
165
  # Clear all thread-local context pools for the current thread
143
166
  # Useful for testing or explicit memory management
144
167
  def self.clear_thread_cache!
145
- Thread.current[:vibe_zstd_cctx_pool] = {}
146
- Thread.current[:vibe_zstd_dctx_pool] = {}
168
+ Thread.current.thread_variable_set(:vibe_zstd_cctx_pool, {})
169
+ Thread.current.thread_variable_set(:vibe_zstd_dctx_pool, {})
147
170
  nil
148
171
  end
149
172
 
150
173
  # Get statistics about the current thread's context pools
151
174
  # @return [Hash] Pool statistics
152
175
  def self.thread_cache_stats
176
+ cctx_pool = Thread.current.thread_variable_get(:vibe_zstd_cctx_pool)
177
+ dctx_pool = Thread.current.thread_variable_get(:vibe_zstd_dctx_pool)
153
178
  {
154
- compression_contexts: Thread.current[:vibe_zstd_cctx_pool]&.size || 0,
155
- decompression_contexts: Thread.current[:vibe_zstd_dctx_pool]&.size || 0,
156
- compression_keys: Thread.current[:vibe_zstd_cctx_pool]&.keys || [],
157
- decompression_keys: Thread.current[:vibe_zstd_dctx_pool]&.keys || []
179
+ compression_contexts: cctx_pool&.size || 0,
180
+ decompression_contexts: dctx_pool&.size || 0,
181
+ compression_keys: cctx_pool&.keys || [],
182
+ decompression_keys: dctx_pool&.keys || []
158
183
  }
159
184
  end
160
185
  end
@@ -229,7 +254,7 @@ module VibeZstd
229
254
  loop do
230
255
  # Check buffer for separator
231
256
  if (idx = @line_buffer.index(sep))
232
- return @line_buffer.slice!(0, idx + sep.bytesize)
257
+ return @line_buffer.slice!(0, idx + sep.length)
233
258
  end
234
259
 
235
260
  # Read more data in larger chunks
@@ -240,7 +265,7 @@ module VibeZstd
240
265
  end
241
266
 
242
267
  # Return remaining buffer or nil
243
- @line_buffer.empty? ? nil : @line_buffer.slice!(0, @line_buffer.bytesize)
268
+ @line_buffer.empty? ? nil : @line_buffer.slice!(0, @line_buffer.length)
244
269
  end
245
270
 
246
271
  # Iterate over lines
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vibe_zstd
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kelley Reynolds
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-25 00:00:00.000000000 Z
10
+ date: 2026-06-12 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: benchmark-ips
@@ -64,6 +64,7 @@ files:
64
64
  - benchmark/streaming.rb
65
65
  - ext/vibe_zstd/cctx.c
66
66
  - ext/vibe_zstd/dctx.c
67
+ - ext/vibe_zstd/depend
67
68
  - ext/vibe_zstd/dict.c
68
69
  - ext/vibe_zstd/extconf.rb
69
70
  - ext/vibe_zstd/frames.c