nosj 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d57a15d6dbdf7dc81518662ef44348f538b6dcf2a95098c3491c741b9e2517b6
4
- data.tar.gz: e5508e9cf8ea61c51e21599ef4a887f024ab08a920a0c642dfcb3ac7fd7b4954
3
+ metadata.gz: 354e310be9d2d56119d2db1f9ad13052c9a6bc8fba5c8d7b7993b78abf345e1e
4
+ data.tar.gz: b21acc76a56a10a56c62c370ff1dfcaa8dc263a96958fcac6c4f3c213c4bb542
5
5
  SHA512:
6
- metadata.gz: 9a7797c890345c879153069e63808fa916fe0e5944376680a86ba30696ef7eec16a6a0f45ae7c428c08783c56bb1a08bed448c27d3b7a7075caea064edee4205
7
- data.tar.gz: 29113d820443054acafaeda1a976f4a388424466d79d09353fac5980cc80d803937a800b6384ed9395d9386735420cbd378cc8a6e6629cd5bdbefd1bd70fd5bd
6
+ metadata.gz: c672e8653239dc1600ce19b4b38c31ae298dbf40a79449fe12ccf05335144eed7d210a115e87a3a60e8b534b868d3c0080079072ed21a8e67b398e2e8d7e8fc4
7
+ data.tar.gz: 22aadd62a736d86fe310ed099d995fe6006e149103eb95d41d128adc6164e6c2f64c2de49e6652437bccefbef70379b27cc3700d45fcad7ae1e2003ac6f05f41
data/CHANGELOG.md CHANGED
@@ -1,3 +1,108 @@
1
+ ## [0.5.0] - 2026-09-25
2
+
3
+ **json 3.0 compatibility.** nosj now matches the behavior of the json
4
+ gem 3.0, and stays compatible with both the json 2.x and 3.0
5
+ interfaces: `NOSJ.parse`, `NOSJ.generate`, and the rest of nosj's own
6
+ API follow json 3.0 semantics, while the `nosj/json` drop-in follows
7
+ whichever json gem your application has installed, 2.x or 3.0, down to
8
+ its calling conventions and the documents it accepts. Upgrading json
9
+ (or not) is your choice; nosj works with either.
10
+
11
+ Behavior changes in nosj's own API, for input that was accepted
12
+ before:
13
+
14
+ - Duplicate object keys raise `NOSJ::ParserError` (positioned at the
15
+ object repeating the key, like json 3) in `parse`, `load_file`,
16
+ `valid?` (returns false), `minify`/`reformat`, and everything that
17
+ materializes values. `allow_duplicate_key: true` restores the old
18
+ behavior: the last value wins (in `minify`, repeated keys pass
19
+ through).
20
+ - Lone UTF-16 surrogates such as `"\udc00"` raise `NOSJ::ParserError`
21
+ everywhere, trailing ones included (they used to decode to raw
22
+ WTF-8 bytes, and `minify` re-escaped them).
23
+ - `generate` raises `NOSJ::GeneratorError` for keys that render alike
24
+ (`{"a" => 1, :a => 2}`, `{1 => 1, "1" => 2}`), with json 3's exact
25
+ message; `allow_duplicate_key: true` emits them as before. Hashes
26
+ whose keys are all of one kind are never checked. The Rails encoder
27
+ is unchanged.
28
+ - `stats` still describes such documents rather than refusing them.
29
+ - Unknown options raise ArgumentError with json 3's message
30
+ (`unknown keyword: foo`) in every entry point, instead of being
31
+ ignored. json options nosj does not implement (`object_class`,
32
+ `array_class`, `decimal_class`, `on_load`, `create_additions`,
33
+ `allow_comments`, `allow_control_characters`,
34
+ `allow_invalid_escape`, `sort_keys`, `as_json`) raise unless falsy;
35
+ `on_load` and the newer ones used to be silently ignored.
36
+ `escape_slash` is gone, as in json 3: use `script_safe`.
37
+ `quirks_mode` is no longer accepted. `stats` takes only the options
38
+ it documents (`max_nesting`, `allow_nan`, `allow_trailing_comma`).
39
+
40
+ The `nosj/json` drop-in follows the installed json gem:
41
+
42
+ - With json 3.0, `JSON.parse` takes keyword options only, `JSON.dump`
43
+ uses json 3's defaults (nesting capped at 100), and whatever json 3
44
+ refuses (`quirks_mode`, `escape_slash`, `create_additions`, unknown
45
+ options, positional option hashes) raises exactly as json 3 raises.
46
+ - With json 2.x, everything behaves as before, including json 2's
47
+ acceptance of duplicate keys, lone surrogates, and comments, and
48
+ its handling of keys that render alike.
49
+ - Whenever the fast path refuses a call, the installed gem runs it
50
+ again and decides: exceptions are now the gem's own (message,
51
+ `json_path`, `invalid_object`) rather than nosj's messages re-raised
52
+ as JSON classes. The second pass happens on failures only, and a
53
+ `generate` run twice this way calls `to_json` again on the objects
54
+ visited before the refusal.
55
+ - Fixed: `JSON.dump` raised NameError (`_dump_default_options`) with
56
+ json older than 2.11, which includes the json bundled with Ruby 3.3
57
+ and 3.4.
58
+ - Fixed: `JSON.generate` (and `pretty_generate`, `dump`) with both
59
+ `ascii_only` and `script_safe` raised ArgumentError; that combination,
60
+ which nosj does not implement, now goes to the gem.
61
+
62
+ ## [0.4.1] - 2026-09-25
63
+
64
+ - Fixed a crash in `NOSJ.generate`: an object whose `to_json` or
65
+ `to_s` shrank the array being generated (for example with
66
+ `Array#clear`) made the generator read freed memory, usually a
67
+ segfault. The array length is now re-read for every element, like
68
+ the json gem, so elements a callback appends are emitted too.
69
+ - Fixed memory corruption in `NOSJ.splice`: a replacement value whose
70
+ `to_json` modified the source string, deduplicated a frozen String
71
+ subclass (`-str`), or removed entries from the edits hash could make
72
+ splice read freed memory, copying unrelated heap bytes into the
73
+ result or crashing. All values are now generated before the source
74
+ is read.
75
+ - Fixed `NOSJ.lazy` and `NOSJ.each_line` reading freed memory when the
76
+ source is a frozen String subclass (such as
77
+ `ActiveSupport::SafeBuffer`) or a frozen string carrying instance
78
+ variables, and it is deduplicated with `-str` while the lazy
79
+ document is alive or between lines: Ruby swaps such a string's
80
+ buffer, and the old one kept being read (wrong values, or a crash).
81
+ - Fixed a memory leak in `NOSJ.generate` and `NOSJ.write_file`: an
82
+ exception raised by user code the generator calls bypassed its
83
+ cleanup and leaked its output buffer (megabytes per call after large
84
+ documents). Affected: a raising `respond_to?` or
85
+ `respond_to_missing?`, a raising `to_s` on an encoding-conversion
86
+ error, a raising autoload of `JSON::Fragment` (strict and Rails
87
+ modes), and a raising `Errno` constructor for a failed write. The
88
+ exception still propagates unchanged.
89
+ - Fixed: after a `NoMemoryError` in the middle of a parse (or
90
+ `minify`/`reformat`), the next call on that thread aborted the whole
91
+ process. Per-thread parser state is now recovered instead.
92
+ - `NOSJ::Lazy` nodes now take part in generational GC: holding many
93
+ nodes no longer slows down every minor GC (200,000 live nodes: 2.9 ms
94
+ per minor GC before, 0.1 ms now), and they are freed immediately
95
+ when collected.
96
+ - Fixed: lazy documents opened with `allow_trailing_comma: true` or
97
+ `allow_nan: true` could not be walked. `size`, `keys`, `each`, and
98
+ any lookup that missed or stepped over a trailing comma or a `NaN`
99
+ raised `NOSJ::ParserError`, even though `value` worked.
100
+ - `NOSJ.at_pointer`, `NOSJ.at_pointers`, and `NOSJ.at_pointer_file`
101
+ now honor `allow_nan` and `allow_trailing_comma` while resolving the
102
+ pointer, not only when materializing the matched value.
103
+ - Updated the nosj crate to 0.2.2.
104
+ - Updated dependencies, Magnus bumped to 0.9.0.
105
+
1
106
  ## [0.4.0] - 2026-09-05
