sghtmltopdf 0.1.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.
data/Cargo.toml ADDED
@@ -0,0 +1,3 @@
1
+ [workspace]
2
+ resolver = "2"
3
+ members = ["ext/sghtmltopdf"]
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yo_waka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # sghtmltopdf
2
+
3
+ Ruby binding for [sghtmltopdf](https://github.com/waka/sghtmltopdf), an HTML-to-PDF renderer written in Rust that does not depend on Chromium, WebKit, or Gecko.
4
+
5
+ The engine runs inside your process through a native extension (magnus + rb-sys) — no subprocess, no temporary files — and releases the GVL while rendering, so other Puma threads keep running.
6
+
7
+ [Documentation](https://waka.github.io/sghtmltopdf/en/usage/ruby_rails.html) · [Repository](https://github.com/waka/sghtmltopdf) · [CHANGELOG](https://github.com/waka/sghtmltopdf/blob/main/CHANGELOG.md)
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "sghtmltopdf"
14
+ ```
15
+
16
+ Precompiled native gems are published for `x86_64-linux`, `aarch64-linux`, `x86_64-linux-musl`, `aarch64-linux-musl`, and `arm64-darwin`.
17
+ There is no build step on those platforms.
18
+
19
+ Elsewhere (Intel Mac, Windows) the gem cannot run in-process — the source gem does not carry the Rust core and will refuse to build with an explanatory message.
20
+ Point those environments at a separate `sghtmltopdf server` process instead; see [Delegating to a server](#delegating-to-a-server).
21
+
22
+ Requires Ruby >= 3.2.
23
+
24
+ ## Usage
25
+
26
+ ```ruby
27
+ pdf = Sghtmltopdf.render("<h1>Invoice</h1>", page_size: "A4", margin_top: "20mm")
28
+ ```
29
+
30
+ Option names are the CLI long options without `--` and with `-` replaced by `_`, so `--page-size A4` becomes `page_size: "A4"`.
31
+ The [option reference](https://waka.github.io/sghtmltopdf/en/usage/cli/reference.html) lists all of them.
32
+
33
+ Write straight to a file (written to a temporary file and renamed on success, so a failure never leaves a broken PDF behind), or take the bytes in chunks:
34
+
35
+ ```ruby
36
+ Sghtmltopdf.render_to_file(html, "invoice.pdf", page_size: "A4")
37
+
38
+ Sghtmltopdf.render(html) { |bytes| io.write(bytes) }
39
+ ```
40
+
41
+ ## Rails
42
+
43
+ Adding the gem is enough; the Railtie wires everything up, and nothing is loaded when Rails is absent.
44
+
45
+ ```ruby
46
+ # config/initializers/sghtmltopdf.rb
47
+ Sghtmltopdf.configure do |c|
48
+ c.page_size = "A4"
49
+ c.gothic_font = Rails.root.join("vendor/fonts/NotoSansJP-Regular.ttf")
50
+ end
51
+ ```
52
+
53
+ A `:pdf` renderer is registered, in the spirit of [wicked_pdf](https://github.com/mileszs/wicked_pdf) — the same keys, so an existing controller often needs no change at all:
54
+
55
+ ```ruby
56
+ class InvoicesController < ApplicationController
57
+ def show
58
+ render pdf: "invoice", # filename; ".pdf" is appended
59
+ template: "invoices/show",
60
+ layout: "pdf",
61
+ page_size: "A4", margin_top: "20mm"
62
+ end
63
+ end
64
+ ```
65
+
66
+ View-rendering keys (`template`, `layout`, `locals`, …) go to `render_to_string`, response keys (`filename`, `disposition`, `status`) go to `send_data`, `show_as_html: true` returns the HTML instead of a PDF, and everything else is passed to the converter.
67
+ Converter keys are flat CLI flag names, so wicked_pdf's nested `margin: {top: 10}` becomes `margin_top: "10mm"` (with the unit spelled out); the [migration guide](https://waka.github.io/sghtmltopdf/en/migration/wicked-pdf.html) maps every key one by one.
68
+
69
+ ### Assets
70
+
71
+ PDF rendering does not go through the HTTP server, so `/assets/…` URLs are resolved as local files: the Railtie defaults `base_url` to `Rails.root/public` and restricts local reads to `Rails.root` via `allow`.
72
+ That is enough for a precompiled production app; in development, these helpers inline the asset instead:
73
+
74
+ ```erb
75
+ <%= sghtmltopdf_stylesheet_link_tag "pdf" %>
76
+ <%= sghtmltopdf_image_tag "logo.png" %>
77
+ ```
78
+
79
+ ### Streaming the response
80
+
81
+ To send pages as soon as their layout is final, pass a block and use `ActionController::Live` — this also makes `Rack::Timeout` and `Thread#kill` effective at chunk boundaries:
82
+
83
+ ```ruby
84
+ class InvoicesController < ApplicationController
85
+ include ActionController::Live
86
+
87
+ def show
88
+ response.headers["Content-Type"] = "application/pdf"
89
+ html = render_to_string(template: "invoices/show", layout: "pdf")
90
+ Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
91
+ ensure
92
+ response.stream.close
93
+ end
94
+ end
95
+ ```
96
+
97
+ ## Delegating to a server
98
+
99
+ If the gem cannot run where your app runs, or you would rather not spend the app's CPU on rendering, set `server_url` and the same calls are delegated over HTTP to a separate `sghtmltopdf server` process.
100
+
101
+ ```ruby
102
+ Sghtmltopdf.configure { |c| c.server_url = "http://pdf:8080" }
103
+ ```
104
+
105
+ The [official Docker image](https://waka.github.io/sghtmltopdf/en/getting-started/docker.html) runs that server and bundles Japanese fonts.
106
+
107
+ ## License
108
+
109
+ MIT License ([LICENSE](LICENSE)).
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "sghtmltopdf"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ publish = false
6
+ description = "Ruby binding for sghtmltopdf (see bindings/ruby)"
7
+
8
+ [lib]
9
+ # Rubyの拡張ライブラリ(.so/.bundle)として読み込まれる。
10
+ crate-type = ["cdylib"]
11
+
12
+ [dependencies]
13
+ # `rb-sys` featureで`AsRawValue`/`FromRawValue`が使えるようになる
14
+ magnus = { version = "0.8", features = ["rb-sys"] }
15
+ # GVL解放(`rb_thread_call_without_gvl`)はmagnusに無いので直接呼ぶ。
16
+ rb-sys = "0.9"
17
+ # HTTPサーバ(tiny_http)は要らないので`server` featureは外す。
18
+ sghtmltopdf-core = { path = "../../../../core", default-features = false, features = [
19
+ "cli",
20
+ ] }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+ require "rb_sys/mkmf"
5
+
6
+ core = File.expand_path("../../../../core", __dir__)
7
+ unless File.exist?(File.join(core, "Cargo.toml"))
8
+ abort <<~MESSAGE
9
+ sghtmltopdf: Rustコア(#{core})が見つかりません。
10
+
11
+ このgemは対応プラットフォーム向けのprecompiled gemとして配布しています。
12
+ お使いの環境(#{RUBY_PLATFORM} / ruby #{RUBY_VERSION})向けのビルド済みgemが
13
+ 無いためソースからのビルドが試みられましたが、ソースgemにはRustコアが
14
+ 含まれていないためビルドできません。
15
+
16
+ 対応プラットフォーム: x86_64-linux / aarch64-linux / x86_64-linux-musl / aarch64-linux-musl / arm64-darwin
17
+ MESSAGE
18
+ end
19
+
20
+ # `lib/sghtmltopdf/sghtmltopdf.so`として作る。
21
+ create_rust_makefile("sghtmltopdf/sghtmltopdf")
@@ -0,0 +1,323 @@
1
+ //! 確定したPDFのバイト列を、Rubyのブロックへチャンクごとに渡す仕組み。
2
+ //!
3
+ //! # スレッドの分け方
4
+ //!
5
+ //! レンダリングはDOMの深さぶん再帰する(スタイル計算・レイアウト・描画)。
6
+ //! Rubyのスレッドのマシンスタックは既定1MiB(`RubyVM::DEFAULT_PARAMS`の
7
+ //! `thread_machine_stack_size`)しかなく、Pumaのワーカースレッド上でそのまま
8
+ //! 走らせると深さ200弱でスタックを溢れさせる。しかもGVLを解放した状態で
9
+ //! ガードページに触れるため、プロセスが落ちるのではなくスレッドが固まる。
10
+ //!
11
+ //! そこでレンダリングは[`sghtmltopdf_core::cli::STACK_SIZE`]のスタックを
12
+ //! 明示的に確保した専用スレッドで走らせ、確定したチャンクはチャネル越しに
13
+ //! 元のスレッドへ渡す。Rubyへ触れるのは元のスレッドだけに限る。
14
+ //!
15
+ //! ```text
16
+ //! 元のスレッド(Rubyが作った / GVL解放中) レンダリングスレッド(16MiB)
17
+ //! recv(chunk) <---------- chunk ---------- Sink::write
18
+ //! with_gvl { block.call(chunk) }
19
+ //! send(ack) ------------ ack ----------> (次のチャンクへ)
20
+ //! ```
21
+ //!
22
+ //! この向きでないと成立しない: [`crate::gvl::with_gvl`]の
23
+ //! `rb_thread_call_with_gvl`は「そのスレッドが`rb_thread_call_without_gvl`で
24
+ //! GVLを手放している」ことが前提で、Rubyの知らないスレッドから呼ぶことは
25
+ //! できない。だからレンダリングスレッドはRubyに一切触れない。
26
+
27
+ use std::io;
28
+ use std::sync::mpsc::{Receiver, SyncSender};
29
+
30
+ use magnus::rb_sys::{AsRawValue, FromRawValue};
31
+ use magnus::{block::Proc, Error, ExceptionClass, RString, Ruby, Value};
32
+ use rb_sys::VALUE;
33
+ use sghtmltopdf_core::sink::Sink;
34
+
35
+ use crate::gvl;
36
+
37
+ /// ブロックが中断したときに`Sink::write`が返すエラー。
38
+ ///
39
+ /// `convert::render`が`Sink<Output = (), Error = io::Error>`を要求するため、
40
+ /// Ruby由来の情報をエラーの型に載せられない。本当の理由は
41
+ /// [`PendingUnwind`]へ置き、こちらは「巻き戻すための合図」として使う。
42
+ fn interrupted() -> io::Error {
43
+ io::Error::other("Rubyのブロックが中断しました")
44
+ }
45
+
46
+ /// `rb_gc_register_address`でGCから守った`VALUE`の置き場。
47
+ ///
48
+ /// Rubyの保守的GCはマシンスタックを走査してVALUEを見つけるが、
49
+ /// GVLを解放した時点のスタック位置までしか走査しない
50
+ /// (解放時にマシンコンテキストが保存されるため)。`without_gvl`の内側で
51
+ /// スタックに積んだ値はその先にあるので走査されない。解放区間をまたいで
52
+ /// 生かしたいVALUEは、必ずここへ登録する。
53
+ ///
54
+ /// 登録アドレスは`Box`で固定する。GCのコンパクションでオブジェクトが移動
55
+ /// しても、登録したアドレスの中身は更新されるため、古い参照を掴まない。
56
+ pub struct ValueSlot {
57
+ slot: Box<VALUE>,
58
+ }
59
+
60
+ impl ValueSlot {
61
+ pub fn new(value: VALUE) -> Self {
62
+ let mut slot = Box::new(value);
63
+ unsafe { rb_sys::rb_gc_register_address(&mut *slot) };
64
+ Self { slot }
65
+ }
66
+
67
+ /// GC登録済みスロットのアドレス。
68
+ pub fn addr(&self) -> *mut VALUE {
69
+ &*self.slot as *const VALUE as *mut VALUE
70
+ }
71
+
72
+ /// 現在の`VALUE`。GVLを保持している間だけ呼ぶこと。
73
+ pub fn get(&self) -> VALUE {
74
+ *self.slot
75
+ }
76
+ }
77
+
78
+ impl Drop for ValueSlot {
79
+ fn drop(&mut self) {
80
+ unsafe { rb_sys::rb_gc_unregister_address(&mut *self.slot) };
81
+ }
82
+ }
83
+
84
+ /// GVL解放区間へ運ぶための、[`ValueSlot`]のアドレス。
85
+ #[derive(Clone, Copy)]
86
+ pub struct BlockSlot(*mut VALUE);
87
+
88
+ // SAFETY: 解放区間ではアドレスを数値として持ち回るだけで、`VALUE`として
89
+ // 読むのは`with_gvl`の内側(=GVLを保持している間)に限る。指す先は
90
+ // `ValueSlot`がGCに登録済みで、`without_gvl`が返るまで生きている。
91
+ unsafe impl Send for BlockSlot {}
92
+
93
+ impl BlockSlot {
94
+ pub fn new(slot: &ValueSlot) -> Self {
95
+ Self(slot.addr())
96
+ }
97
+
98
+ /// GVLを保持している前提で`Proc`へ戻す。
99
+ fn proc(self) -> Option<Proc> {
100
+ let value = unsafe { Value::from_raw(*self.0) };
101
+ Proc::from_value(value)
102
+ }
103
+ }
104
+
105
+ /// ブロックが投げた例外・脱出を、GVL解放区間の外へ運ぶための受け皿。
106
+ #[derive(Default)]
107
+ pub struct PendingUnwind {
108
+ unwind: Option<Unwind>,
109
+ }
110
+
111
+ enum Unwind {
112
+ /// Rubyの例外オブジェクト。
113
+ Exception(ValueSlot),
114
+ /// Rust側(magnus)が組み立てたエラー。クラスとメッセージを別々に運ぶ。
115
+ Raise { class: ValueSlot, message: String },
116
+ /// `break`・`return`・`throw`など。値は`rb_jump_tag`へ渡すタグ。
117
+ Jump(i32),
118
+ }
119
+
120
+ impl PendingUnwind {
121
+ /// ブロックが中断していれば`true`。
122
+ pub fn is_pending(&self) -> bool {
123
+ self.unwind.is_some()
124
+ }
125
+
126
+ /// magnusの`Error`を、解放区間をまたげる形に変換して保存する。
127
+ /// GVLを保持している間に呼ぶこと(GCへの登録を行うため)。
128
+ fn store(&mut self, error: Error) {
129
+ use magnus::error::ErrorType;
130
+
131
+ let unwind = match error.error_type() {
132
+ ErrorType::Jump(tag) => Unwind::Jump(*tag as i32),
133
+ ErrorType::Error(class, message) => Unwind::Raise {
134
+ class: ValueSlot::new(class.as_raw()),
135
+ message: message.to_string(),
136
+ },
137
+ ErrorType::Exception(exception) => {
138
+ Unwind::Exception(ValueSlot::new(exception.as_raw()))
139
+ }
140
+ };
141
+ self.unwind = Some(unwind);
142
+ }
143
+
144
+ /// 保存した中断をRubyへ返す。GVLを保持している間に呼ぶこと。
145
+ ///
146
+ /// `break`などの脱出は`rb_jump_tag`で忠実に伝播させる。この関数は
147
+ /// そこから戻らないため、Rust側の後始末が済んでから呼ぶこと
148
+ /// (`ValueSlot`のGC登録解除もこの関数の中で済ませてある)。
149
+ pub fn into_error(self) -> Option<Error> {
150
+ match self.unwind? {
151
+ Unwind::Exception(slot) => {
152
+ let value = unsafe { Value::from_raw(slot.get()) };
153
+ // 登録を外すのは`Error`を組み立てたあと。ここから先は
154
+ // 呼び出し元がGVLを保持したままRubyへ戻るので、
155
+ // 保守的GCの走査範囲に入る。
156
+ let error = magnus::Exception::from_value(value).map(Error::from);
157
+ drop(slot);
158
+ Some(error.unwrap_or_else(|| {
159
+ Error::new(
160
+ Ruby::get()
161
+ .expect("GVLを保持したまま呼ばれるはず")
162
+ .exception_runtime_error(),
163
+ "ブロックが投げた例外を復元できませんでした",
164
+ )
165
+ }))
166
+ }
167
+ Unwind::Raise { class, message } => {
168
+ let value = unsafe { Value::from_raw(class.get()) };
169
+ let error = ExceptionClass::from_value(value).map(|c| Error::new(c, message));
170
+ drop(class);
171
+ Some(error.unwrap_or_else(|| {
172
+ Error::new(
173
+ Ruby::get()
174
+ .expect("GVLを保持したまま呼ばれるはず")
175
+ .exception_runtime_error(),
176
+ "ブロックの中断を復元できませんでした",
177
+ )
178
+ }))
179
+ }
180
+ // `rb_jump_tag`は戻らない(`-> !`)。`self`の他のフィールドは
181
+ // ここまでで全部落ちている。
182
+ Unwind::Jump(tag) => unsafe { rb_sys::rb_jump_tag(tag) },
183
+ }
184
+ }
185
+ }
186
+
187
+ /// 確定したバイト列を`chunk_size`ごとにチャネルへ流すSink。
188
+ ///
189
+ /// レンダリングスレッド側で使う。Rubyには一切触れないので、`Send`であり
190
+ /// GVLの制約とも無縁。1チャンク送るごとに受け取り側の応答を待つ
191
+ /// (rendezvous)ことで、ブロックの処理より先に走ってメモリを溜め込まない。
192
+ pub struct ChannelSink {
193
+ chunks: SyncSender<Vec<u8>>,
194
+ ack: Receiver<bool>,
195
+ buf: Vec<u8>,
196
+ chunk_size: usize,
197
+ }
198
+
199
+ impl ChannelSink {
200
+ fn new(chunks: SyncSender<Vec<u8>>, ack: Receiver<bool>, chunk_size: usize) -> Self {
201
+ Self {
202
+ chunks,
203
+ ack,
204
+ buf: Vec::new(),
205
+ // 0だと1バイトごとにGVLを取り直すことになるため下限を設ける。
206
+ chunk_size: chunk_size.max(1),
207
+ }
208
+ }
209
+
210
+ /// 1チャンク渡して、ブロックが受け取り終えるまで待つ。
211
+ ///
212
+ /// 送れない(受け取り側が降りた)場合と、ブロックが中断を返した場合は
213
+ /// どちらも[`interrupted`]で巻き戻す。中断の本当の理由は受け取り側の
214
+ /// [`PendingUnwind`]に入っている。
215
+ fn hand_off(&mut self, chunk: Vec<u8>) -> Result<(), io::Error> {
216
+ if self.chunks.send(chunk).is_err() {
217
+ return Err(interrupted());
218
+ }
219
+ match self.ack.recv() {
220
+ Ok(true) => Ok(()),
221
+ _ => Err(interrupted()),
222
+ }
223
+ }
224
+ }
225
+
226
+ impl Sink for ChannelSink {
227
+ type Output = ();
228
+ type Error = io::Error;
229
+
230
+ fn write(&mut self, bytes: &[u8]) -> Result<(), io::Error> {
231
+ self.buf.extend_from_slice(bytes);
232
+ while self.buf.len() >= self.chunk_size {
233
+ let chunk: Vec<u8> = self.buf.drain(..self.chunk_size).collect();
234
+ self.hand_off(chunk)?;
235
+ }
236
+ Ok(())
237
+ }
238
+
239
+ fn finish(mut self) -> Result<(), io::Error> {
240
+ if self.buf.is_empty() {
241
+ return Ok(());
242
+ }
243
+ let rest = std::mem::take(&mut self.buf);
244
+ self.hand_off(rest)
245
+ }
246
+ }
247
+
248
+ /// 1チャンクをRubyのブロックへ渡す。中断したら`false`を返す。
249
+ ///
250
+ /// GVLを取り戻すのはこの中だけ。ブロックの呼び出しはmagnusの`Proc::call`が
251
+ /// 内部で`rb_protect`しているので、例外が出てもlongjmpがRustのフレームを
252
+ /// 飛び越えない。
253
+ ///
254
+ /// エラーの保存もこの区間の中で済ませる。`with_gvl`からRubyのオブジェクト
255
+ /// (例外)を持ち出すと、GVLを手放した瞬間にGCのスコープから外れてしまう
256
+ /// ため(`gvl::with_gvl`のドキュメント)。持ち出すのは真偽値だけ。
257
+ fn call_block(block: BlockSlot, pending: &mut PendingUnwind, bytes: Vec<u8>) -> bool {
258
+ gvl::with_gvl(move || {
259
+ let ruby = Ruby::get().expect("with_gvlの内側なのでGVLを持っている");
260
+ let result = match block.proc() {
261
+ Some(proc) => {
262
+ let chunk: RString = ruby.str_from_slice(&bytes);
263
+ proc.call::<_, Value>((chunk,)).map(|_| ())
264
+ }
265
+ None => Err(Error::new(
266
+ ruby.exception_runtime_error(),
267
+ "ブロックが失われました",
268
+ )),
269
+ };
270
+ match result {
271
+ Ok(()) => true,
272
+ Err(error) => {
273
+ // GCへの登録もGVLを持っているこの場で行う。
274
+ pending.store(error);
275
+ false
276
+ }
277
+ }
278
+ })
279
+ }
280
+
281
+ /// `render`をレンダリング専用スレッドで走らせ、出てきたチャンクをこのスレッド
282
+ /// からRubyのブロックへ渡し続ける。
283
+ ///
284
+ /// GVLを解放している区間(`without_gvl`の内側)から、その解放したスレッド上で
285
+ /// 呼ぶこと。モジュールdocの図のうち左側がこの関数にあたる。
286
+ pub fn pump_to_block<F>(
287
+ block: BlockSlot,
288
+ pending: &mut PendingUnwind,
289
+ chunk_size: usize,
290
+ render: F,
291
+ ) -> Result<(), sghtmltopdf_core::cli::CliError>
292
+ where
293
+ F: FnOnce(ChannelSink) -> Result<(), sghtmltopdf_core::cli::CliError> + Send + 'static,
294
+ {
295
+ use sghtmltopdf_core::cli::{CliError, STACK_SIZE};
296
+
297
+ // どちらも容量0のrendezvous。レンダリング側は1チャンクごとに
298
+ // ブロックの完了を待つ。
299
+ let (chunk_tx, chunk_rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(0);
300
+ let (ack_tx, ack_rx) = std::sync::mpsc::sync_channel::<bool>(0);
301
+
302
+ let worker = std::thread::Builder::new()
303
+ .name("sghtmltopdf-render".to_string())
304
+ .stack_size(STACK_SIZE)
305
+ .spawn(move || render(ChannelSink::new(chunk_tx, ack_rx, chunk_size)))
306
+ .map_err(|e| CliError::Input(format!("レンダリングスレッドを作れません: {e}")))?;
307
+
308
+ while let Ok(chunk) = chunk_rx.recv() {
309
+ let ok = call_block(block, pending, chunk);
310
+ // 応答を返せない(レンダリング側が既に降りた)場合も抜ける。
311
+ if ack_tx.send(ok).is_err() || !ok {
312
+ break;
313
+ }
314
+ }
315
+ // 中断で抜けた場合、レンダリング側が次のsendでエラーになって巻き戻れる
316
+ // よう、受け口を先に落とす。
317
+ drop(chunk_rx);
318
+ drop(ack_tx);
319
+
320
+ worker
321
+ .join()
322
+ .unwrap_or_else(|panic| std::panic::resume_unwind(panic))
323
+ }
@@ -0,0 +1,77 @@
1
+ //! Rubyの例外クラスと、コアの[`CliError`]からの対応付け。
2
+
3
+ use std::panic::AssertUnwindSafe;
4
+
5
+ use magnus::{prelude::*, ExceptionClass, RModule, Ruby};
6
+ use sghtmltopdf_core::cli::CliError;
7
+
8
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), magnus::Error> {
9
+ let base = module.define_error("Error", ruby.exception_standard_error())?;
10
+ module.define_error("UsageError", base)?;
11
+ module.define_error("InputError", base)?;
12
+ module.define_error("RenderError", base)?;
13
+ module.define_error("TimeoutError", base)?;
14
+ module.define_error("InternalError", base)?;
15
+ Ok(())
16
+ }
17
+
18
+ /// Rustのパニックを`Sghtmltopdf::InternalError`へ変換して`f`を実行する。
19
+ ///
20
+ /// # なぜ自前で捕まえるか
21
+ ///
22
+ /// magnusもメソッド呼び出しをパニックから守っており、プロセスがabortする
23
+ /// ことはない。ただしmagnusが変換する先はRubyの`fatal`で、これは
24
+ /// `rescue Exception`でも捕まえられずプロセスが終了する。Webアプリの中で
25
+ /// 1リクエストぶんのバグのためにワーカーごと落ちるのは困るので、
26
+ /// magnusへ渡る前にここで`StandardError`の子孫へ変換する。
27
+ ///
28
+ /// パニックはコアの不具合を意味するので、握りつぶさずメッセージを残す。
29
+ pub fn catch_panic<F, R>(ruby: &Ruby, f: F) -> Result<R, magnus::Error>
30
+ where
31
+ F: FnOnce() -> Result<R, magnus::Error>,
32
+ {
33
+ // AssertUnwindSafe: パニックで巻き戻った後に触るのはRuby側の例外生成だけで、
34
+ // Rust側の壊れかけた状態を読み直すことはない。
35
+ match std::panic::catch_unwind(AssertUnwindSafe(f)) {
36
+ Ok(result) => result,
37
+ Err(payload) => Err(magnus::Error::new(
38
+ class(ruby, "InternalError"),
39
+ format!("内部エラー(パニック): {}", panic_message(&payload)),
40
+ )),
41
+ }
42
+ }
43
+
44
+ /// パニックのペイロードから人が読めるメッセージを取り出す。
45
+ fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
46
+ if let Some(message) = payload.downcast_ref::<&'static str>() {
47
+ (*message).to_string()
48
+ } else if let Some(message) = payload.downcast_ref::<String>() {
49
+ message.clone()
50
+ } else {
51
+ "詳細不明".to_string()
52
+ }
53
+ }
54
+
55
+ /// コアのエラーを、対応するRubyの例外へ変換する。
56
+ ///
57
+ /// メッセージはコアが返す文言をそのまま使う(CLIと同じ文言になる)。
58
+ pub fn to_ruby(ruby: &Ruby, error: CliError) -> magnus::Error {
59
+ let (class_name, message) = match error {
60
+ CliError::Usage(message) => ("UsageError", message),
61
+ CliError::Input(message) => ("InputError", message),
62
+ CliError::Render(message) => ("RenderError", message),
63
+ CliError::Timeout(message) => ("TimeoutError", message),
64
+ };
65
+ magnus::Error::new(class(ruby, class_name), message)
66
+ }
67
+
68
+ /// `Sghtmltopdf::<name>`の例外クラスを引く。
69
+ ///
70
+ /// 定義は`.so`のロード時に済んでいる([`define`])。万一引けなかった場合は
71
+ /// エラーを握りつぶさずに`RuntimeError`として上げる。
72
+ pub fn class(ruby: &Ruby, name: &str) -> ExceptionClass {
73
+ ruby.class_object()
74
+ .const_get::<_, RModule>("Sghtmltopdf")
75
+ .and_then(|module| module.const_get::<_, ExceptionClass>(name))
76
+ .unwrap_or_else(|_| ruby.exception_runtime_error())
77
+ }