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.
@@ -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};
@@ -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) => return Ok(None), // EOF
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: RString, value: Value| {
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::<RString>(i as isize)?);
363
+ take(&mut builder, coerce_str(values.entry(i as isize)?)?);
332
364
  }
333
365
  } else {
334
- take(&mut builder, RString::try_convert(value)?);
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)),
@@ -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
- accepted = listener.accept() => {
142
- let Ok((stream, remote_addr)) = accepted else { continue };
143
- // Small responses must not wait on Nagle + delayed ACK.
144
- let _ = stream.set_nodelay(true);
145
- let local_addr = stream
146
- .local_addr()
147
- .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
148
- let server = server.clone();
149
- let acceptor = acceptor.clone();
150
- tokio::spawn(async move {
151
- match acceptor {
152
- Some(acceptor) => {
153
- // Handshake failures (port scans, plain HTTP to a
154
- // TLS port) just drop the connection.
155
- let Ok(tls) = acceptor.accept(stream).await else { return };
156
- serve_connection(tls, server, remote_addr, local_addr).await;
157
- }
158
- None => serve_connection(stream, server, remote_addr, local_addr).await,
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
- if hyper::body::Body::is_end_stream(&body) {
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
- while let Some(frame) = body.frame().await {
235
- let Ok(frame) = frame else { break };
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);
@@ -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, batch: @batch).start
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
@@ -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.1"
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
@@ -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