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