nosj 0.3.2 → 0.4.1

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.
@@ -29,20 +29,25 @@ mod walker;
29
29
 
30
30
  use magnus::rb_sys::{AsRawValue, FromRawValue};
31
31
  use magnus::{Error, RString, Ruby, Value};
32
- use std::cell::RefCell;
32
+ use std::cell::Cell;
33
33
 
34
+ use crate::state::with_taken;
34
35
  use errors::raise_fail;
35
36
  use keys::GenKeyCache;
37
+ pub(crate) use ruby::warm_up;
36
38
  use walker::Gen;
37
39
 
38
40
  /// Per-thread generate scratch: the pooled output buffer (capacity
39
- /// survives across calls) and the pre-escaped key cache. One
40
- /// thread_local, borrowed ONCE for the whole call: in a shared library
41
- /// every thread_local access is a `__tls_get_addr` call, and the old
42
- /// take/put-back shape paid two of them plus struct moves per call
43
- /// (measurable on 45-byte documents). A recursive generate via a user
44
- /// `to_json` hits the failed `try_borrow_mut` arm and runs on a fresh
45
- /// scratch instead of panicking the RefCell.
41
+ /// survives across calls) and the pre-escaped key caches. A call takes
42
+ /// the scratch OUT of the thread-local and stores it back afterwards:
43
+ /// off the main Ractor, Ruby threads run M:N over a pool of native
44
+ /// threads, so a thread resuming after a `to_json` / `as_json` callback
45
+ /// may be on another native thread, where a reference into the old
46
+ /// thread's cell would alias whatever that thread is running. An empty
47
+ /// cell (a recursive generate holds the scratch, or a thread hop left
48
+ /// it elsewhere) means a fresh one. The Box moves as one pointer; the
49
+ /// cost over a plain borrow is one thread-local address lookup per
50
+ /// call, measured at parity (feature/ractor, 2026-09).
46
51
  struct GenScratch {
47
52
  buf: Vec<u8>,
48
53
  keys: GenKeyCache,
@@ -52,12 +57,28 @@ struct GenScratch {
52
57
  html_keys: GenKeyCache,
53
58
  }
54
59
 
60
+ impl GenScratch {
61
+ #[cold]
62
+ fn fresh() -> Box<Self> {
63
+ Box::new(GenScratch {
64
+ buf: Vec::new(),
65
+ keys: GenKeyCache::with_capacity(256),
66
+ html_keys: GenKeyCache::with_capacity(64),
67
+ })
68
+ }
69
+ }
70
+
55
71
  thread_local! {
56
- static GEN_SCRATCH: RefCell<GenScratch> = RefCell::new(GenScratch {
57
- buf: Vec::new(),
58
- keys: GenKeyCache::with_capacity(256),
59
- html_keys: GenKeyCache::with_capacity(64),
60
- });
72
+ static GEN_SCRATCH: Cell<Option<Box<GenScratch>>> = const { Cell::new(None) };
73
+ }
74
+
75
+ /// Run `f` on this thread's scratch (see [`with_taken`]). A recursive
76
+ /// generate stores its own fresh scratch meanwhile; the outermost call's
77
+ /// warm one replaces it (the replaced key cache keeps its shadow).
78
+ fn with_scratch<R>(f: impl FnOnce(&mut GenScratch) -> R) -> R {
79
+ with_taken(&GEN_SCRATCH, |slot| {
80
+ f(slot.get_or_insert_with(GenScratch::fresh))
81
+ })
61
82
  }
62
83
 
63
84
  /// `NOSJ.generate(obj, opts = nil)`, registered as a variadic native
@@ -174,27 +195,18 @@ fn generate_scratched_into<R>(
174
195
  cap_hint: usize,
175
196
  finish: impl FnOnce(&Ruby, &[u8]) -> Result<R, Error>,
176
197
  ) -> Result<R, Error> {
177
- GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() {
178
- Ok(mut scratch) => {
179
- let scratch = &mut *scratch;
180
- let GenScratch {
181
- buf,
182
- keys,
183
- html_keys,
184
- ..
185
- } = scratch;
186
- let keys = if cfg.mode == nosj::emit::EscapeMode::HtmlSafe {
187
- html_keys
188
- } else {
189
- keys
190
- };
191
- generate_with(ruby, obj, cfg, cap_hint, buf, keys, finish)
192
- }
193
- Err(_) => {
194
- let mut buf = Vec::new();
195
- let mut keys = GenKeyCache::default();
196
- generate_with(ruby, obj, cfg, cap_hint, &mut buf, &mut keys, finish)
197
- }
198
+ with_scratch(|scratch| {
199
+ let GenScratch {
200
+ buf,
201
+ keys,
202
+ html_keys,
203
+ } = scratch;
204
+ let keys = if cfg.mode == nosj::emit::EscapeMode::HtmlSafe {
205
+ html_keys
206
+ } else {
207
+ keys
208
+ };
209
+ generate_with(ruby, obj, cfg, cap_hint, buf, keys, finish)
198
210
  })
199
211
  }
200
212
 
@@ -258,10 +270,7 @@ pub(crate) fn emit_into(
258
270
  cfg: &opts::GenConfig,
259
271
  out: &mut Vec<u8>,
260
272
  ) -> Result<(), Error> {
261
- GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() {
262
- Ok(mut scratch) => emit_one(ruby, obj, cfg, out, &mut scratch.keys),
263
- Err(_) => emit_one(ruby, obj, cfg, out, &mut GenKeyCache::default()),
264
- })
273
+ with_scratch(|scratch| emit_one(ruby, obj, cfg, out, &mut scratch.keys))
265
274
  }
266
275
 
267
276
  /// Formatting strings holding a newline (or carriage return) would
@@ -313,17 +322,9 @@ pub(crate) fn generate_lines_bytes_into<R>(
313
322
  "formatting options containing newlines would break JSON Lines framing",
314
323
  ));
