kino 0.1.2 → 0.1.3

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: 0bd8e6e3b295832fa1d87743b4c9a121cdb5687287011b29f1140814fdae0575
4
- data.tar.gz: f21305459e857d366159ee258d873e2109b7a7715bb0cbe8d23c47e458ae2b33
3
+ metadata.gz: 390e987542cf3e7fe2a605bdd9905181fa0a0630990433c53b465732650cf6fd
4
+ data.tar.gz: 128a9da74166473f5682baf461f4eccd3cf39c3f017786ab3bf6c45ed762ce64
5
5
  SHA512:
6
- metadata.gz: 04e95f9ee2133b4d15bdd2069977e73791a16b033e57a3bdb88478d0048b0bb5d8f56eff1963813bbf8d9399461bb80bf9c33a2039f98805e4f129b3e42b28ef
7
- data.tar.gz: 8eb131cdbbe5bdbd29d188ab4ff43dbbec2f8c791aacf85586ba69c7208b2f74cc05d4007c469a4de61c4578ef08214e56a4fd080b9ad655af3a01d208b4c752
6
+ metadata.gz: 4c7eb584af1dbdc98818a53738dea224952c2980cd2beda5a5ac83cfe695f6bedb82428adcdf29df53fcb3ae7cd995a274e5b6431bbeadc5e1a217073fba01a4
7
+ data.tar.gz: 896aa7e556f501f91e39cd7b9dccb7edcd53ca9ff20c7788b7a615480274eb13df2e28e9dbb584dd36712ac3672140e4d5a420bbfa1c64a8ecb552a01fcf026c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## [0.1.3] - 2026-07-04
2
+
3
+ - Non-String response header names and values (booleans, numbers, symbols)
4
+ are now serialized with `to_s`, matching Puma, instead of failing the
5
+ request with a `TypeError`. (Fixes [#3], reported by Max Erkin @rus-max)
6
+ - New `on_error` directive: a callable invoked with `(exception, env)` when
7
+ a worker catches an app or delivery error, after the client got its 500.
8
+ Delivery errors (unserializable header, a body that raised mid-stream)
9
+ happen after the middleware stack returned, so this hook is the only
10
+ place an error tracker can see them. Handler failures are logged and
11
+ swallowed; in :ractor mode the handler must be Ractor-shareable. (Fixes [#3], reported by Max Erkin @rus-max)
12
+ - Worker error log lines now include the exception backtrace (first 12
13
+ frames), not just the exception class and message.
14
+ - A streaming body that raises mid-stream now aborts the connection
15
+ instead of finishing the chunked response cleanly, so a client can no
16
+ longer mistake a truncated response for a complete one.
17
+
1
18
  ## [0.1.2] - 2026-06-22
2
19
 
3
20
  - Drop a connection that has not sent its complete request headers
@@ -89,3 +106,5 @@ Initial release.
89
106
  - Request timeouts: `request_timeout: seconds` (off by default) returns an
90
107
  immediate 504 when the app misses the deadline; the late response is
91
108
  dropped and the handler is never killed. Counted as `stats[:timeouts]`.
109
+
110
+ [#3]: https://github.com/yaroslav/kino/issues/3
data/Cargo.lock CHANGED
@@ -332,7 +332,7 @@ dependencies = [
332
332
 
333
333
  [[package]]
334
334
  name = "kino"
335
- version = "0.1.2"
335
+ version = "0.1.3"
336
336
  dependencies = [
337
337
  "ahash",
338
338
  "bytes",
data/README.md CHANGED
@@ -14,7 +14,7 @@ and a threaded fallback mode runs everything else, Rails included.
14
14
  * **Fast.** On a real 8-core server, every Kino mode is **1.5-2×**
15
15
  ahead of a Puma fork cluster on I/O-light endpoints. Ractor mode also
16
16
  wins on pure CPU, **30%+**. [Benchmarks](#benchmarks) below.
17
- * **A fraction of the memory.** Aabout **~7×** on the simplistic bench
17
+ * **A fraction of the memory.** About **~7×** on the simplistic bench
18
18
  Ractor app, and about **4× less memory** than a Puma cluster serving Rails in fallback threaded mode.
19
19
  * **Parallel without forking.** Ractor mode runs CPU work **more than
20
20
  5× faster** than Kino's own GVL-bound threaded mode, in the same
data/ext/kino/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kino"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  edition = "2021"
5
5
  authors = ["Yaroslav Markin <yaroslav@markin.net>"]
6
6
  license = "MIT"
@@ -9,7 +9,8 @@ use std::sync::Arc;
9
9
 
10
10
  use bytes::Bytes;
11
11
  use magnus::r_hash::ForEach;
12
- use magnus::{Error, RArray, RHash, RString, Ruby, TryConvert, Value};
12
+ use magnus::value::ReprValue;
13
+ use magnus::{Error, RArray, RHash, RString, Ruby, Value};
13
14
 
14
15
  use crate::gvl;
15
16
  use crate::response::{full_body, BodyFrame, Responder};
@@ -348,7 +349,8 @@ impl Request {
348
349
  /// copies the bytes immediately.
349
350
  fn build_head(status: u16, headers: RHash) -> Result<hyper::http::response::Builder, Error> {
350
351
  let mut builder = Some(hyper::Response::builder().status(status));
351
- headers.foreach(|name: RString, value: Value| {
352
+ headers.foreach(|name: Value, value: Value| {
353
+ let name = coerce_str(name)?;
352
354
  let take = |b: &mut Option<hyper::http::response::Builder>, v: RString| {
353
355
  let next = b
354
356
  .take()
@@ -358,16 +360,27 @@ fn build_head(status: u16, headers: RHash) -> Result<hyper::http::response::Buil
358
360
  };
359
361
  if let Some(values) = RArray::from_value(value) {
360
362
  for i in 0..values.len() {
361
- take(&mut builder, values.entry::<RString>(i as isize)?);
363
+ take(&mut builder, coerce_str(values.entry(i as isize)?)?);
362
364
  }
363
365
  } else {
364
- take(&mut builder, RString::try_convert(value)?);
366
+ take(&mut builder, coerce_str(value)?);
365
367
  }
366
368
  Ok(ForEach::Continue)
367
369
  })?;
368
370
  Ok(builder.expect("builder always present"))
369
371
  }
370
372
 
373
+ /// Rack SPEC says header names and values are Strings, but real apps set
374
+ /// booleans, numbers, and symbols, and Puma serves them via to_s. Match
375
+ /// that: Strings pass through untouched (the hot path), anything else pays
376
+ /// one to_s call back into Ruby.
377
+ fn coerce_str(value: Value) -> Result<RString, Error> {
378
+ match RString::from_value(value) {
379
+ Some(s) => Ok(s),
380
+ None => value.funcall("to_s", ()),
381
+ }
382
+ }
383
+
371
384
  fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
372
385
  match host.rsplit_once(':') {
373
386
  Some((name, port)) if !name.is_empty() => match port.parse() {
@@ -22,6 +22,7 @@ module Kino
22
22
  batch: 1,
23
23
  lanes: false,
24
24
  log_requests: false,
25
+ on_error: nil,
25
26
  shutdown_timeout: 30,
26
27
  tokio_threads: nil,
27
28
  tls: nil,
@@ -179,6 +180,11 @@ module Kino
179
180
  # Native access log: one status-colored line per request to stdout.
180
181
  def log_requests(enabled) = @config.set(:log_requests, !!enabled)
181
182
 
183
+ # Called with (exception, env) when a worker catches an app or
184
+ # delivery error; wire your error tracker here. Takes a callable
185
+ # or a block. Must be Ractor-shareable in :ractor mode.
186
+ def on_error(handler = nil, &block) = @config.set(:on_error, handler || block)
187
+
182
188
  # Graceful-shutdown drain deadline in seconds.
183
189
  def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
184
190
 
@@ -9,12 +9,13 @@ module Kino
9
9
  class RactorSupervisor
10
10
  attr_reader :respawns
11
11
 
12
- def initialize(server_id, app, workers:, threads:, batch: 1)
12
+ def initialize(server_id, app, workers:, threads:, batch: 1, on_error: nil)
13
13
  @server_id = server_id
14
14
  @app = app
15
15
  @workers = workers
16
16
  @threads = threads
17
17
  @batch = batch
18
+ @on_error = on_error
18
19
  @respawns = 0
19
20
  @draining = false
20
21
  @lock = Mutex.new
@@ -83,13 +84,13 @@ module Kino
83
84
  # old slot.
84
85
  def spawn_worker
85
86
  worker_ids = Array.new(@threads) { Native.register_worker(@server_id) }
86
- ractor = Ractor.new(@server_id, worker_ids, @app, @batch) do |server_id, ids, app, batch|
87
+ ractor = Ractor.new(@server_id, worker_ids, @app, @batch, @on_error) do |server_id, ids, app, batch, on_error|
87
88
  ids.map do |id|
88
89
  Thread.new do
89
90
  # Crashes surface via Ractor#value in the supervisor; don't also
90
91
  # spray the backtrace to stderr from inside the dying ractor.
91
92
  Thread.current.report_on_exception = false
92
- Kino::Worker.run(server_id, id, app, batch)
93
+ Kino::Worker.run(server_id, id, app, batch, on_error)
93
94
  end
94
95
  end.each(&:join)
95
96
  end
data/lib/kino/server.rb CHANGED
@@ -41,6 +41,7 @@ module Kino
41
41
  @bind = settings[:bind]
42
42
  @requested_port = settings[:port]
43
43
  @workers = Integer(settings[:workers])
44
+ @on_error = validate_on_error(settings[:on_error])
44
45
  @mode = resolve_mode(settings[:mode])
45
46
  # Default threads per mode: 1 in :ractor (threads inside a ractor
46
47
  # share its lock; a measured +17% on fast handlers; raise `workers`
@@ -84,11 +85,12 @@ module Kino
84
85
  )
85
86
  File.write(@pidfile, "#{Process.pid}\n") if @pidfile
86
87
  if @mode == :ractor
87
- @supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads, batch: @batch).start
88
+ @supervisor = RactorSupervisor.new(@id, @app, workers: @workers, threads: @threads,
89
+ batch: @batch, on_error: @on_error).start
88
90
  else
89
91
  @worker_threads = (@workers * @threads).times.map do
90
92
  worker_id = Native.register_worker(@id)
91
- Thread.new { Worker.run(@id, worker_id, @app, @batch) }
93
+ Thread.new { Worker.run(@id, worker_id, @app, @batch, @on_error) }
92
94
  end
93
95
  end
94
96
  @started = true
@@ -214,6 +216,15 @@ module Kino
214
216
  {cert: String(tls[:cert]), key: String(tls[:key])}
215
217
  end
216
218
 
219
+ def validate_on_error(handler)
220
+ return nil if handler.nil?
221
+ unless handler.respond_to?(:call)
222
+ raise ArgumentError, "on_error must respond to #call (got #{handler.class})"
223
+ end
224
+
225
+ handler
226
+ end
227
+
217
228
  def monotonic_now
218
229
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
219
230
  end
@@ -275,13 +286,21 @@ module Kino
275
286
  "Ractor.shareable_proc endpoints); try Ractor.make_shareable(app) " \
276
287
  "or mode: :threaded"
277
288
  end
289
+ unless @on_error.nil? || Ractor.shareable?(@on_error)
290
+ raise Error,
291
+ "mode: :ractor requires a Ractor-shareable on_error handler " \
292
+ "(build it with Ractor.shareable_proc, or use mode: :threaded)"
293
+ end
278
294
  :ractor
279
295
  when :auto
280
- if Ractor.shareable?(@app)
281
- :ractor
282
- else
296
+ if !Ractor.shareable?(@app)
283
297
  warn "Kino: app is not Ractor-shareable; falling back to mode: :threaded"
284
298
  :threaded
299
+ elsif !(@on_error.nil? || Ractor.shareable?(@on_error))
300
+ warn "Kino: on_error handler is not Ractor-shareable; falling back to mode: :threaded"
301
+ :threaded
302
+ else
303
+ :ractor
285
304
  end
286
305
  else
287
306
  raise ArgumentError, "mode must be :auto, :ractor, or :threaded (got #{requested.inspect})"
@@ -79,6 +79,14 @@
79
79
  # never saw, such as 503s. Recommended in development.
80
80
  # log_requests false
81
81
 
82
+ # Called when a worker catches an app or delivery error, after the client
83
+ # got its 500. This is the only place that sees errors raised while the
84
+ # response was being written (bad header, body that died mid-stream):
85
+ # they happen after your middleware returned, so only the server can
86
+ # report them. Wire your error tracker here. In :ractor mode the handler
87
+ # must be Ractor-shareable (build it with Ractor.shareable_proc).
88
+ # on_error ->(error, env) { ExceptionService.capture(error) }
89
+
82
90
  ## Lifecycle
83
91
 
84
92
  # On shutdown, give in-flight requests this many seconds to finish.
data/lib/kino/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Kino
4
4
  # The gem version (single source of truth; ext/kino/Cargo.toml syncs).
5
- VERSION = "0.1.2"
5
+ VERSION = "0.1.3"
6
6
  end
data/lib/kino/worker.rb CHANGED
@@ -18,13 +18,13 @@ module Kino
18
18
 
19
19
  module_function
20
20
 
21
- def run(server_id, worker_id, app, batch_size = 1)
21
+ def run(server_id, worker_id, app, batch_size = 1, on_error = nil)
22
22
  if batch_size <= 1
23
23
  env = Native.take_one(server_id, worker_id)
24
- env = handle_one(env, server_id, worker_id, app) while env
24
+ env = handle_one(env, server_id, worker_id, app, on_error) while env
25
25
  else
26
26
  batch = Native.take_batch(server_id, worker_id, batch_size)
27
- batch = process(batch, server_id, worker_id, app, batch_size) while batch
27
+ batch = process(batch, server_id, worker_id, app, batch_size, on_error) while batch
28
28
  end
29
29
  end
30
30
 
@@ -34,8 +34,8 @@ module Kino
34
34
  NOT_FUSED = Object.new.freeze
35
35
 
36
36
  # Handle one request; returns the next env (fused take) or nil.
37
- def handle_one(env, server_id, worker_id, app)
38
- result = serve(env, app) do |request, status, headers, chunks|
37
+ def handle_one(env, server_id, worker_id, app, on_error)
38
+ result = serve(env, app, on_error) do |request, status, headers, chunks|
39
39
  request.respond_and_take_one(server_id, worker_id, status, headers, chunks)
40
40
  end
41
41
  result.equal?(NOT_FUSED) ? Native.take_one(server_id, worker_id) : result
@@ -43,10 +43,10 @@ module Kino
43
43
 
44
44
  # Handle every env in the batch; returns the next batch (the last
45
45
  # simple response rides the fused respond_and_take) or nil on shutdown.
46
- def process(batch, server_id, worker_id, app, batch_size)
46
+ def process(batch, server_id, worker_id, app, batch_size, on_error)
47
47
  last = batch.size - 1
48
48
  batch.each_with_index do |env, index|
49
- result = serve(env, app) do |request, status, headers, chunks|
49
+ result = serve(env, app, on_error) do |request, status, headers, chunks|
50
50
  if index == last
51
51
  request.respond_and_take(server_id, worker_id, batch_size,
52
52
  status, headers, chunks)
@@ -66,7 +66,7 @@ module Kino
66
66
  # here and return NOT_FUSED. App errors must never kill the worker;
67
67
  # hard crashes (Exception) are the supervisor's job; and `abort` does
68
68
  # the right thing whether or not the response head already went out.
69
- def serve(env, app)
69
+ def serve(env, app, on_error)
70
70
  request = env[KINO_REQUEST]
71
71
  env[RACK_INPUT] ||= Input.new(request)
72
72
  status, headers, body = app.call(env)
@@ -80,11 +80,32 @@ module Kino
80
80
  NOT_FUSED
81
81
  end
82
82
  rescue => e
83
- Native.log_error("#{e.class}: #{e.message}")
83
+ # Abort before the hook: the client's 500 must never wait on a
84
+ # reporting round-trip. The hook is the app's only window onto
85
+ # delivery errors (they happen after app.call returned, so no
86
+ # middleware can see them); its own failures are logged, not raised,
87
+ # because nothing may escape this block and kill the worker.
88
+ Native.log_error(error_log_line(e))
84
89
  request.abort
90
+ if on_error
91
+ begin
92
+ on_error.call(e, env)
93
+ rescue => hook_error
94
+ Native.log_error("on_error hook raised #{hook_error.class}: #{hook_error.message}")
95
+ end
96
+ end
85
97
  NOT_FUSED
86
98
  end
87
99
 
100
+ # First frames only: the raise site is at the top, and Rails stacks
101
+ # run hundreds of middleware frames deep. Hooks get the full exception.
102
+ BACKTRACE_FRAMES = 12
103
+
104
+ def error_log_line(error)
105
+ ["#{error.class}: #{error.message}",
106
+ *(error.backtrace || []).first(BACKTRACE_FRAMES)].join("\n ")
107
+ end
108
+
88
109
  def deliver_streaming(request, status, headers, body, input)
89
110
  request.send_headers(status, headers)
90
111
  if body.respond_to?(:call) && !body.respond_to?(:each)
@@ -99,10 +120,13 @@ module Kino
99
120
  end
100
121
  else
101
122
  # Enumerable body: chunked transfer unless the app set content-length.
123
+ # finish only on success: a body that raised must abort the
124
+ # connection (serve's rescue), not fake a clean end of stream that
125
+ # the client cannot tell from a complete response.
102
126
  begin
103
127
  body.each { |chunk| request.write_chunk(chunk) }
104
- ensure
105
128
  request.finish
129
+ ensure
106
130
  body.close if body.respond_to?(:close)
107
131
  end
108
132
  end
@@ -119,6 +143,6 @@ module Kino
119
143
  end
120
144
 
121
145
  private_class_method :handle_one, :process, :serve, :deliver_streaming,
122
- :join_chunks
146
+ :join_chunks, :error_log_line
123
147
  end
124
148
  end
data/sig/kino.rbs CHANGED
@@ -97,6 +97,7 @@ module Kino
97
97
  def batch: (int count) -> untyped
98
98
  def lanes: (boolish enabled) -> untyped
99
99
  def log_requests: (boolish enabled) -> untyped
100
+ def on_error: (?^(Exception, Hash[String, untyped]) -> void handler) ?{ (Exception, Hash[String, untyped]) -> void } -> untyped
100
101
  def shutdown_timeout: (Numeric seconds) -> untyped
101
102
  def tokio_threads: (int count) -> untyped
102
103
  def tls: (cert: String, key: String) -> untyped
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kino
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin