fulgur_chart 0.7.0 → 0.9.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: fc454d38dbab809eed4d7e7e22edc3630e74f5042308ceec29b6ed7042515ace
4
- data.tar.gz: 770d479abd84d965265a031ec44250d47cf5d65a9d365d27593cf11fd87f0770
3
+ metadata.gz: 06d802ba139417a89422869f273ca4006b43acab13337bf45be42494ea02444f
4
+ data.tar.gz: 5301f5ac659389f8c334960e7239c1de6fa21e269c6929f690ab36a4e0232532
5
5
  SHA512:
6
- metadata.gz: '09ecf263ac296b185c6c5c9fd04454cd518898af8b165f7a62ecb87f0ab0f3c702f66eb24e2d3f4d1eed072eba56d22e4ebee3fe92df0328397a666a54843207'
7
- data.tar.gz: 8dded30443c6f3af4c5eb4490f53b154390c36f2469652bf5ebb3d2882a58212057e2b98d03aaea18b264181ca5ab7b3b0e62c81ca8f26ea006850e454fcd9bf
6
+ metadata.gz: 6963cd4c72110e3cf2e112414a3b130e5a68fcc3af6cea3f59bf71bb95967d5bc850dc10228b6a81ee3054f126c13c0bcbf76257e5a341c969525459e03359d7
7
+ data.tar.gz: c377c3c2ea5ab1fe325d2775cc3d9867cbfbb1847b94c867331bf4feeafa0ca51fa5d53aef63af8b6c30731472de64721ed63c6f350c6e45e1e005f0fd493ffd
@@ -1,7 +1,7 @@
1
1
  [package]
2
2
  name = "fulgur_chart"
3
- version = "0.7.0"
4
- edition = "2021"
3
+ version = "0.9.0"
4
+ edition = "2024"
5
5
  publish = false
6
6
 
7
7
  [lib]
@@ -9,7 +9,11 @@ crate-type = ["cdylib"]
9
9
 
10
10
  [dependencies]
11
11
  magnus = "0.7"
12
- fulgur-chart = "0.7.0"
12
+ # Raw libruby bindings for rb_thread_call_without_gvl (magnus 0.7 does not wrap it).
13
+ # Already pulled in transitively by magnus; declaring it directly unifies to the same
14
+ # version and makes the symbol importable.
15
+ rb-sys = "0.9"
16
+ fulgur-chart = "0.9.0"
13
17
  serde = { version = "1", features = ["derive"] }
14
18
  serde_json = "1"
15
19
  schemars = { version = "1.2", features = ["preserve_order"] }
@@ -1,9 +1,8 @@
1
- use fulgur_chart::guard::{validate_spec, InputLimits};
1
+ use fulgur_chart::guard::{InputLimits, validate_spec};
2
2
  use magnus::{
3
- function,
3
+ Error, ExceptionClass, RHash, RString, Ruby, Value, function,
4
4
  prelude::*,
5
5
  scan_args::{get_kwargs, scan_args},
6
- Error, ExceptionClass, RHash, RString, Ruby, Value,
7
6
  };
8
7
 
9
8
  // --- error helpers (classification is by CALL SITE, never by parsing the message) ---
@@ -167,6 +166,112 @@ fn build_ir(
167
166
  Ok(ir)
168
167
  }
169
168
 