315
324
  }
316
- GEN_SCRATCH.with(|cell| match cell.try_borrow_mut() {
317
- Ok(mut scratch) => {
318
- let scratch = &mut *scratch;
319
- let GenScratch { buf, keys, .. } = scratch;
320
- generate_lines_with(ruby, values, cfg, cap_hint, buf, keys, finish)
321
- }
322
- Err(_) => {
323
- let mut buf = Vec::new();
324
- let mut keys = GenKeyCache::default();
325
- generate_lines_with(ruby, values, cfg, cap_hint, &mut buf, &mut keys, finish)
326
- }
325
+ with_scratch(|scratch| {
326
+ let GenScratch { buf, keys, .. } = scratch;
327
+ generate_lines_with(ruby, values, cfg, cap_hint, buf, keys, finish)
327
328
  })
328
329
  }
329
330
 
@@ -23,6 +23,21 @@ pub(super) fn protected_to_json(v: VALUE) -> Result<VALUE, Error> {
23
23
  magnus::rb_sys::protect(|| unsafe { rb_sys::rb_funcall(v, to_json_id(), 0) })
24
24
  }
25
25
 
26
+ /// `v.to_json` if `v.respond_to?(:to_json)`, else `None`, under ONE
27
+ /// protect: `rb_respond_to` dispatches to a user-defined `respond_to?` /
28
+ /// `respond_to_missing?`, which may raise just like `to_json` itself.
29
+ pub(super) fn protected_to_json_if_responds(v: VALUE) -> Result<Option<VALUE>, Error> {
30
+ const QUNDEF: VALUE = ruby_special_consts::RUBY_Qundef as VALUE;
31
+ let json = magnus::rb_sys::protect(|| unsafe {
32
+ if rb_sys::rb_respond_to(v, to_json_id()) != 0 {
33
+ rb_sys::rb_funcall(v, to_json_id(), 0)
34
+ } else {
35
+ QUNDEF
36
+ }
37
+ })?;
38
+ Ok((json != QUNDEF).then_some(json))
39
+ }
40
+
26
41
  /// `v.as_json`, protected. Argument-less on purpose: ActiveSupport's
27
42
  /// JSONGemEncoder#jsonify recursion also calls as_json without
28
43
  /// options (only the top-level value receives them).
@@ -37,6 +52,15 @@ fn as_json_id() -> rb_sys::ID {
37
52
  as rb_sys::ID
38
53
  }
39
54
 
55
+ /// Resolve the `OnceLock`s here now (init, main Ractor): an initializer
56
+ /// that enters the VM must never race between Ractors (see lib.rs), and
57
+ /// an initialized lock never blocks again.
58
+ pub(crate) fn warm_up() {
59
+ as_json_id();
60
+ to_json_id();
61
+ utf8_encindexes();
62
+ }
63
+
40
64
  /// Whether `v` is a `JSON::Fragment` (pre-rendered JSON to splice
41
65
  /// verbatim: the gem accepts fragments even under `strict`, and
42
66
  /// ActiveSupport's encoder passes them through). The class is resolved
@@ -44,33 +68,37 @@ fn as_json_id() -> rb_sys::ID {
44
68
  /// first generate is still found; a fragment instance existing implies
45
69
  /// its class does. The cached VALUE is a constant of the JSON module,
46
70
  /// so it can never be collected.
47
- pub(super) fn is_json_fragment(v: VALUE) -> bool {
71
+ pub(super) fn is_json_fragment(v: VALUE) -> Result<bool, Error> {
48
72
  use std::sync::atomic::{AtomicUsize, Ordering};
49
73
  static FRAGMENT: AtomicUsize = AtomicUsize::new(0);
50
- let mut cls = FRAGMENT.load(Ordering::Relaxed);
74
+ let mut cls = FRAGMENT.load(Ordering::Relaxed) as VALUE;
51
75
  if cls == 0 {
52
- cls = resolve_json_fragment();
76
+ cls = resolve_json_fragment()?;
53
77
  if cls == 0 {
54
- return false;
78
+ return Ok(false);
55
79
  }
56
- FRAGMENT.store(cls, Ordering::Relaxed);
80
+ FRAGMENT.store(cls as usize, Ordering::Relaxed);
57
81
  }
58
- unsafe { rb_sys::rb_obj_is_kind_of(v, cls as VALUE) != QFALSE }
82
+ Ok(unsafe { rb_sys::rb_obj_is_kind_of(v, cls) != QFALSE })
59
83
  }
60
84
 
61
- fn resolve_json_fragment() -> usize {
85
+ /// `JSON::Fragment`, or 0 while undefined. Only the `rb_const_get`s are
86
+ /// protected: fetching a constant can run its pending autoload (user
87
+ /// code, whose raise propagates as any constant reference's would),
88
+ /// while `rb_const_defined` never loads anything.
89
+ fn resolve_json_fragment() -> Result<VALUE, Error> {
62
90
  unsafe {
63
91
  let object = rb_sys::rb_cObject;
64
92
  let json_id = rb_sys::rb_intern(c"JSON".as_ptr());
65
93
  if rb_sys::rb_const_defined(object, json_id) == 0 {
66
- return 0;
94
+ return Ok(0);
67
95
  }
68
- let json = rb_sys::rb_const_get(object, json_id);
96
+ let json = magnus::rb_sys::protect(|| rb_sys::rb_const_get(object, json_id))?;
69
97
  let fragment_id = rb_sys::rb_intern(c"Fragment".as_ptr());
70
98
  if rb_sys::rb_const_defined(json, fragment_id) == 0 {
71
- return 0;
99
+ return Ok(0);
72
100
  }
73
- rb_sys::rb_const_get(json, fragment_id) as usize
101
+ magnus::rb_sys::protect(|| rb_sys::rb_const_get(json, fragment_id))
74
102
  }
75
103
  }
76
104
 
@@ -86,7 +114,7 @@ pub(super) fn protected_encode_utf8(v: VALUE) -> Result<VALUE, Error> {
86
114
  }
87
115
 
88
116
  /// Interned `to_json` method ID, resolved once per process.
89
- pub(super) fn to_json_id() -> rb_sys::ID {
117
+ fn to_json_id() -> rb_sys::ID {
90
118
  static TO_JSON: OnceLock<usize> = OnceLock::new();
91
119
  *TO_JSON.get_or_init(|| unsafe { rb_sys::rb_intern(c"to_json".as_ptr()) } as usize)
92
120
  as rb_sys::ID
@@ -100,12 +128,16 @@ pub(super) fn utf8_encindexes() -> (c_int, c_int) {
100
128
  // Coderange and encoding index live in RBasic flags (public ABI); reading
101
129
  // them inline instead of calling rb_enc_str_coderange / rb_enc_get_index is
102
130
  // how the gem avoids two C calls per string (RB_ENC_CODERANGE,
103
- // RB_ENCODING_GET_INLINED).
104
- const CR_MASK: u64 = 3 << 20;
105
- pub(super) const CR_7BIT: u64 = 1 << 20;
106
- pub(super) const CR_VALID: u64 = 2 << 20;
107
- const ENC_SHIFT: u64 = 22;
108
- const ENC_MASK: u64 = 127 << 22;
131
+ // RB_ENCODING_GET_INLINED). The bit layout comes from rb-sys's bindings,
132
+ // generated from the headers of the Ruby being built against, so it
133
+ // follows any layout change instead of silently misreading flags.
134
+ const CR_MASK: u64 = rb_sys::ruby_coderange_type::RUBY_ENC_CODERANGE_MASK as u64;
135
+ pub(super) const CR_7BIT: u64 = rb_sys::ruby_coderange_type::RUBY_ENC_CODERANGE_7BIT as u64;
136
+ pub(super) const CR_VALID: u64 = rb_sys::ruby_coderange_type::RUBY_ENC_CODERANGE_VALID as u64;
137
+ const ENC_SHIFT: u64 = rb_sys::ruby_encoding_consts::RUBY_ENCODING_SHIFT as u64;
138
+ const ENC_MASK: u64 = rb_sys::ruby_encoding_consts::RUBY_ENCODING_MASK as u64;
139
+ /// Inline encoding-index sentinel: the real index is stored out of line.
140
+ const ENC_INLINE_MAX: c_int = rb_sys::ruby_encoding_consts::RUBY_ENCODING_INLINE_MAX as c_int;
109
141
 
110
142
  #[inline(always)]
111
143
  pub(super) fn str_coderange(s: VALUE) -> u64 {
@@ -122,18 +154,18 @@ pub(super) fn str_coderange(s: VALUE) -> u64 {
122
154
  pub(super) fn str_enc_index(s: VALUE) -> c_int {
123
155
  let flags = unsafe { (*(s as *const rb_sys::RBasic)).flags };
124
156
  let idx = ((flags & ENC_MASK) >> ENC_SHIFT) as c_int;
125
- if idx == 127 {
126
- // RUBY_ENCODING_INLINE_MAX sentinel: index stored out of line.
157
+ if idx == ENC_INLINE_MAX {
127
158
  unsafe { rb_sys::rb_enc_get_index(s) }
128
159
  } else {
129
160
  idx
130
161
  }
131
162
  }
132
163
 
164
+ /// `RB_SPECIAL_CONST_P` (immediates plus Qnil/Qfalse), through rb-sys's
165
+ /// inline versioned stable API rather than a hand-copied bit test.
133
166
  #[inline(always)]
134
167
  pub(super) fn is_special_const(v: VALUE) -> bool {
135
- // RB_SPECIAL_CONST_P: immediates plus Qnil/Qfalse.
136
- (v & (ruby_special_consts::RUBY_IMMEDIATE_MASK as VALUE)) != 0 || v == QNIL || v == QFALSE
168
+ rb_sys::macros::SPECIAL_CONST_P(v)
137
169
  }
138
170
 
139
171
  /// Borrow a Ruby String's bytes.
@@ -3,6 +3,7 @@
3
3
  //! Compact and pretty modes are one const-generic body, so the compact
4
4
  //! hot path carries no formatting branches.
5
5
 
6
+ use magnus::Error;
6
7
  use nosj::emit::{self, copy_short_raw, EscapeMode};
7
8
  use rb_sys::macros::{
8
9
  FIX2LONG, FIXNUM_P, FLONUM_P, RARRAY_CONST_PTR, RARRAY_LEN, RB_BUILTIN_TYPE, RHASH_SIZE,
@@ -15,8 +16,8 @@ use super::keys::GenKeyCache;
15
16
  use super::opts::GenConfig;
16
17
  use super::ruby::{
17
18
  is_json_fragment, is_special_const, protected_as_json, protected_encode_utf8,
18
- protected_to_json, protected_to_s, rstring_bytes, str_coderange, str_enc_index, to_json_id,
19
- utf8_encindexes, CR_7BIT, CR_VALID, QFALSE, QNIL, QTRUE,
19
+ protected_to_json, protected_to_json_if_responds, protected_to_s, rstring_bytes, str_coderange,
20
+ str_enc_index, utf8_encindexes, CR_7BIT, CR_VALID, QFALSE, QNIL, QTRUE,
20
21
  };
21
22
 
22
23
  /// Whether keys escaped under `mode` may be cached: the cached bytes
@@ -265,13 +266,15 @@ impl Gen<'_> {
265
266
  let s = unsafe { rb_sys::rb_sym2str(k) };
266
267
  return self.emit_rstring_quoted(s);
267
268
  }
268
- match protected_to_s(k) {
269
- Ok(s) => self.emit_rstring_quoted(s),
270
- Err(exc) => {
271
- self.fail = Some(GenFail::Reraise(exc));
272
- Err(())
273
- }
274
- }
269
+ let s = self.reraise(protected_to_s(k))?;
270
+ self.emit_rstring_quoted(s)
271
+ }
272
+
273
+ /// A protected Ruby call's outcome inside the walk: a raised
274
+ /// exception becomes this walk's failure, re-raised unchanged once
275
+ /// the walk has unwound.
276
+ fn reraise<T>(&mut self, r: Result<T, Error>) -> Result<T, ()> {
277
+ r.map_err(|exc| self.fail = Some(GenFail::Reraise(exc)))
275
278
  }
276
279
 
277
280
  /// Non-native type: strict raises (except `JSON::Fragment`, which
@@ -285,7 +288,7 @@ impl Gen<'_> {
285
288
  return self.emit_rails_fallback::<PRETTY>(raw, depth);
286
289
  }
287
290
  if self.cfg.strict {
288
- if is_json_fragment(raw) {
291
+ if self.reraise(is_json_fragment(raw))? {
289
292
  return self.splice_to_json(raw);
290
293
  }
291
294
  let name = unsafe {
@@ -296,53 +299,32 @@ impl Gen<'_> {
296
299
  self.fail = Some(GenFail::Generator(format!("{name} not allowed in JSON")));
297
300
  return Err(());
298
301
  }
299
- if unsafe { rb_sys::rb_respond_to(raw, to_json_id()) } != 0 {
300
- match protected_to_json(raw) {
301
- Ok(json) => {
302
- if !is_special_const(json)
303
- && unsafe { RB_BUILTIN_TYPE(json) } == ruby_value_type::RUBY_T_STRING
304
- {
305
- self.append_rstring_raw(json);
306
- return Ok(());
307
- }
308
- }
309
- Err(exc) => {
310
- self.fail = Some(GenFail::Reraise(exc));
311
- return Err(());
312
- }
313
- }
314
- }
315
- match protected_to_s(raw) {
316
- Ok(s) => self.emit_rstring_quoted(s),
317
- Err(exc) => {
318
- self.fail = Some(GenFail::Reraise(exc));
319
- Err(())
302
+ if let Some(json) = self.reraise(protected_to_json_if_responds(raw))? {
303
+ if !is_special_const(json)
304
+ && unsafe { RB_BUILTIN_TYPE(json) } == ruby_value_type::RUBY_T_STRING
305
+ {
306
+ self.append_rstring_raw(json);
307
+ return Ok(());
320
308
  }
321
309
  }
310
+ let s = self.reraise(protected_to_s(raw))?;
311
+ self.emit_rstring_quoted(s)
322
312
  }
323
313
 
324
314
  /// Splice `raw`'s `to_json` result verbatim: the JSON::Fragment
325
315
  /// path (pre-rendered JSON, trusted like the gem trusts it).
326
316
  fn splice_to_json(&mut self, raw: VALUE) -> Result<(), ()> {
327
- match protected_to_json(raw) {
328
- Ok(json)
329
- if !is_special_const(json)
330
- && unsafe { RB_BUILTIN_TYPE(json) } == ruby_value_type::RUBY_T_STRING =>
331
- {
332
- self.append_rstring_raw(json);
333
- Ok(())
334
- }
335
- Ok(_) => {
336
- self.fail = Some(GenFail::Generator(
337
- "JSON::Fragment#to_json did not return a String".to_string(),
338
- ));
339
- Err(())
340
- }
341
- Err(exc) => {
342
- self.fail = Some(GenFail::Reraise(exc));
343
- Err(())
344
- }
317
+ let json = self.reraise(protected_to_json(raw))?;
318
+ if is_special_const(json)
319
+ || unsafe { RB_BUILTIN_TYPE(json) } != ruby_value_type::RUBY_T_STRING
320
+ {
321
+ self.fail = Some(GenFail::Generator(
322
+ "JSON::Fragment#to_json did not return a String".to_string(),
323
+ ));
324
+ return Err(());
345
325
  }
326
+ self.append_rstring_raw(json);
327
+ Ok(())
346
328
  }
347
329
 
348
330
  /// Rails-mode fallback, mirroring JSONGemEncoder#jsonify:
@@ -356,27 +338,22 @@ impl Gen<'_> {
356
338
  raw: VALUE,
357
339
  depth: usize,
358
340
  ) -> Result<(), ()> {
359
- if is_json_fragment(raw) {
341
+ if self.reraise(is_json_fragment(raw))? {
360
342
  return self.splice_to_json(raw);
361
343
  }
362
- match protected_as_json(raw) {
363
- Ok(json) if json == raw => {
364
- let name = unsafe {
365
- std::ffi::CStr::from_ptr(rb_sys::rb_obj_classname(raw))
366
- .to_string_lossy()
367
- .into_owned()
368
- };
369
- self.fail = Some(GenFail::Generator(format!(
370
- "{name}#as_json returned the receiver"
371
- )));
372
- Err(())
373
- }
374
- Ok(json) => self.emit_value::<PRETTY>(json, depth),
375
- Err(exc) => {
376
- self.fail = Some(GenFail::Reraise(exc));
377
- Err(())
378
- }
344
+ let json = self.reraise(protected_as_json(raw))?;
345
+ if json == raw {
346
+ let name = unsafe {
347
+ std::ffi::CStr::from_ptr(rb_sys::rb_obj_classname(raw))
348
+ .to_string_lossy()
349
+ .into_owned()
350
+ };
351
+ self.fail = Some(GenFail::Generator(format!(
352
+ "{name}#as_json returned the receiver"
353
+ )));
354
+ return Err(());
379
355
  }
356
+ self.emit_value::<PRETTY>(json, depth)
380
357
  }
381
358
 
382
359
  fn nesting_check(&mut self, inner: usize) -> Result<(), ()> {
@@ -390,14 +367,23 @@ impl Gen<'_> {
390
367
  fn emit_array<const PRETTY: bool>(&mut self, ary: VALUE, depth: usize) -> Result<(), ()> {
391
368
  let inner = depth + 1;
392
369
  self.nesting_check(inner)?;
393
- let len = unsafe { RARRAY_LEN(ary) } as usize;
394
370
  self.out.push(b'[');
395
- if len == 0 {
371
+ if unsafe { RARRAY_LEN(ary) } == 0 {
396
372
  self.out.push(b']');
397
373
  return Ok(());
398
374
  }
399
375
  let mut i = 0usize;
400
- while i < len {
376
+ loop {
377
+ // Length and pointer are re-read every element, like the
378
+ // json gem: a user callback inside the recursion (to_json,
379
+ // to_s, as_json) may shrink the array (slots past the live
380
+ // length hold freed or reused VALUEs; growth is emitted),
381
+ // and any allocation may compact it elsewhere.
382
+ let len = unsafe { RARRAY_LEN(ary) } as usize;
383
+ if i >= len {
384
+ break;
385
+ }
386
+ let elem = unsafe { *RARRAY_CONST_PTR(ary).add(i) };
401
387
  if i > 0 {
402
388
  self.out.push(b',');
403
389
  }
@@ -405,9 +391,6 @@ impl Gen<'_> {
405
391
  self.out.extend_from_slice(&self.cfg.array_nl);
406
392
  self.push_indent(inner);
407
393
  }
408
- // Re-read the pointer every element: an allocation inside the
409
- // recursion may trigger GC compaction and move the array.
410
- let elem = unsafe { *RARRAY_CONST_PTR(ary).add(i) };
411
394
  // Numeric runs (compact mode) emit through a raw local
412
395
  // cursor under one chunked reservation: per-element Vec
413
396
  // operations round-trip length and pointer through memory