kino 0.1.1 → 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 +47 -0
- data/Cargo.lock +1 -1
- data/README.md +65 -31
- data/doc/benchmarks.md +138 -85
- data/doc/rails-on-ractors.md +5 -4
- data/doc/why-kino.md +7 -7
- data/ext/kino/Cargo.toml +1 -1
- data/ext/kino/src/registry.rs +4 -0
- data/ext/kino/src/request.rs +50 -5
- data/ext/kino/src/server.rs +123 -25
- data/lib/kino/configuration.rb +16 -0
- data/lib/kino/ractor_supervisor.rb +4 -3
- data/lib/kino/server.rb +40 -5
- data/lib/kino/templates/kino.rb.tt +19 -0
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +35 -11
- data/sig/kino.rbs +3 -0
- metadata +1 -1
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};
|
|
@@ -25,6 +26,12 @@ pub struct RequestCtx {
|
|
|
25
26
|
/// Request body, streamed from hyper through a bounded channel: hyper is
|
|
26
27
|
/// only polled as Ruby consumes, so inbound backpressure is free.
|
|
27
28
|
pub body_rx: flume::Receiver<Bytes>,
|
|
29
|
+
/// Set by the body forwarder when the body exceeded max_body_size: turns
|
|
30
|
+
/// the next read into an error instead of a (truncated) clean EOF.
|
|
31
|
+
pub body_overflow: Arc<std::sync::atomic::AtomicBool>,
|
|
32
|
+
/// Set by the body forwarder when the client stalled past the idle
|
|
33
|
+
/// deadline: the next read raises so the worker reclaims its slot.
|
|
34
|
+
pub body_timeout: Arc<std::sync::atomic::AtomicBool>,
|
|
28
35
|
/// When a frame is bigger than read_body's max_len, the rest waits here.
|
|
29
36
|
pub leftover: Option<Bytes>,
|
|
30
37
|
/// The owning worker slot (set at admit time, queue.rs); its interrupt
|
|
@@ -62,6 +69,20 @@ fn interrupted_error(ruby: &Ruby) -> Error {
|
|
|
62
69
|
)
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
fn body_too_large_error(ruby: &Ruby) -> Error {
|
|
73
|
+
Error::new(
|
|
74
|
+
ruby.exception_runtime_error(),
|
|
75
|
+
"Kino: request body exceeded max_body_size",
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn body_timeout_error(ruby: &Ruby) -> Error {
|
|
80
|
+
Error::new(
|
|
81
|
+
ruby.exception_runtime_error(),
|
|
82
|
+
"Kino: request body read timed out",
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
fn invalid_response(ruby: &Ruby, e: impl std::fmt::Display) -> Error {
|
|
66
87
|
Error::new(
|
|
67
88
|
ruby.exception_runtime_error(),
|
|
@@ -238,7 +259,17 @@ impl Request {
|
|
|
238
259
|
});
|
|
239
260
|
match outcome {
|
|
240
261
|
Some(Some(bytes)) => bytes,
|
|
241
|
-
Some(None) =>
|
|
262
|
+
Some(None) => {
|
|
263
|
+
// Disconnected: a clean EOF, unless the forwarder
|
|
264
|
+
// abandoned the body (too large, or the client stalled).
|
|
265
|
+
if ctx.body_overflow.load(std::sync::atomic::Ordering::Relaxed) {
|
|
266
|
+
return Err(body_too_large_error(ruby));
|
|
267
|
+
}
|
|
268
|
+
if ctx.body_timeout.load(std::sync::atomic::Ordering::Relaxed) {
|
|
269
|
+
return Err(body_timeout_error(ruby));
|
|
270
|
+
}
|
|
271
|
+
return Ok(None); // EOF
|
|
272
|
+
}
|
|
242
273
|
None => return Err(interrupted_error(ruby)),
|
|
243
274
|
}
|
|
244
275
|
}
|
|
@@ -318,7 +349,8 @@ impl Request {
|
|
|
318
349
|
/// copies the bytes immediately.
|
|
319
350
|
fn build_head(status: u16, headers: RHash) -> Result<hyper::http::response::Builder, Error> {
|
|
320
351
|
let mut builder = Some(hyper::Response::builder().status(status));
|
|
321
|
-
headers.foreach(|name:
|
|
352
|
+
headers.foreach(|name: Value, value: Value| {
|
|
353
|
+
let name = coerce_str(name)?;
|
|
322
354
|
let take = |b: &mut Option<hyper::http::response::Builder>, v: RString| {
|
|
323
355
|
let next = b
|
|
324
356
|
.take()
|
|
@@ -328,16 +360,27 @@ fn build_head(status: u16, headers: RHash) -> Result<hyper::http::response::Buil
|
|
|
328
360
|
};
|
|
329
361
|
if let Some(values) = RArray::from_value(value) {
|
|
330
362
|
for i in 0..values.len() {
|
|
331
|
-
take(&mut builder, values.entry
|
|
363
|
+
take(&mut builder, coerce_str(values.entry(i as isize)?)?);
|
|
332
364
|
}
|
|
333
365
|
} else {
|
|
334
|
-
take(&mut builder,
|
|
366
|
+
take(&mut builder, coerce_str(value)?);
|
|
335
367
|
}
|
|
336
368
|
Ok(ForEach::Continue)
|
|
337
369
|
})?;
|
|
338
370
|
Ok(builder.expect("builder always present"))
|
|
339
371
|
}
|
|
340
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
|
+
|
|
341
384
|
fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
|
|
342
385
|
match host.rsplit_once(':') {
|
|
343
386
|
Some((name, port)) if !name.is_empty() => match port.parse() {
|
|
@@ -363,6 +406,8 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
|
|
|
363
406
|
local_addr: "127.0.0.1:9292".parse().expect("static addr"),
|
|
364
407
|
https: false,
|
|
365
408
|
body_rx,
|
|
409
|
+
body_overflow: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
410
|
+
body_timeout: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
366
411
|
leftover: None,
|
|
367
412
|
slot: None,
|
|
368
413
|
responder: Arc::new(Responder::new(head_tx)),
|
data/ext/kino/src/server.rs
CHANGED
|
@@ -51,6 +51,8 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
51
51
|
let queue_depth: usize = cfg(ruby, config, "queue_depth")?;
|
|
52
52
|
let queue_timeout_ms: u64 = cfg(ruby, config, "queue_timeout_ms")?;
|
|
53
53
|
let request_timeout_ms: u64 = cfg_opt::<u64>(ruby, config, "request_timeout_ms")?.unwrap_or(0);
|
|
54
|
+
let max_body_size: usize = cfg_opt::<usize>(ruby, config, "max_body_size")?.unwrap_or(0);
|
|
55
|
+
let max_connections: usize = cfg_opt::<usize>(ruby, config, "max_connections")?.unwrap_or(1024);
|
|
54
56
|
let tokio_threads: usize = cfg_opt::<usize>(ruby, config, "tokio_threads")?.unwrap_or(0);
|
|
55
57
|
let tls_cert: Option<String> = cfg_opt(ruby, config, "tls_cert")?;
|
|
56
58
|
let tls_key: Option<String> = cfg_opt(ruby, config, "tls_key")?;
|
|
@@ -104,6 +106,7 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
104
106
|
rejected: std::sync::atomic::AtomicU64::new(0),
|
|
105
107
|
queue_timeout_ms,
|
|
106
108
|
request_timeout_ms,
|
|
109
|
+
max_body_size,
|
|
107
110
|
timeouts: std::sync::atomic::AtomicU64::new(0),
|
|
108
111
|
https: acceptor.is_some(),
|
|
109
112
|
access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
|
|
@@ -120,6 +123,7 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
120
123
|
tokio_listener,
|
|
121
124
|
acceptor,
|
|
122
125
|
server.clone(),
|
|
126
|
+
max_connections,
|
|
123
127
|
shutdown_rx,
|
|
124
128
|
));
|
|
125
129
|
*server.runtime.lock() = Some(runtime);
|
|
@@ -129,40 +133,73 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Er
|
|
|
129
133
|
Ok((id, local_port))
|
|
130
134
|
}
|
|
131
135
|
|
|
136
|
+
/// Slowloris guard for TLS: a client that completes the TCP connect but then
|
|
137
|
+
/// stalls the handshake would otherwise hold a connection slot indefinitely
|
|
138
|
+
/// (the per-request and header-read deadlines only start once hyper is
|
|
139
|
+
/// serving, i.e. after the handshake). A handshake is a few round trips, so
|
|
140
|
+
/// this is generous even for a high-latency client. Fixed, like the header
|
|
141
|
+
/// timeout: not a knob.
|
|
142
|
+
const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
143
|
+
|
|
132
144
|
async fn accept_loop(
|
|
133
145
|
listener: tokio::net::TcpListener,
|
|
134
146
|
acceptor: Option<tokio_rustls::TlsAcceptor>,
|
|
135
147
|
server: Arc<ServerInner>,
|
|
148
|
+
max_connections: usize,
|
|
136
149
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
|
137
150
|
) {
|
|
151
|
+
// Bound concurrent connections: unbounded, a flood spawns a task and holds
|
|
152
|
+
// a socket per connection until file descriptors or memory run out. One
|
|
153
|
+
// permit per live connection; acquiring BEFORE accept leaves the excess in
|
|
154
|
+
// the kernel backlog (backpressure) rather than accepting then dropping.
|
|
155
|
+
let conn_limit = Arc::new(tokio::sync::Semaphore::new(max_connections));
|
|
138
156
|
loop {
|
|
139
|
-
tokio::select! {
|
|
157
|
+
let permit = tokio::select! {
|
|
140
158
|
_ = shutdown_rx.changed() => break,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
permit = conn_limit.clone().acquire_owned() => match permit {
|
|
160
|
+
Ok(permit) => permit,
|
|
161
|
+
Err(_) => break, // semaphore closed
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
let (stream, remote_addr) = tokio::select! {
|
|
165
|
+
_ = shutdown_rx.changed() => break,
|
|
166
|
+
accepted = listener.accept() => match accepted {
|
|
167
|
+
Ok(pair) => pair,
|
|
168
|
+
Err(_) => continue, // transient accept error; permit drops, retry
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
// Small responses must not wait on Nagle + delayed ACK.
|
|
172
|
+
let _ = stream.set_nodelay(true);
|
|
173
|
+
let local_addr = stream
|
|
174
|
+
.local_addr()
|
|
175
|
+
.unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
|
|
176
|
+
let server = server.clone();
|
|
177
|
+
let acceptor = acceptor.clone();
|
|
178
|
+
tokio::spawn(async move {
|
|
179
|
+
// Held for the connection's lifetime; dropping it frees a slot.
|
|
180
|
+
let _permit = permit;
|
|
181
|
+
match acceptor {
|
|
182
|
+
Some(acceptor) => {
|
|
183
|
+
// Handshake failures (port scans, plain HTTP to a TLS
|
|
184
|
+
// port) and stalled handshakes (slowloris) just drop the
|
|
185
|
+
// connection; the timeout bounds the latter.
|
|
186
|
+
let handshake = tokio::time::timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream));
|
|
187
|
+
let Ok(Ok(tls)) = handshake.await else { return };
|
|
188
|
+
serve_connection(tls, server, remote_addr, local_addr).await;
|
|
189
|
+
}
|
|
190
|
+
None => serve_connection(stream, server, remote_addr, local_addr).await,
|
|
161
191
|
}
|
|
162
|
-
}
|
|
192
|
+
});
|
|
163
193
|
}
|
|
164
194
|
}
|
|
165
195
|
|
|
196
|
+
/// Slowloris guard: drop a connection that has not sent its complete request
|
|
197
|
+
/// headers within this window. Long enough never to trip a real client (even
|
|
198
|
+
/// on a slow mobile link), short enough to reap a stalled one. Deliberately a
|
|
199
|
+
/// constant, not a config knob: fine-tuning intake limits is the fronting
|
|
200
|
+
/// proxy's job; the actual hazard was having no default at all.
|
|
201
|
+
const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
|
|
202
|
+
|
|
166
203
|
async fn serve_connection<I>(
|
|
167
204
|
io: I,
|
|
168
205
|
server: Arc<ServerInner>,
|
|
@@ -176,7 +213,13 @@ async fn serve_connection<I>(
|
|
|
176
213
|
// No auto Date header: it costs a clock read per response (together
|
|
177
214
|
// with timer reads, ~7% of tokio-side cycles in the profile); it's a
|
|
178
215
|
// SHOULD not a MUST, and apps that need it can set it themselves.
|
|
216
|
+
//
|
|
217
|
+
// The timer is installed so header_read_timeout actually fires: hyper's
|
|
218
|
+
// slow-header guard is inert without one. It arms only while the request
|
|
219
|
+
// head is being read, so it adds no per-response cost on the hot path.
|
|
179
220
|
let _ = hyper::server::conn::http1::Builder::new()
|
|
221
|
+
.timer(hyper_util::rt::TokioTimer::new())
|
|
222
|
+
.header_read_timeout(HEADER_READ_TIMEOUT)
|
|
180
223
|
.auto_date_header(false)
|
|
181
224
|
.serve_connection(TokioIo::new(io), service)
|
|
182
225
|
.await;
|
|
@@ -198,6 +241,25 @@ fn branded(mut response: HyperResponse) -> HyperResponse {
|
|
|
198
241
|
response
|
|
199
242
|
}
|
|
200
243
|
|
|
244
|
+
/// A single valid Content-Length as a byte count. hyper has already rejected
|
|
245
|
+
/// conflicting/duplicate values, so the first is authoritative; anything
|
|
246
|
+
/// unparseable yields None and the streaming cap still applies.
|
|
247
|
+
fn content_length(headers: &http::HeaderMap) -> Option<u64> {
|
|
248
|
+
headers
|
|
249
|
+
.get(http::header::CONTENT_LENGTH)?
|
|
250
|
+
.to_str()
|
|
251
|
+
.ok()?
|
|
252
|
+
.trim()
|
|
253
|
+
.parse()
|
|
254
|
+
.ok()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// Idle deadline between request-body frames. A client that stalls mid-body
|
|
258
|
+
/// would otherwise hold a worker slot indefinitely (the worker blocks in
|
|
259
|
+
/// read_body). Generous: a real upload sends steadily and resets this each
|
|
260
|
+
/// frame, so only a silent client trips it. Fixed, like the header timeout.
|
|
261
|
+
const BODY_READ_TIMEOUT: Duration = Duration::from_secs(30);
|
|
262
|
+
|
|
201
263
|
async fn handle_request(
|
|
202
264
|
server: Arc<ServerInner>,
|
|
203
265
|
remote_addr: SocketAddr,
|
|
@@ -221,19 +283,50 @@ async fn handle_request(
|
|
|
221
283
|
)
|
|
222
284
|
});
|
|
223
285
|
|
|
286
|
+
// Body-size guard: an honestly-declared oversize body is refused with a
|
|
287
|
+
// 413 below, before any worker runs. Chunked or lying clients are caught
|
|
288
|
+
// by the forwarder, which caps cumulative bytes and flags an overflow so
|
|
289
|
+
// read_body raises instead of letting the app buffer without bound.
|
|
290
|
+
let max_body = server.max_body_size;
|
|
291
|
+
let oversize =
|
|
292
|
+
max_body > 0 && content_length(&parts.headers).is_some_and(|len| len > max_body as u64);
|
|
293
|
+
|
|
224
294
|
// Stream the request body through a bounded channel: hyper is polled
|
|
225
295
|
// only as fast as the Ruby side consumes (inbound backpressure), and the
|
|
226
296
|
// forwarder dropping the sender is EOF. Bodyless requests (most GETs)
|
|
227
297
|
// skip the forwarder task entirely: dropping the sender IS the EOF.
|
|
228
298
|
let (body_tx, body_rx) = flume::bounded::<bytes::Bytes>(8);
|
|
229
|
-
|
|
299
|
+
let body_overflow = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
300
|
+
let body_timeout = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
301
|
+
if oversize || hyper::body::Body::is_end_stream(&body) {
|
|
230
302
|
drop(body_tx);
|
|
231
303
|
} else {
|
|
304
|
+
let overflow = body_overflow.clone();
|
|
305
|
+
let timed_out = body_timeout.clone();
|
|
232
306
|
tokio::spawn(async move {
|
|
233
307
|
let mut body = body;
|
|
234
|
-
|
|
235
|
-
|
|
308
|
+
let mut total: u64 = 0;
|
|
309
|
+
loop {
|
|
310
|
+
// Idle deadline between frames: a client that stalls mid-body
|
|
311
|
+
// would otherwise pin a worker blocked in read_body. Only the
|
|
312
|
+
// client's silence trips this; a slow APP blocks the forwarder
|
|
313
|
+
// in send_async below instead, which is not timed.
|
|
314
|
+
let frame = match tokio::time::timeout(BODY_READ_TIMEOUT, body.frame()).await {
|
|
315
|
+
Ok(Some(Ok(frame))) => frame,
|
|
316
|
+
Ok(Some(Err(_))) | Ok(None) => break, // body error or clean EOF
|
|
317
|
+
Err(_) => {
|
|
318
|
+
timed_out.store(true, Ordering::Relaxed);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
236
322
|
if let Ok(data) = frame.into_data() {
|
|
323
|
+
total += data.len() as u64;
|
|
324
|
+
if max_body > 0 && total > max_body as u64 {
|
|
325
|
+
// Past the cap: flag it and stop pulling. Dropping the
|
|
326
|
+
// sender unblocks read_body, which then raises.
|
|
327
|
+
overflow.store(true, Ordering::Relaxed);
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
237
330
|
if body_tx.send_async(data).await.is_err() {
|
|
238
331
|
break; // request handle dropped; stop pulling
|
|
239
332
|
}
|
|
@@ -253,6 +346,8 @@ async fn handle_request(
|
|
|
253
346
|
local_addr,
|
|
254
347
|
https: server.https,
|
|
255
348
|
body_rx,
|
|
349
|
+
body_overflow,
|
|
350
|
+
body_timeout,
|
|
256
351
|
leftover: None,
|
|
257
352
|
slot: None,
|
|
258
353
|
responder,
|
|
@@ -273,6 +368,9 @@ async fn handle_request(
|
|
|
273
368
|
|
|
274
369
|
// Single exit point so the access log sees every outcome, 503s included.
|
|
275
370
|
let response: HyperResponse = 'resp: {
|
|
371
|
+
if oversize {
|
|
372
|
+
break 'resp plain_response(413, "Payload Too Large\n");
|
|
373
|
+
}
|
|
276
374
|
if server.lanes {
|
|
277
375
|
if !dispatch_to_lane(&server, ctx).await {
|
|
278
376
|
break 'resp unavailable(&server);
|
data/lib/kino/configuration.rb
CHANGED
|
@@ -17,9 +17,12 @@ module Kino
|
|
|
17
17
|
queue_depth: 1024,
|
|
18
18
|
queue_timeout: 5.0,
|
|
19
19
|
request_timeout: nil,
|
|
20
|
+
max_connections: nil, # nil = derive from the open-file limit
|
|
21
|
+
max_body_size: 50 * 1024 * 1024, # 50 MB; nil/0 = unlimited
|
|
20
22
|
batch: 1,
|
|
21
23
|
lanes: false,
|
|
22
24
|
log_requests: false,
|
|
25
|
+
on_error: nil,
|
|
23
26
|
shutdown_timeout: 30,
|
|
24
27
|
tokio_threads: nil,
|
|
25
28
|
tls: nil,
|
|
@@ -160,6 +163,14 @@ module Kino
|
|
|
160
163
|
# Seconds the app gets before the client receives a 504; nil = off.
|
|
161
164
|
def request_timeout(seconds) = @config.set(:request_timeout, seconds && Float(seconds))
|
|
162
165
|
|
|
166
|
+
# Max connections served at once; beyond it, new connections wait in
|
|
167
|
+
# the kernel backlog. Defaults to most of the open-file limit.
|
|
168
|
+
def max_connections(count) = @config.set(:max_connections, Integer(count))
|
|
169
|
+
|
|
170
|
+
# Max request-body bytes before a 413; nil disables (delegate to a
|
|
171
|
+
# fronting proxy). Default 50 MB.
|
|
172
|
+
def max_body_size(bytes) = @config.set(:max_body_size, bytes && Integer(bytes))
|
|
173
|
+
|
|
163
174
|
# Requests a worker may grab per queue visit (default 1).
|
|
164
175
|
def batch(count) = @config.set(:batch, Integer(count))
|
|
165
176
|
|
|
@@ -169,6 +180,11 @@ module Kino
|
|
|
169
180
|
# Native access log: one status-colored line per request to stdout.
|
|
170
181
|
def log_requests(enabled) = @config.set(:log_requests, !!enabled)
|
|
171
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
|
+
|
|
172
188
|
# Graceful-shutdown drain deadline in seconds.
|
|
173
189
|
def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
|
|
174
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`
|
|
@@ -50,6 +51,8 @@ module Kino
|
|
|
50
51
|
@queue_depth = Integer(settings[:queue_depth])
|
|
51
52
|
@queue_timeout_ms = (Float(settings[:queue_timeout]) * 1000).round
|
|
52
53
|
@request_timeout_ms = settings[:request_timeout] ? (Float(settings[:request_timeout]) * 1000).round : 0
|
|
54
|
+
@max_connections = settings[:max_connections] ? Integer(settings[:max_connections]) : default_max_connections
|
|
55
|
+
@max_body_size = Integer(settings[:max_body_size] || 0)
|
|
53
56
|
@batch = [Integer(settings[:batch]), 1].max
|
|
54
57
|
@lanes = !!settings[:lanes]
|
|
55
58
|
@log_requests = !!settings[:log_requests]
|
|
@@ -74,17 +77,20 @@ module Kino
|
|
|
74
77
|
bind: @bind, port: @requested_port,
|
|
75
78
|
queue_depth: @queue_depth, queue_timeout_ms: @queue_timeout_ms,
|
|
76
79
|
request_timeout_ms: @request_timeout_ms,
|
|
80
|
+
max_connections: @max_connections,
|
|
81
|
+
max_body_size: @max_body_size,
|
|
77
82
|
tokio_threads: @tokio_threads,
|
|
78
83
|
tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
|
|
79
84
|
lanes: @lanes, log_requests: @log_requests
|
|
80
85
|
)
|
|
81
86
|
File.write(@pidfile, "#{Process.pid}\n") if @pidfile
|
|
82
87
|
if @mode == :ractor
|
|
83
|
-
@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
|
|
84
90
|
else
|
|
85
91
|
@worker_threads = (@workers * @threads).times.map do
|
|
86
92
|
worker_id = Native.register_worker(@id)
|
|
87
|
-
Thread.new { Worker.run(@id, worker_id, @app, @batch) }
|
|
93
|
+
Thread.new { Worker.run(@id, worker_id, @app, @batch, @on_error) }
|
|
88
94
|
end
|
|
89
95
|
end
|
|
90
96
|
@started = true
|
|
@@ -210,10 +216,31 @@ module Kino
|
|
|
210
216
|
{cert: String(tls[:cert]), key: String(tls[:key])}
|
|
211
217
|
end
|
|
212
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
|
+
|
|
213
228
|
def monotonic_now
|
|
214
229
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
215
230
|
end
|
|
216
231
|
|
|
232
|
+
# Default connection cap: most of the process open-file limit. A
|
|
233
|
+
# connection flood's failure mode is descriptor exhaustion, and in
|
|
234
|
+
# :ractor/:threaded mode the app's own sockets and files share this
|
|
235
|
+
# process's table, so leave headroom. Scales with `ulimit -n`; raise the
|
|
236
|
+
# OS limit (or set max_connections) to allow more.
|
|
237
|
+
def default_max_connections
|
|
238
|
+
soft, = Process.getrlimit(Process::RLIMIT_NOFILE)
|
|
239
|
+
return 65_536 if soft == Process::RLIM_INFINITY
|
|
240
|
+
|
|
241
|
+
[soft * 8 / 10, 64].max
|
|
242
|
+
end
|
|
243
|
+
|
|
217
244
|
def join_workers(deadline)
|
|
218
245
|
if @supervisor
|
|
219
246
|
@supervisor.shutdown([deadline - monotonic_now, 0].max)
|
|
@@ -259,13 +286,21 @@ module Kino
|
|
|
259
286
|
"Ractor.shareable_proc endpoints); try Ractor.make_shareable(app) " \
|
|
260
287
|
"or mode: :threaded"
|
|
261
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
|
|
262
294
|
:ractor
|
|
263
295
|
when :auto
|
|
264
|
-
if Ractor.shareable?(@app)
|
|
265
|
-
:ractor
|
|
266
|
-
else
|
|
296
|
+
if !Ractor.shareable?(@app)
|
|
267
297
|
warn "Kino: app is not Ractor-shareable; falling back to mode: :threaded"
|
|
268
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
|
|
269
304
|
end
|
|
270
305
|
else
|
|
271
306
|
raise ArgumentError, "mode must be :auto, :ractor, or :threaded (got #{requested.inspect})"
|
|
@@ -55,6 +55,17 @@
|
|
|
55
55
|
# above your slowest legitimate endpoint.
|
|
56
56
|
# request_timeout 30
|
|
57
57
|
|
|
58
|
+
# Most connections to serve at once. Past this, new connections wait in
|
|
59
|
+
# the kernel backlog instead of piling up until the server runs out of
|
|
60
|
+
# file descriptors. Defaults to most of the open-file limit (ulimit -n),
|
|
61
|
+
# so it scales with the OS limit and only bites under a flood.
|
|
62
|
+
# max_connections 8192
|
|
63
|
+
|
|
64
|
+
# Reject request bodies larger than this many bytes with a 413, so an
|
|
65
|
+
# oversized or endless upload can't drive your app to run out of memory.
|
|
66
|
+
# Set to nil to disable and let a fronting proxy handle it. Default: 50 MB.
|
|
67
|
+
# max_body_size 50 * 1024 * 1024
|
|
68
|
+
|
|
58
69
|
# How many requests a worker grabs from the line at once. Leave at 1
|
|
59
70
|
# unless all your endpoints are uniformly fast.
|
|
60
71
|
# batch 1
|
|
@@ -68,6 +79,14 @@
|
|
|
68
79
|
# never saw, such as 503s. Recommended in development.
|
|
69
80
|
# log_requests false
|
|
70
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
|
+
|
|
71
90
|
## Lifecycle
|
|
72
91
|
|
|
73
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
|
@@ -92,9 +92,12 @@ module Kino
|
|
|
92
92
|
def queue_depth: (int depth) -> untyped
|
|
93
93
|
def queue_timeout: (Numeric seconds) -> untyped
|
|
94
94
|
def request_timeout: (Numeric? seconds) -> untyped
|
|
95
|
+
def max_connections: (int count) -> untyped
|
|
96
|
+
def max_body_size: (int? bytes) -> untyped
|
|
95
97
|
def batch: (int count) -> untyped
|
|
96
98
|
def lanes: (boolish enabled) -> untyped
|
|
97
99
|
def log_requests: (boolish enabled) -> untyped
|
|
100
|
+
def on_error: (?^(Exception, Hash[String, untyped]) -> void handler) ?{ (Exception, Hash[String, untyped]) -> void } -> untyped
|
|
98
101
|
def shutdown_timeout: (Numeric seconds) -> untyped
|
|
99
102
|
def tokio_threads: (int count) -> untyped
|
|
100
103
|
def tls: (cert: String, key: String) -> untyped
|