kino 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@ use magnus::{Error, Ruby};
14
14
  use parking_lot::{Mutex, RwLock};
15
15
 
16
16
  use crate::listen::Listener;
17
- use crate::registry::{self, BoxedCtx, ServerInner, WorkerSlot};
17
+ use crate::registry::{self, BoxedCtx, RuntimeHandle, ServerInner, WorkerSlot};
18
18
  use crate::request::RequestCtx;
19
19
  use crate::response::{plain_response, HyperResponse, Responder};
20
20
 
@@ -56,6 +56,8 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
56
56
  let request_timeout_ms: u64 = cfg_opt::<u64>(ruby, config, "request_timeout_ms")?.unwrap_or(0);
57
57
  let max_body_size: usize = cfg_opt::<usize>(ruby, config, "max_body_size")?.unwrap_or(0);
58
58
  let max_connections: usize = cfg_opt::<usize>(ruby, config, "max_connections")?.unwrap_or(1024);
59
+ let io_shards: bool = cfg_opt(ruby, config, "io_shards")?.unwrap_or(false);
60
+ let io_threads: usize = cfg_opt::<usize>(ruby, config, "io_threads")?.unwrap_or(0);
59
61
  let tokio_threads: usize = cfg_opt::<usize>(ruby, config, "tokio_threads")?.unwrap_or(0);
60
62
  let tls_cert: Option<String> = cfg_opt(ruby, config, "tls_cert")?;
61
63
  let tls_key: Option<String> = cfg_opt(ruby, config, "tls_key")?;
@@ -79,8 +81,7 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
79
81
  }
80
82
  };
81
83
 
82
- let listener =
83
- Listener::bind(&bind, port).map_err(|e| io_error(ruby, "bind failed", e))?;
84
+ let listener = Listener::bind(&bind, port).map_err(|e| io_error(ruby, "bind failed", e))?;
84
85
  // Ruby refuses this combination up front; this guards embedders
85
86
  // calling the native layer directly.
86
87
  if acceptor.is_some() && matches!(listener, Listener::Unix(..)) {
@@ -106,15 +107,6 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
106
107
  })
107
108
  .transpose()?;
108
109
 
109
- let mut builder = tokio::runtime::Builder::new_multi_thread();
110
- builder.enable_all().thread_name("kino-tokio");
111
- if tokio_threads > 0 {
112
- builder.worker_threads(tokio_threads);
113
- }
114
- let runtime = builder
115
- .build()
116
- .map_err(|e| io_error(ruby, "tokio runtime failed", e))?;
117
-
118
110
  let (req_tx, req_rx) = flume::bounded(queue_depth);
119
111
  let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
120
112
 
@@ -123,7 +115,7 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
123
115
  req_tx: Mutex::new(Some(req_tx)),
124
116
  req_rx,
125
117
  shutdown_tx,
126
- runtime: Mutex::new(None),
118
+ runtime: Mutex::new(RuntimeHandle::None),
127
119
  slots: RwLock::new(Vec::new()),
128
120
  in_flight: std::sync::atomic::AtomicUsize::new(0),
129
121
  served: std::sync::atomic::AtomicU64::new(0),
@@ -135,7 +127,12 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
135
127
  state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
136
128
  respawns: std::sync::atomic::AtomicU64::new(0),
137
129
  quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
138
- topology: registry::Topology { mode, workers, threads, batch },
130
+ topology: registry::Topology {
131
+ mode,
132
+ workers,
133
+ threads,
134
+ batch,
135
+ },
139
136
  https: acceptor.is_some(),
140
137
  unix_path,
141
138
  access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
@@ -145,18 +142,48 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
145
142
  queue_histogram: registry::QueueHistogram::new(),
146
143
  });
147
144
 
