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 +4 -4
- data/CHANGELOG.md +19 -0
- data/Cargo.lock +1 -1
- data/README.md +1 -1
- data/ext/kino/Cargo.toml +1 -1
- data/ext/kino/src/request.rs +17 -4
- data/lib/kino/configuration.rb +6 -0
- data/lib/kino/ractor_supervisor.rb +4 -3
- data/lib/kino/server.rb +24 -5
- data/lib/kino/templates/kino.rb.tt +8 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +35 -11
- data/sig/kino.rbs +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 390e987542cf3e7fe2a605bdd9905181fa0a0630990433c53b465732650cf6fd
|
|
4
|
+
data.tar.gz: 128a9da74166473f5682baf461f4eccd3cf39c3f017786ab3bf6c45ed762ce64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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.**
|
|
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
data/ext/kino/src/request.rs
CHANGED
|
@@ -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::
|
|
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:
|
|
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
|
|
363
|
+
take(&mut builder, coerce_str(values.entry(i as isize)?)?);
|
|
362
364
|
}
|
|
363
365
|
} else {
|
|
364
|
-
take(&mut builder,
|
|
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() {
|
data/lib/kino/configuration.rb
CHANGED
|
@@ -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,
|
|
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
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
|
-
|
|
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
|