2
107
 
3
108
  - Ractors: on Ruby 4.0+, `NOSJ.parse`, `NOSJ.generate`, and every
data/Cargo.lock CHANGED
@@ -44,9 +44,9 @@ dependencies = [
44
44
 
45
45
  [[package]]
46
46
  name = "bitflags"
47
- version = "2.13.1"
47
+ version = "2.13.2"
48
48
  source = "registry+https://github.com/rust-lang/crates.io-index"
49
- checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
49
+ checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
50
50
 
51
51
  [[package]]
52
52
  name = "cexpr"
@@ -59,9 +59,9 @@ dependencies = [
59
59
 
60
60
  [[package]]
61
61
  name = "cfg-if"
62
- version = "1.0.4"
62
+ version = "1.0.5"
63
63
  source = "registry+https://github.com/rust-lang/crates.io-index"
64
- checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
64
+ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
65
65
 
66
66
  [[package]]
67
67
  name = "clang-sys"
@@ -137,9 +137,9 @@ dependencies = [
137
137
 
138
138
  [[package]]
139
139
  name = "magnus"
140
- version = "0.8.2"
140
+ version = "0.9.0"
141
141
  source = "registry+https://github.com/rust-lang/crates.io-index"
142
- checksum = "3b36a5b126bbe97eb0d02d07acfeb327036c6319fd816139a49824a83b7f9012"
142
+ checksum = "379d50b6eea4ca84074ccd3e551eb9b07f7865194d5b3198325317e9ef78e5e9"
143
143
  dependencies = [
144
144
  "magnus-macros",
145
145
  "rb-sys",
@@ -149,9 +149,9 @@ dependencies = [
149
149
 
150
150
  [[package]]
151
151
  name = "magnus-macros"
152
- version = "0.8.0"
152
+ version = "0.9.0"
153
153
  source = "registry+https://github.com/rust-lang/crates.io-index"
154
- checksum = "47607461fd8e1513cb4f2076c197d8092d921a1ea75bd08af97398f593751892"
154
+ checksum = "4ca70566af98184bb92d4d9853daaf533c1a3418b9570d0547ea44970035f3fe"
155
155
  dependencies = [
156
156
  "proc-macro2",
157
157
  "quote",
@@ -191,16 +191,16 @@ dependencies = [
191
191
 
192
192
  [[package]]
193
193
  name = "nosj"
194
- version = "0.2.0"
194
+ version = "0.2.2"
195
195
  source = "registry+https://github.com/rust-lang/crates.io-index"
196
- checksum = "d25913644b7871b36095cc3da27daf95ebf9508189c5d8ccfb16da3bdb9f802f"
196
+ checksum = "3d398b0019f2d8bf39b10de6038451b0f3d96a9e3111748097192d8f57d6f120"
197
197
  dependencies = [
198
198
  "fast-float2",
199
199
  ]
200
200
 
201
201
  [[package]]
202
202
  name = "nosj_native"
203
- version = "0.4.0"
203
+ version = "0.5.0"
204
204
  dependencies = [
205
205
  "ahash",
206
206
  "magnus",
@@ -335,9 +335,9 @@ dependencies = [
335
335
 
336
336
  [[package]]
337
337
  name = "unicode-ident"
338
- version = "1.0.24"
338
+ version = "1.0.26"
339
339
  source = "registry+https://github.com/rust-lang/crates.io-index"
340
- checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
340
+ checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
341
341
 
342
342
  [[package]]
343
343
  name = "version_check"
@@ -368,18 +368,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
368
368
 
369
369
  [[package]]
370
370
  name = "zerocopy"
371
- version = "0.8.56"
371
+ version = "0.8.58"
372
372
  source = "registry+https://github.com/rust-lang/crates.io-index"
373
- checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
373
+ checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44"
374
374
  dependencies = [
375
375
  "zerocopy-derive",
376
376
  ]
377
377
 
378
378
  [[package]]
379
379
  name = "zerocopy-derive"
380
- version = "0.8.56"
380
+ version = "0.8.58"
381
381
  source = "registry+https://github.com/rust-lang/crates.io-index"
382
- checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
382
+ checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2"
383
383
  dependencies = [
384
384
  "proc-macro2",
385
385
  "quote",
data/README.md CHANGED
@@ -65,6 +65,10 @@ through nosj:
65
65
  require "nosj/json"
66
66
  ```
67
67
 
68
+ It works with json 2.x and 3.0 alike and follows whichever your app
69
+ has installed: its calling conventions, its defaults, and the documents
70
+ it accepts.
71
+
68
72
  In a Bundler app (Rails included) that can live entirely in the
69
73
  Gemfile you can do this:
70
74
 
@@ -108,9 +112,11 @@ The `json` gem API, on the `NOSJ` module:
108
112
 
109
113
  ```ruby
110
114
  NOSJ.parse(src, symbolize_names: true) # also: freeze, max_nesting,
111
- # allow_nan, allow_trailing_comma
115
+ # allow_nan, allow_trailing_comma,
116
+ # allow_duplicate_key
112
117
  NOSJ.generate(obj) # indent, space, object_nl, ...,
113
- NOSJ.pretty_generate(obj) # ascii_only, script_safe, strict
118
+ NOSJ.pretty_generate(obj) # ascii_only, script_safe, strict,
119
+ # allow_duplicate_key
114
120
  ```
115
121
 
116
122
  ### Lazy documents
@@ -151,7 +157,8 @@ Example: an early field resolves in ~0.35µs where `JSON.parse(json).dig(...)`
151
157
  costs ~980µs on the same document—three orders of magnitude. A field
152
158
  at the far end of a 570 KB document costs ~71µs, still 13× faster
153
159
  than parse-then-dig. Misses return nil; matched subtrees materialize
154
- with the same options as `parse` (`symbolize_names:`, `freeze:`).
160
+ with the same options as `parse` (`symbolize_names:`, `freeze:`), and
161
+ `allow_nan:`/`allow_trailing_comma:` govern the walk to them too.
155
162
 
156
163
  ### Files API
157
164
 
@@ -188,10 +195,11 @@ NOSJ.reformat_file("big.json") # straight off a memory map
188
195
  ```
189
196
 
190
197
  Output is exactly `generate(parse(json))`—canonical number spellings,
191
- normalized escapes, same formatting options—except duplicate keys pass
192
- through (a reformatter must not silently drop data) and lone-surrogate
193
- strings re-escape instead of raising. Acceptance options apply too:
194
- `minify(src, allow_trailing_comma: true)` normalizes the commas away.
198
+ normalized escapes, same formatting options—and it accepts exactly what
199
+ `parse` does. Acceptance options apply too:
200
+ `minify(src, allow_trailing_comma: true)` normalizes the commas away,
201
+ and under `allow_duplicate_key: true` repeated keys pass through (a
202
+ reformatter must not silently drop data).
195
203
 
196
204
  ### Byte-splicing edits and JSON Patch
197
205
 
@@ -402,19 +410,28 @@ Reproduce with `rake bench` (the parity-gated comparison, after a PGO retrain—
402
410
 
403
411
  ## Switching from the json gem
404
412
 
405
- You mostly don't have to do anything. Some differences:
406
-
407
- - The legacy object-deserialization options (`create_additions`,
408
- `object_class`, `array_class`, `decimal_class`) raise ArgumentError;
409
- the `nosj/json` drop-in falls back to the original gem for them.
410
- - Behaviors the `json` gem itself deprecates (JS comments, raw invalid
411
- UTF-8) follow the strict semantics instead.
413
+ You mostly don't have to do anything. `NOSJ.*` follows json 3.0,
414
+ whichever json your app runs; the `nosj/json` drop-in follows the
415
+ installed one, 2.x or 3.0. Some differences:
416
+
417
+ - json 3.0 semantics: duplicate keys, lone surrogates, JS comments,
418
+ and invalid UTF-8 are errors (`allow_duplicate_key: true` restores
419
+ last-key-wins), keys that render alike raise in `generate`, and
420
+ unknown options raise ArgumentError. With json 2.x installed, the
421
+ drop-in keeps json 2's leniency: when nosj refuses a call, the
422
+ installed gem runs it and has the last word.
423
+ - The json options nosj doesn't implement (`create_additions`,
424
+ `object_class`, `array_class`, `decimal_class`, `on_load`,
425
+ `allow_comments`, `allow_control_characters`,
426
+ `allow_invalid_escape`, `sort_keys`, `as_json`) raise ArgumentError
427
+ unless falsy; the drop-in passes them to the original gem.
412
428
  - Unlike `Array#dig`, negative indices in `NOSJ.dig` return nil (JSON
413
429
  Pointer has no equivalent).
414
430
  - Parse errors raise `NOSJ::ParserError` (`NOSJ::NestingError` past
415
431
  `max_nesting`, like the gem); messages use byte offsets rather than
416
432
  the gem's phrasing, and the exception carries `#line`, `#column`,
417
- and a caret `#snippet`.
433
+ and a caret `#snippet`. Through the drop-in, exceptions are the
434
+ installed gem's own (message, `json_path`, `invalid_object`).
418
435
 
419
436
  Everything else—including the gem's exact float formatting, which is
420
437
  not the shortest-round-trip form most libraries emit—matches
data/ext/nosj/Cargo.toml CHANGED
@@ -5,7 +5,7 @@
5
5
  # required by lib/nosj.rb as "nosj/nosj" (Init_nosj set explicitly in
6
6
  # lib.rs). Gem name, module, and API are plain nosj.
7
7
  name = "nosj_native"
8
- version = "0.4.0"
8
+ version = "0.5.0"
9
9
  edition = "2021"
10
10
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
11
11
  license = "MIT"
@@ -16,13 +16,13 @@ name = "nosj"
16
16
  crate-type = ["cdylib", "rlib"]
17
17
 
18
18
  [dependencies]
19
- magnus = { version = "0.8", features = ["rb-sys"] }
20
- rb-sys = { version = "0.9.124", default-features = false }
21
- ahash = "0.8.11"
19
+ magnus = { version = "0.9", features = ["rb-sys"] }
20
+ rb-sys = { version = "0.9.130", default-features = false }
21
+ ahash = "0.8.12"
22
22
  # Read-only file mapping for the file entry points (load_lazy_file,
23
23
  # at_pointer_file, dig_file): pages never touched are never read.
24
- memmap2 = "0.9"
24
+ memmap2 = "0.9.11"
25
25
  # First-party SIMD JSON parse/generate library (github.com/yaroslav/nosj).
26
26
  # For coordinated crate+gem work, temporarily flip to
27
27
  # { path = "../../../nosj" } and restore before any commit or release.
28
- nosj = "0.2.0"
28
+ nosj = "0.2.2"
@@ -13,7 +13,7 @@ libfuzzer-sys = "0.4"
13
13
  # crate. Only this harness enables "embed" (rb-sys/link-ruby): fuzz
14
14
  # binaries host their own VM, while the extension must never link
15
15
  # libruby (its symbols resolve from the host process at load time).
16
- magnus = { version = "0.8", features = ["embed", "rb-sys"] }
16
+ magnus = { version = "0.9", features = ["embed", "rb-sys"] }
17
17
  # The extension as an rlib; its lib target is named `nosj` (the bundle
18
18
  # name), so rename the dependency to keep fuzz code readable.
19
19
  nosj_ext = { path = "..", package = "nosj_native" }
@@ -48,7 +48,14 @@ module NOSJFuzz
48
48
  rescue *PARSE_FAIL
49
49
  false
50
50
  end
51
- raise "stats disagrees with parse on acceptance" unless stats_ok == (status == :ok)
51
+ # stats describes documents rather than refusing them: it accepts
52
+ # everything parse accepts, plus duplicate keys and lone surrogates
53
+ # (json 3 refusals parse makes on top of the grammar).
54
+ raise "stats refused what parse accepted" if status == :ok && !stats_ok
55
+ if status == :err && stats_ok &&
56
+ try_parse(s, {allow_duplicate_key: true})[0] != :ok && !s.include?("\\u")
57
+ raise "stats accepted what parse refused for a grammar reason"
58
+ end
52
59
 
53
60
  min_status, min = begin
54
61
  [:ok, NOSJ.reformat_native(s, nil)]
@@ -59,24 +66,17 @@ module NOSJFuzz
59
66
  end
60
67
 
61
68
  if status == :err
62
- # The pipe may abort with GeneratorError (lone-surrogate key,
63
- # non-finite float) before the parser reaches whatever made the
64
- # whole document unparseable; any refusal is a refusal.
69
+ # The pipe may abort with GeneratorError (a non-finite float)
70
+ # before the parser reaches whatever made the whole document
71
+ # unparseable; any refusal is a refusal.
65
72
  raise "reformat accepted what parse refused" unless [:err, :generator].include?(min_status)
66
73
  return
67
74
  end
68
75
  if min_status == :generator
69
76
  # On a document parse accepts, only an overflow-to-Infinity
70
- # float (1e999, a 300-digit integer with a small exponent, maybe
71
- # hidden behind a duplicate key parse would discard) or a
72
- # lone-surrogate object key may abort the pipe. Rerunning with
73
- # allow_nan separates them: it lifts the float refusal but not
74
- # the key refusal, and the key requires a \u escape.
75
- begin
76
- NOSJ.reformat_native(s, {allow_nan: true})
77
- rescue NOSJ::GeneratorError
78
- raise "GeneratorError without a \\u escape in source" unless s.include?("\\u")
79
- end
77
+ # float (1e999, a 300-digit integer with a small exponent) may
78
+ # abort the pipe, and allow_nan lifts exactly that refusal.
79
+ stage("reformat with allow_nan") { NOSJ.reformat_native(s, {allow_nan: true}) }
80
80
  return
81
81
  end
82
82
  raise "reformat refused what parse accepted" unless min_status == :ok
@@ -140,11 +140,9 @@ module NOSJFuzz
140
140
  raise "yielded values diverge from per-line parses" unless yielded.eql?(reference)
141
141
 
142
142
  if status == :ok && !yielded.empty?
143
- begin
144
- ndjson = NOSJ.generate_lines_native(yielded, nil)
145
- rescue NOSJ::GeneratorError
146
- return # WTF-8 strings (lone surrogates) are not generable
147
- end
143
+ # Parsed values are always generable: parse refuses lone
144
+ # surrogates, the only source of unencodable strings.
145
+ ndjson = stage("generate_lines") { NOSJ.generate_lines_native(yielded, nil) }
148
146
  back = []
149
147
  NOSJ.each_line_native(ndjson, nil) { |v| back << v }
150
148
  raise "generate_lines does not round-trip" unless back.eql?(yielded)
@@ -168,16 +166,11 @@ module NOSJFuzz
168
166
  doc = utf8(doc_bytes)
169
167
  spec_status, spec = try_parse(utf8(spec_bytes))
170
168
  return unless spec_status == :ok
171
- # Replacement values with broken encoding (lone surrogates) raise
172
- # GeneratorError on insertion; the reference cannot mirror that.
173
- return unless deep_valid_encoding?(spec)
174
169
 
170
+ # Parsing refuses duplicate keys (raw-byte resolution would see the
171
+ # first occurrence, the tree the last) and lone surrogates, so an
172
+ # accepted tree and spec are free of both.
175
173
  tree_status, tree = try_parse(doc)
176
- # Duplicate keys: raw-byte resolution sees the first occurrence,
177
- # tree materialization keeps the last; the two sides cannot agree.
178
- if tree_status == :ok && NOSJ.stats_native(doc, nil)[:keys] != count_keys(tree)
179
- return
180
- end
181
174
 
182
175
  case spec
183
176
  when Hash
@@ -380,21 +373,4 @@ module NOSJFuzz
380
373
  else v
381
374
  end
382
375
  end
383
-
384
- def deep_valid_encoding?(v)
385
- case v
386
- when String then v.valid_encoding?
387
- when Array then v.all? { |e| deep_valid_encoding?(e) }
388
- when Hash then v.all? { |k, e| deep_valid_encoding?(k) && deep_valid_encoding?(e) }
389
- else true
390
- end
391
- end
392
-
393
- def count_keys(v)
394
- case v
395
- when Hash then v.size + v.sum { |_, e| count_keys(e) }
396
- when Array then v.sum { |e| count_keys(e) }
397
- else 0
398
- end
399
- end
400
376
  end
@@ -10,7 +10,7 @@
10
10
  //! I/O failures raise the mapped `Errno::*` exception, like `File`
11
11
  //! methods do.
12
12
 
13
- use std::cell::RefCell;
13
+ use std::cell::Cell;
14
14
  use std::fs;
15
15
  use std::io::Read;
16
16
 
@@ -22,14 +22,15 @@ use crate::gen;
22
22
  use crate::lazy::{self, DocBytes};
23
23
  use crate::parse::{err, materialize, materialize_at, parse_native_opts, span_of, ParseNativeOpts};
24
24
  use crate::pointer::path_to_pointer;
25
- use crate::state::PULL_STATE;
25
+ use crate::state::{with_pull_state, with_taken};
26
26
 
27
27
  const NOT_UTF8: &str = "input is not valid UTF-8";
28
28
 
29
29
  thread_local! {
30
30
  /// Reused read buffer for `load_file`: capacity survives across
31
- /// calls, so a hot loop over files allocates nothing.
32
- static FILE_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
31
+ /// calls, so a hot loop over files allocates nothing (see
32
+ /// `state::with_taken`).
33
+ static FILE_BUF: Cell<Vec<u8>> = const { Cell::new(Vec::new()) };
33
34
  }
34
35
 
35
36
  /// On Unix the raw OS error IS the errno `rb_syserr_new` expects.
@@ -58,6 +59,9 @@ fn errno_of(e: &std::io::Error) -> Option<i32> {
58
59
 
59
60
  /// Map an I/O failure onto the matching `Errno::*` exception (class
60
61
  /// parity with `File.read`/`File.write`; the message carries the path).
62
+ /// Constructing it runs the class's `initialize` (overridable Ruby), so
63
+ /// the call is protected; a raise there propagates in its place, as it
64
+ /// would from `File.read`.
61
65
  fn io_error(ruby: &Ruby, path: &str, e: &std::io::Error) -> Error {
62
66
  use magnus::rb_sys::FromRawValue;
63
67
  let Some(errno) = errno_of(e) else {
@@ -66,8 +70,13 @@ fn io_error(ruby: &Ruby, path: &str, e: &std::io::Error) -> Error {
66
70
  let Ok(cpath) = std::ffi::CString::new(path) else {
67
71
  return runtime_error(ruby, format!("{e} - {path}"));
68
72
  };
73
+ let raw =
74
+ match magnus::rb_sys::protect(|| unsafe { rb_sys::rb_syserr_new(errno, cpath.as_ptr()) }) {
75
+ Ok(raw) => raw,
76
+ Err(raised) => return raised,
77
+ };
69
78
  // SAFETY: rb_syserr_new returns a live Errno exception instance.
70
- let exc = unsafe { Value::from_raw(rb_sys::rb_syserr_new(errno, cpath.as_ptr())) };
79
+ let exc = unsafe { Value::from_raw(raw) };
71
80
  match magnus::Exception::from_value(exc) {
72
81
  Some(exc) => exc.into(),
73
82
  None => runtime_error(ruby, format!("{e} - {path}")),
@@ -84,23 +93,16 @@ pub fn load_file_native(
84
93
  ) -> Result<Value, Error> {
85
94
  let o = parse_native_opts(ruby, opts)?;
86
95
  let p = path.to_string()?;
87
- FILE_BUF.with(|cell| {
88
- let mut buf = cell
89
- .try_borrow_mut()
90
- .map_or_else(|_| Vec::new(), |mut b| std::mem::take(&mut *b));
96
+ with_taken(&FILE_BUF, |buf| {
91
97
  buf.clear();
92
- let result = read_into(&mut buf, &p)
98
+ read_into(buf, &p)
93
99
  .map_err(|e| io_error(ruby, &p, &e))
94
100
  .and_then(|()| {
95
- if std::str::from_utf8(&buf).is_err() {
101
+ if std::str::from_utf8(buf).is_err() {
96
102
  return Err(err(ruby, NOT_UTF8.into()));
97
103
  }
98
- materialize(ruby, &buf, &o)
99
- });
100
- if let Ok(mut slot) = cell.try_borrow_mut() {
101
- *slot = buf;
102
- }
103
- result
104
+ materialize(ruby, buf, &o)
105
+ })
104
106
  })
105
107
  }
106
108
 
@@ -190,10 +192,9 @@ fn resolve_file_pointer(
190
192
  o: &ParseNativeOpts,
191
193
  ) -> Result<Value, Error> {
192
194
  with_mapped_file(ruby, path, |map| {
193
- let resolved = PULL_STATE.with(|cell| {
194
- let mut state = cell.borrow_mut();
195
+ let resolved = with_pull_state(|state| {
195
196
  // SAFETY: UTF-8 checked by with_mapped_file.
196
- unsafe { nosj::pointer_utf8_unchecked(&map, pointer, &mut state.bufs) }
197
+ unsafe { nosj::pointer_utf8_unchecked_with(&map, pointer, &mut state.bufs, o.popts) }
197
198
  });
198
199
  match resolved {
199
200
  Ok(None) => Ok(ruby.qnil().as_value()),
@@ -7,7 +7,7 @@ use magnus::rb_sys::AsRawValue;
7
7
  use magnus::value::ReprValue;
8
8
  use magnus::{Error, Ruby};
9
9
 
10
- use super::ruby::rstring_bytes;
10
+ use super::ruby::{protected_to_s, rstring_bytes};
11
11
  use crate::errors::nosj_exception;
12
12
 
13
13
  pub(super) enum GenFail {
@@ -24,26 +24,28 @@ pub(super) enum GenFail {
24
24
  }
25
25
 
26
26
  /// The exception's `to_s` (its message), matching what the gem embeds
27
- /// when it wraps a secondary exception.
28
- fn error_message(err: &Error) -> String {
27
+ /// when it wraps a secondary exception. Protected: an exception class
28
+ /// may override `to_s`, and a raise there propagates in place of the
29
+ /// GeneratorError (as it does from the gem).
30
+ fn error_message(err: &Error) -> Result<String, Error> {
29
31
  if let ErrorType::Exception(exc) = err.error_type() {
30
- let s = unsafe { rb_sys::rb_obj_as_string(exc.as_value().as_raw()) };
32
+ let s = protected_to_s(exc.as_value().as_raw())?;
31
33
  // Safety: rb_obj_as_string returns a T_STRING; the bytes are
32
34
  // copied into an owned String before any further Ruby call.
33
35
  let bytes = unsafe { rstring_bytes(s) };
34
- return String::from_utf8_lossy(bytes).into_owned();
36
+ return Ok(String::from_utf8_lossy(bytes).into_owned());
35
37
  }
36
- err.to_string()
38
+ Ok(err.to_string())
37
39
  }
38
40
 
39
41
  pub(super) fn raise_fail(ruby: &Ruby, fail: GenFail) -> Error {
40
42
  match fail {
41
43
  GenFail::Reraise(err) => err,
42
44
  GenFail::Generator(msg) => Error::new(nosj_exception(ruby, "GeneratorError"), msg),
43
- GenFail::GeneratorFrom(err) => {
44
- let msg = error_message(&err);
45
- Error::new(nosj_exception(ruby, "GeneratorError"), msg)
46
- }
45
+ GenFail::GeneratorFrom(err) => match error_message(&err) {
46
+ Ok(msg) => Error::new(nosj_exception(ruby, "GeneratorError"), msg),
47
+ Err(raised) => raised,
48
+ },
47
49
  GenFail::Nesting(limit) => Error::new(
48
50
  nosj_exception(ruby, "NestingError"),
49
51
  format!(
@@ -31,6 +31,7 @@ use magnus::rb_sys::{AsRawValue, FromRawValue};
31
31
  use magnus::{Error, RString, Ruby, Value};
32
32
  use std::cell::Cell;
33
33
 
34
+ use crate::state::with_taken;
34
35
  use errors::raise_fail;
35
36
  use keys::GenKeyCache;
36
37
  pub(crate) use ruby::warm_up;
@@ -57,6 +58,7 @@ struct GenScratch {
57
58
  }
58
59
 
59
60
  impl GenScratch {
61
+ #[cold]
60
62
  fn fresh() -> Box<Self> {
61
63
  Box::new(GenScratch {
62
64
  buf: Vec::new(),
@@ -70,17 +72,13 @@ thread_local! {
70
72
  static GEN_SCRATCH: Cell<Option<Box<GenScratch>>> = const { Cell::new(None) };
71
73
  }
72
74
 
73
- /// Run `f` on this thread's scratch, then store it back, replacing one a
74
- /// recursive generate stored meanwhile (the outermost call's is the warm
75
- /// one; the replaced scratch's key cache keeps its shadow, exactly as
76
- /// the old fallback arm's did).
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).
77
78
  fn with_scratch<R>(f: impl FnOnce(&mut GenScratch) -> R) -> R {
78
- let mut scratch = GEN_SCRATCH
79
- .with(Cell::take)
80
- .unwrap_or_else(GenScratch::fresh);
81
- let result = f(&mut scratch);
82
- GEN_SCRATCH.with(|cell| cell.set(Some(scratch)));
83
- result
79
+ with_taken(&GEN_SCRATCH, |slot| {
80
+ f(slot.get_or_insert_with(GenScratch::fresh))
81
+ })
84
82
  }
85
83
 
86
84
  /// `NOSJ.generate(obj, opts = nil)`, registered as a variadic native