148
- let tokio_listener = {
149
- let _guard = runtime.enter();
150
- AsyncListener::from_std(listener).map_err(|e| io_error(ruby, "listener setup failed", e))?
151
- };
152
- runtime.spawn(accept_loop(
153
- tokio_listener,
154
- acceptor,
155
- server.clone(),
156
- max_connections,
157
- shutdown_rx,
158
- ));
159
- *server.runtime.lock() = Some(runtime);
145
+ if io_shards {
146
+ // The shards keep serving accepted connections while the acceptor
147
+ // drains on `shutdown_rx`; this second signal stops them only at
148
+ // final teardown.
149
+ let (runtime_shutdown_tx, runtime_shutdown_rx) = tokio::sync::watch::channel(false);
150
+ let threads = crate::io_shards::spawn(
151
+ listener,
152
+ acceptor,
153
+ server.clone(),
154
+ max_connections,
155
+ shutdown_rx,
156
+ runtime_shutdown_rx,
157
+ crate::io_shards::thread_count(io_threads),
158
+ )
159
+ .map_err(|e| io_error(ruby, "sharded runtime failed", e))?;
160
+ *server.runtime.lock() = RuntimeHandle::Shards {
161
+ shutdown_tx: runtime_shutdown_tx,
162
+ threads,
163
+ };
164
+ } else {
165
+ let mut builder = tokio::runtime::Builder::new_multi_thread();
166
+ builder.enable_all().thread_name("kino-tokio");
167
+ if tokio_threads > 0 {
168
+ builder.worker_threads(tokio_threads);
169
+ }
170
+ let runtime = builder
171
+ .build()
172
+ .map_err(|e| io_error(ruby, "tokio runtime failed", e))?;
173
+ let tokio_listener = {
174
+ let _guard = runtime.enter();
175
+ AsyncListener::from_std(listener)
176
+ .map_err(|e| io_error(ruby, "listener setup failed", e))?
177
+ };
178
+ runtime.spawn(accept_loop(
179
+ tokio_listener,
180
+ acceptor,
181
+ server.clone(),
182
+ max_connections,
183
+ shutdown_rx,
184
+ ));
185
+ *server.runtime.lock() = RuntimeHandle::MultiThread(runtime);
186
+ }
160
187
 
161
188
  let id = server.id;
162
189
  let control_port = match control_bind {
@@ -170,10 +197,11 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
170
197
  Some(bind) => match crate::control::start(bind, server.clone(), control_token) {
171
198
  Ok(port) => port,
172
199
  Err(e) => {
173
- // A plain drop blocks until the accept loop's task (its
174
- // only task, idling on accept/shutdown) is torn down; the
175
- // runtime only ever had this one thing to cancel.
176
- drop(server.runtime.lock().take());
200
+ // Nothing is serving yet, so the bound only matters for a
201
+ // wedged shard thread; the default runtime just cancels
202
+ // its one idle accept task.
203
+ let _ = server.shutdown_tx.send(true);
204
+ std::mem::take(&mut *server.runtime.lock()).shutdown(Duration::from_millis(1_000));
177
205
  return Err(io_error(ruby, "control start failed", e));
178
206
  }
179
207
  },
@@ -200,22 +228,24 @@ const UNIX_LOCAL: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST),
200
228
 
201
229
  /// The accept loop's listener: TCP (optionally behind TLS), or a unix
202
230
  /// socket, which carries plain HTTP only.