169
+ // --- GVL release ---
170
+
171
+ /// Run `func` with the GVL released, returning `Some(value)` — or `None` if a pending
172
+ /// interrupt prevented `func` from running at all (see below).
173
+ ///
174
+ /// Uses `rb_thread_call_without_gvl2`, NOT `rb_thread_call_without_gvl`. The v2 variant does
175
+ /// NOT process interrupts on GVL re-acquisition, so control ALWAYS returns to this Rust frame
176
+ /// after `func` runs: it never raises / longjmps across the `extern "C"` boundary. The v1
177
+ /// variant checks (and handles) pending interrupts right after `func` returns — that handling
178
+ /// can raise, longjmp-ing past this frame and the caller's, skipping our box reclamation and
179
+ /// the owned Rust data's Drop glue (a leak on every interrupted render, and an unsound
180
+ /// longjmp across Rust frames). v2 avoids that: the render runs uninterrupted and a pending
181
+ /// interrupt is honored by Ruby at its next checkpoint, after this call returns.
182
+ ///
183
+ /// `func` MUST NOT call any Ruby C API — it executes while this thread does not hold the GVL,
184
+ /// so building VALUEs / raising / allocating Ruby objects would be unsound. (If a global
185
+ /// allocator that calls back into Ruby's GC — e.g. rb-sys's tracking allocator — were ever
186
+ /// installed, even plain Rust heap allocation here would become unsound. None is today.)
187
+ ///
188
+ /// `None`: v2 checks for a pending interrupt BEFORE releasing the GVL and, if one is set,
189
+ /// returns without ever calling `func`. Callers fall back to running the work under the GVL,
190
+ /// leaving the still-pending interrupt for Ruby to raise once control returns to it.
191
+ ///
192
+ /// A panic inside `func` is caught and re-raised here so it never crosses `extern "C"` (UB).
193
+ fn nogvl<F, R>(func: F) -> Option<R>
194
+ where
195
+ F: FnOnce() -> R,
196
+ {
197
+ use std::ffi::c_void;
198
+
199
+ unsafe extern "C" fn call<F, R>(arg: *mut c_void) -> *mut c_void
200
+ where
201
+ F: FnOnce() -> R,
202
+ {
203
+ // `arg` is the Box<F> leaked below; reconstitute and consume it exactly once.
204
+ let func = *unsafe { Box::from_raw(arg as *mut F) };
205
+ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(func));
206
+ Box::into_raw(Box::new(result)) as *mut c_void
207
+ }
208
+
209
+ let arg = Box::into_raw(Box::new(func)) as *mut c_void;
210
+ // SAFETY: `call::<F, R>` has the rb_thread_call_without_gvl2 callback signature; `arg` is
211
+ // a valid Box<F>. NULL ubf → the render itself is never interrupted mid-flight.
212
+ let ret = unsafe {
213
+ rb_sys::rb_thread_call_without_gvl2(Some(call::<F, R>), arg, None, std::ptr::null_mut())
214
+ };
215
+ if ret.is_null() {
216
+ // v2 declined to run `func` (interrupt pending at entry), so `call` never ran and the
217
+ // closure box was not consumed — reclaim it here to avoid leaking it. (`func` always
218
+ // returns a non-null box pointer, so a null return unambiguously means "did not run".)
219
+ drop(unsafe { Box::from_raw(arg as *mut F) });
220
+ return None;
221
+ }
222
+ // `ret` is the Box<thread::Result<R>> leaked at the end of `call`.
223
+ match *unsafe { Box::from_raw(ret as *mut std::thread::Result<R>) } {
224
+ Ok(value) => Some(value),
225
+ Err(payload) => std::panic::resume_unwind(payload),
226
+ }
227
+ }
228
+
229
+ /// Output of the GVL-free render region. Carries only plain data (no Ruby VALUEs) so it
230
+ /// can cross back into the VM once the GVL is re-acquired.
231
+ enum Rendered {
232
+ Svg(String),
233
+ Png(Vec<u8>),
234
+ }
235
+
236
+ /// How a render failure is classified once back under the GVL. Mirrors the original
237
+ /// call-site classification: SVG font/render failures → ParseError, raster failures →
238
+ /// RenderError, unknown format → ParseError.
239
+ enum RenderFail {
240
+ Parse(String),
241
+ Render(String),
242
+ UnsupportedFormat(String),
243
+ }
244
+
245
+ /// The heavy, Ruby-free rendering. Called inside `nogvl`, so it must touch ONLY the
246
+ /// owned/borrowed plain Rust inputs (`ir`, `font`, `scale`) and never the Ruby C API.
247
+ fn render_pure(
248
+ ir: &fulgur_chart::ir::ChartSpec,
249
+ format: &str,
250
+ font: Option<&[u8]>,
251
+ scale: f32,
252
+ ) -> Result<Rendered, RenderFail> {
253
+ match format {
254
+ "svg" => {
255
+ // Font present → render_chart_with_font (Err → ParseError on the SVG path);
256
+ // else the bundled-font render.
257
+ let svg = match font {
258
+ Some(bytes) => fulgur_chart::render::render_chart_with_font(ir, bytes)
259
+ .map_err(RenderFail::Parse)?,
260
+ None => fulgur_chart::render::render_chart(ir),
261
+ };
262
+ Ok(Rendered::Svg(svg))
263
+ }
264
+ "png" => {
265
+ let fb = font.unwrap_or(fulgur_chart::font::DEFAULT_FONT);
266
+ // Invalid font on the image path → RenderError (the SVG path maps this to ParseError).
267
+ let png = fulgur_chart::raster_direct::render_chart_to_png(ir, scale, fb)
268
+ .map_err(RenderFail::Render)?;
269
+ Ok(Rendered::Png(png))
270
+ }
271
+ other => Err(RenderFail::UnsupportedFormat(other.to_string())),
272
+ }
273
+ }
274
+
170
275
  // --- public API: render (low-level primitive; the FulgurChart::Builder is the intended API) ---