203
- enum AsyncListener {
231
+ pub(crate) enum AsyncListener {
204
232
  Tcp(tokio::net::TcpListener),
205
233
  Unix(tokio::net::UnixListener),
206
234
  }
207
235
 
208
236
  /// One accepted connection, before the protocol layer sees it.
209
- enum Conn {
237
+ pub(crate) enum Conn {
210
238
  Tcp(tokio::net::TcpStream),
211
239
  Unix(tokio::net::UnixStream),
212
240
  }
213
241
 
214
242
  impl AsyncListener {
215
243
  /// Register the bound listener with the current runtime.
216
- fn from_std(listener: Listener) -> std::io::Result<AsyncListener> {
244
+ pub(crate) fn from_std(listener: Listener) -> std::io::Result<AsyncListener> {
217
245
  Ok(match listener {
218
- Listener::Tcp(listener) => AsyncListener::Tcp(tokio::net::TcpListener::from_std(listener)?),
246
+ Listener::Tcp(listener) => {
247
+ AsyncListener::Tcp(tokio::net::TcpListener::from_std(listener)?)
248
+ }
219
249
  Listener::Unix(listener, _) => {
220
250
  AsyncListener::Unix(tokio::net::UnixListener::from_std(listener)?)
221
251
  }
@@ -223,7 +253,7 @@ impl AsyncListener {
223
253
  }
224
254
 
225
255
  /// The next connection with its (peer, local) addresses.
226
- async fn accept(&self) -> std::io::Result<(Conn, SocketAddr, SocketAddr)> {
256
+ pub(crate) async fn accept(&self) -> std::io::Result<(Conn, SocketAddr, SocketAddr)> {
227
257
  match self {
228
258
  AsyncListener::Tcp(listener) => {
229
259
  let (stream, remote_addr) = listener.accept().await?;
@@ -274,27 +304,38 @@ async fn accept_loop(
274
304
  tokio::spawn(async move {
275
305
  // Held for the connection's lifetime; dropping it frees a slot.
276
306
  let _permit = permit;
277
- match (conn, acceptor) {
278
- (Conn::Tcp(stream), Some(acceptor)) => {
279
- // Handshake failures (port scans, plain HTTP to a TLS
280
- // port) and stalled handshakes (slowloris) just drop the
281
- // connection; the timeout bounds the latter.
282
- let handshake = tokio::time::timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream));
283
- let Ok(Ok(tls)) = handshake.await else { return };
284
- serve_connection(tls, server, remote_addr, local_addr).await;
285
- }
286
- (Conn::Tcp(stream), None) => {
287
- serve_connection(stream, server, remote_addr, local_addr).await
288
- }
289
- // TLS over a unix socket is refused at bind time.
290
- (Conn::Unix(stream), _) => {
291
- serve_connection(stream, server, remote_addr, local_addr).await
292
- }
293
- }
307
+ serve_conn(conn, acceptor, server, remote_addr, local_addr).await;
294
308
  });
295
309
  }
296
310
  }
297
311
 
312
+ /// Everything between an accepted connection and hyper: the optional TLS
313
+ /// handshake, then the protocol layer. Shared by the default accept loop
314
+ /// and the sharded one, so connection policy exists exactly once.
315
+ pub(crate) async fn serve_conn(
316
+ conn: Conn,
317
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
318
+ server: Arc<ServerInner>,
319
+ remote_addr: SocketAddr,
320
+ local_addr: SocketAddr,
321
+ ) {
322
+ match (conn, acceptor) {
323
+ (Conn::Tcp(stream), Some(acceptor)) => {
324
+ // Handshake failures (port scans, plain HTTP to a TLS
325
+ // port) and stalled handshakes (slowloris) just drop the
326
+ // connection; the timeout bounds the latter.
327
+ let handshake = tokio::time::timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream));
328
+ let Ok(Ok(tls)) = handshake.await else { return };
329
+ serve_connection(tls, server, remote_addr, local_addr).await;
330
+ }
331
+ (Conn::Tcp(stream), None) => {
332
+ serve_connection(stream, server, remote_addr, local_addr).await
333
+ }
334
+ // TLS over a unix socket is refused at bind time.
335
+ (Conn::Unix(stream), _) => serve_connection(stream, server, remote_addr, local_addr).await,
336
+ }
337
+ }
338
+
298
339
  /// Slowloris guard: drop a connection that has not sent its complete request
299
340
  /// headers within this window. Long enough never to trip a real client (even
300
341
  /// on a slow mobile link), short enough to reap a stalled one. Deliberately a
@@ -380,7 +421,11 @@ async fn handle_request(
380
421
  None => parts.uri.path().to_string(),
381
422
  };
382
423
  let method = parts.method.to_string();
383
- log.write_line(crate::access_log::arrival(&method, &target, remote_addr.ip()));
424
+ log.write_line(crate::access_log::arrival(
425
+ &method,
426
+ &target,
427
+ remote_addr.ip(),
428
+ ));
384
429
  (std::time::Instant::now(), method, target)
385
430
  });
386
431
 
@@ -627,7 +672,9 @@ pub fn register_worker(ruby: &Ruby, server_id: u64) -> Result<usize, Error> {
627
672
 
628
673
  pub fn stop_accepting(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
629
674
  if let Some(server) = registry::try_get(server_id) {
630
- server.state.store(registry::STATE_DRAINING, Ordering::Relaxed);
675
+ server
676
+ .state
677
+ .store(registry::STATE_DRAINING, Ordering::Relaxed);
631
678
  let _ = server.shutdown_tx.send(true);
632
679
  }
633
680
  Ok(())
@@ -716,9 +763,8 @@ pub fn interrupt_all_workers(_ruby: &Ruby, server_id: u64) -> Result<(), Error>
716
763
 
717
764
  pub fn shutdown_runtime(_ruby: &Ruby, server_id: u64, timeout_ms: u64) -> Result<(), Error> {
718
765
  if let Some(server) = registry::remove(server_id) {
719
- if let Some(runtime) = server.runtime.lock().take() {
720
- runtime.shutdown_timeout(Duration::from_millis(timeout_ms));
721
- }
766
+ let _ = server.shutdown_tx.send(true);
767
+ std::mem::take(&mut *server.runtime.lock()).shutdown(Duration::from_millis(timeout_ms));
722
768
  // The listener is closed with the runtime; its socket file is not.
723
769
  if let Some(path) = &server.unix_path {
724
770
  crate::listen::cleanup_unix(path);
@@ -775,10 +821,7 @@ pub type WorkerStatRow = (usize, u64, usize, u64, bool);
775
821
 
776
822
  /// Per-slot rows for Server#stats parity: [index, served, in_flight,
777
823
  /// busy_ms, quarantined] each. Empty when the server is gone.
778
- pub fn worker_stats(
779
- _ruby: &Ruby,
780
- server_id: u64,
781
- ) -> Result<Vec<WorkerStatRow>, Error> {
824
+ pub fn worker_stats(_ruby: &Ruby, server_id: u64) -> Result<Vec<WorkerStatRow>, Error> {
782
825
  let Some(server) = registry::try_get(server_id) else {
783
826
  return Ok(Vec::new());
784
827
  };
@@ -800,7 +843,9 @@ pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<
800
843
  /// One replacement spawned by the quarantine monitor.
801
844
  pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
802
845
  if let Some(server) = registry::try_get(server_id) {
803
- server.quarantine_replacements.fetch_add(1, Ordering::Relaxed);
846
+ server
847
+ .quarantine_replacements
848
+ .fetch_add(1, Ordering::Relaxed);
804
849
  }
805
850
  Ok(())
806
851
  }
@@ -851,7 +896,10 @@ mod tests {
851
896
  server.register_worker();
852
897
  server.slots.read()[0].lane_tx.lock().take();
853
898
 
854
- assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Closed));
899
+ assert!(matches!(
900
+ try_dispatch(&server, test_ctx()),
901
+ Dispatch::Closed
902
+ ));
855
903
  }
856
904
 
857
905
  #[test]
@@ -894,9 +942,7 @@ mod tests {
894
942
  let server = test_server(true, 4);
895
943
  server.register_worker();
896
944
  server.register_worker();
897
- server.slots.read()[0]
898
- .parked
899
- .store(true, Ordering::Relaxed);
945
+ server.slots.read()[0].parked.store(true, Ordering::Relaxed);
900
946
 
901
947
  // Both dispatches land on the awake lane (slot 1), regardless of
902
948
  // where the rotating cursor starts.
data/ext/kino/src/tls.rs CHANGED
@@ -71,7 +71,8 @@ WJ2lRijROyX9v7f8aSQlb6kEwKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAa
71
71
  #[test]
72
72
  fn missing_file_paths_error_without_panicking() {
73
73
  let err = build_acceptor("/nonexistent/cert.pem", "/nonexistent/key.pem")
74
- .err().expect("missing files");
74
+ .err()
75
+ .expect("missing files");
75
76
  assert!(err.contains("cannot read"));
76
77
  }
77
78
 
@@ -83,14 +84,20 @@ WJ2lRijROyX9v7f8aSQlb6kEwKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAa
83
84
 
84
85
  #[test]
85
86
  fn pem_without_a_key_is_rejected() {
86
- let err = build_acceptor(CERT, CERT).err().expect("a cert is not a key");
87
+ let err = build_acceptor(CERT, CERT)
88
+ .err()
89
+ .expect("a cert is not a key");
87
90
  assert!(err.contains("no private key found"));
88
91
  }
89
92
 
90
93
  #[test]
91
94
  fn mismatched_cert_and_garbage_key_are_rejected() {
92
- let err = build_acceptor(CERT, "-----BEGIN PRIVATE KEY-----\ngarbage\n-----END PRIVATE KEY-----")
93
- .err().expect("garbage key");
95
+ let err = build_acceptor(
96
+ CERT,
97
+ "-----BEGIN PRIVATE KEY-----\ngarbage\n-----END PRIVATE KEY-----",
98
+ )
99
+ .err()
100
+ .expect("garbage key");
94
101
  assert!(!err.is_empty());
95
102
  }
96
103
  }
@@ -26,6 +26,8 @@ module Kino
26
26
  after_request_complete: nil,
27
27
  on_worker_exit: nil,
28
28
  shutdown_timeout: 30,
29
+ io_shards: false,
30
+ io_threads: nil,
29
31
  tokio_threads: nil,
30
32
  tls: nil,
31
33
  environment: nil,
@@ -148,6 +150,8 @@ module Kino
148
150
  # queue_depth 2048
149
151
  # queue_timeout 0.5
150
152
  # shutdown_timeout 15
153
+ # io_shards true
154
+ # io_threads 6
151
155
  # tokio_threads 4
152
156
  # tls cert: "cert.pem", key: "key.pem"
153
157
  #
@@ -229,6 +233,12 @@ module Kino
229
233
  # Graceful-shutdown drain deadline in seconds.
230
234
  def shutdown_timeout(seconds) = @config.set(:shutdown_timeout, seconds)
231
235
 
236
+ # Run native HTTP I/O on current-thread shards instead of Tokio's shared pool.
237
+ def io_shards(enabled = true) = @config.set(:io_shards, !!enabled)
238
+
239
+ # Native HTTP I/O shard count; default with io_shards: half available CPUs.
240
+ def io_threads(count) = @config.set(:io_threads, Integer(count))
241
+
232
242
  # Threads for the tokio (Rust I/O) runtime; default: one per core.
233
243
  def tokio_threads(count) = @config.set(:tokio_threads, Integer(count))
234
244
 
data/lib/kino/server.rb CHANGED
@@ -98,6 +98,12 @@ module Kino
98
98
  @lanes = !!settings[:lanes]
99
99
  @log_requests = !!settings[:log_requests]
100
100
  @shutdown_timeout = settings[:shutdown_timeout]
101
+ @io_shards = !!settings[:io_shards]
102
+ @io_threads = Integer(settings[:io_threads]) unless settings[:io_threads].nil?
103
+ if @io_threads && @io_threads < 1
104
+ raise ArgumentError, "io_threads must be >= 1"
105
+ end
106
+ Log.warn("io_threads has no effect unless io_shards is true") if @io_threads && !@io_shards
101
107
  @tokio_threads = settings[:tokio_threads]
102
108
  @tls = validate_tls(settings[:tls])
103
109
  if @tls && unix?
@@ -145,6 +151,8 @@ module Kino
145
151
  request_timeout_ms: @request_timeout_ms,
146
152
  max_connections: @max_connections,
147
153
  max_body_size: @max_body_size,
154
+ io_shards: @io_shards,
155
+ io_threads: @io_threads,
148
156
  tokio_threads: @tokio_threads,
149
157
  tls_cert: @tls&.fetch(:cert), tls_key: @tls&.fetch(:key),
150
158
  lanes: @lanes, log_requests: @log_requests,
@@ -125,8 +125,14 @@
125
125
 
126
126
  ## Runtime
127
127
 
128
- # Threads for the Rust I/O engine. The default suits most apps; for
129
- # heavily CPU-bound apps, try 1 to leave more cores for Ruby.
128
+ # Run native HTTP I/O on current-thread shards instead of Tokio's shared
129
+ # worker pool, reducing scheduler contention on very fast handlers.
130
+ # io_shards true
131
+
132
+ # I/O shard count. Default with io_shards: half available CPUs.
133
+ # io_threads 6
134
+
135
+ # Threads for the Tokio multi-thread runtime. Default: one per available CPU.
130
136
  # tokio_threads 4
131
137
 
132
138
  ## Control plane
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.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
data/sig/kino.rbs CHANGED
@@ -126,6 +126,8 @@ module Kino
126
126
  def after_request_complete: (?^(Hash[String, untyped], Integer) -> void handler) ?{ (Hash[String, untyped], Integer) -> void } -> untyped
127
127
  def on_worker_exit: (?^(Integer, Exception?) -> void handler) ?{ (Integer, Exception?) -> void } -> untyped
128
128
  def shutdown_timeout: (Numeric seconds) -> untyped
129
+ def io_shards: (?boolish enabled) -> untyped
130
+ def io_threads: (int? count) -> untyped
129
131
  def tokio_threads: (int count) -> untyped
130
132
  def tls: (cert: String, key: String) -> untyped
131
133
  def environment: (String | Symbol env) -> 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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin
@@ -180,6 +180,7 @@ files:
180
180
  - ext/kino/src/cpus.rs
181
181
  - ext/kino/src/env_strings.rs
182
182
  - ext/kino/src/gvl.rs
183
+ - ext/kino/src/io_shards.rs
183
184
  - ext/kino/src/lib.rs
184
185
  - ext/kino/src/listen.rs
185
186
  - ext/kino/src/log.rs