171
276
 
172
277
  /// `FulgurChart.render(spec_json, format, **opts)` → String.
@@ -182,28 +287,25 @@ fn render(ruby: &Ruby, args: &[Value]) -> Result<RString, Error> {
182
287
  let opts = parse_opts(ruby, scanned.keywords)?;
183
288
  let ir = build_ir(ruby, &spec_json, &opts)?;
184
289
 
185
- match format.as_str() {
186
- "svg" => {
187
- // Font present render_chart_with_font (Err ParseError on the SVG path);
188
- // else the bundled-font render.
189
- let svg = match &opts.font {
190
- Some(bytes) => fulgur_chart::render::render_chart_with_font(&ir, bytes)
191
- .map_err(|e| parse_err(ruby, e))?,
192
- None => fulgur_chart::render::render_chart(&ir),
193
- };
194
- Ok(ruby.str_new(&svg)) // UTF-8 String
195
- }
196
- "png" => {
197
- let fb: &[u8] = opts
198
- .font
199
- .as_deref()
200
- .unwrap_or(fulgur_chart::font::DEFAULT_FONT);
201
- // Invalid font on the image path → RenderError (the SVG path maps this to ParseError).
202
- let png = fulgur_chart::raster_direct::render_chart_to_png(&ir, opts.scale, fb)
203
- .map_err(|e| render_err(ruby, e))?;
204
- Ok(ruby.str_from_slice(&png)) // ASCII-8BIT (BINARY) String
205
- }
206
- other => Err(parse_err(
290
+ // The heavy rendering touches no Ruby objects (ir/font are owned plain data; font was
291
+ // already copied out of the VM in parse_opts), so run it with the GVL released. Other
292
+ // Ruby threads including other renders then run truly in parallel. Ruby strings and
293
+ // exceptions are built BELOW, after the GVL is re-acquired; the closure must not touch
294
+ // the Ruby C API.
295
+ let font = opts.font; // Option<Vec<u8>>, owned
296
+ let scale = opts.scale;
297
+ // Release the GVL for the heavy render. If an interrupt is already pending, nogvl returns
298
+ // None without running the closure; fall back to rendering under the GVL and let Ruby
299
+ // raise the still-pending interrupt when control returns from this method.
300
+ let result = nogvl(|| render_pure(&ir, format.as_str(), font.as_deref(), scale))
301
+ .unwrap_or_else(|| render_pure(&ir, format.as_str(), font.as_deref(), scale));
302
+
303
+ match result {
304
+ Ok(Rendered::Svg(svg)) => Ok(ruby.str_new(&svg)), // UTF-8 String
305
+ Ok(Rendered::Png(png)) => Ok(ruby.str_from_slice(&png)), // ASCII-8BIT (BINARY) String
306
+ Err(RenderFail::Parse(m)) => Err(parse_err(ruby, m)),
307
+ Err(RenderFail::Render(m)) => Err(render_err(ruby, m)),
308
+ Err(RenderFail::UnsupportedFormat(other)) => Err(parse_err(
207
309
  ruby,
208
310
  format!("unsupported format '{other}' (supported: svg, png)"),
209
311
  )),
@@ -223,7 +325,7 @@ fn schema(ruby: &Ruby, dsl: Value) -> Result<String, Error> {
223
325
  return Err(parse_err(
224
326
  ruby,
225
327
  format!("unsupported DSL '{other}' (supported: chartjs, vegalite)"),
226
- ))
328
+ ));
227
329
  }
228
330
  };
229
331
  serde_json::to_string(&s).map_err(|e| render_err(ruby, format!("schema serialization: {e}")))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fulgur_chart
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fulgur