kino 0.4.0 → 0.6.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.
@@ -21,6 +21,7 @@ use parking_lot::{Mutex, RwLock};
21
21
  /// These maps are probed several times per request; ahash beats the
22
22
  /// DoS-resistant default since every key here is our own static data.
23
23
  type HashMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
24
+ type HashSet<K> = std::collections::HashSet<K, ahash::RandomState>;
24
25
 
25
26
  pub struct EnvStrings {
26
27
  // keys
@@ -44,17 +45,25 @@ pub struct EnvStrings {
44
45
  pub https: Opaque<RString>,
45
46
  pub http10: Opaque<RString>,
46
47
  pub http11: Opaque<RString>,
48
+ pub http2: Opaque<RString>,
47
49
  pub methods: HashMap<&'static str, Opaque<RString>>,
48
50
  /// lowercase header name -> frozen "HTTP_<UPPER>" key
49
51
  pub header_names: HashMap<&'static str, Opaque<RString>>,
50
- /// Host-header bytes -> frozen (SERVER_NAME, SERVER_PORT) values, and
51
- /// peer IP -> frozen REMOTE_ADDR value. Real traffic has low
52
- /// cardinality on both, so these kill 3 string allocations per request.
52
+ /// Host-header or :authority bytes -> frozen host values, and peer
53
+ /// IP -> frozen REMOTE_ADDR value. Real traffic has low cardinality
54
+ /// on both, so these kill 3 string allocations per request.
53
55
  /// LRU-bounded: entries are BoxValue-rooted (registered with the GC on
54
56
  /// insert, UNregistered on eviction-drop), so a rotating-host attack
55
57
  /// recycles cache slots instead of leaking immortal strings.
56
- pub hosts: Mutex<lru::LruCache<Vec<u8>, (CachedStr, CachedStr), ahash::RandomState>>,
58
+ pub hosts: Mutex<lru::LruCache<Vec<u8>, HostEntry, ahash::RandomState>>,
57
59
  pub addrs: Mutex<lru::LruCache<IpAddr, CachedStr, ahash::RandomState>>,
60
+ /// Interned values of low-cardinality headers (see
61
+ /// [`INTERNABLE_VALUES`]): value bytes -> frozen RString, shared
62
+ /// across headers that happen to carry the same bytes. Same rooting
63
+ /// and locking contract as `hosts`/`addrs`.
64
+ pub values: Mutex<lru::LruCache<Vec<u8>, CachedStr, ahash::RandomState>>,
65
+ /// The names whose values go through the `values` cache.
66
+ pub internable: HashSet<&'static str>,
58
67
  /// Ractor-shareable defaults provided by the Ruby layer at boot:
59
68
  /// the frozen rack.errors writer and the frozen null rack.input.
60
69
  pub errors_stream: RwLock<Option<Opaque<Value>>>,
@@ -63,6 +72,53 @@ pub struct EnvStrings {
63
72
 
64
73
  const HOST_CACHE_CAP: usize = 256;
65
74
  const ADDR_CACHE_CAP: usize = 1024;
75
+ const VALUE_CACHE_CAP: usize = 512;
76
+
77
+ /// Values longer than this are never interned: past it the memcpy into
78
+ /// a fresh Ruby string is cheap relative to the bytes themselves, and
79
+ /// unbounded keys would let one client fill the cache with garbage.
80
+ const VALUE_INTERN_MAX_LEN: usize = 512;
81
+
82
+ /// Headers whose values are effectively enums or per-install constants
83
+ /// (a browser resends the same UA, accept-*, and sec-ch-* on every
84
+ /// request), so caching kills an allocation + copy per header per
85
+ /// request: the env-side analogue of what HPACK does on the wire.
86
+ /// Deliberately absent: `cookie` and `authorization` (per-user
87
+ /// cardinality would churn the cache, and secrets should not outlive
88
+ /// their request in an evict-to-free cache), `referer`/`x-request-id`
89
+ /// and friends (unbounded cardinality).
90
+ const INTERNABLE_VALUES: &[&str] = &[
91
+ "user-agent",
92
+ "accept",
93
+ "accept-encoding",
94
+ "accept-language",
95
+ "cache-control",
96
+ "dnt",
97
+ "origin",
98
+ "pragma",
99
+ "priority",
100
+ "sec-ch-ua",
101
+ "sec-ch-ua-mobile",
102
+ "sec-ch-ua-platform",
103
+ "sec-fetch-dest",
104
+ "sec-fetch-mode",
105
+ "sec-fetch-site",
106
+ "sec-fetch-user",
107
+ "upgrade-insecure-requests",
108
+ "x-requested-with",
109
+ ];
110
+
111
+ /// One hosts-cache entry: the frozen SERVER_NAME/SERVER_PORT pair, plus
112
+ /// the frozen full authority ("host[:port]" as sent) used as the
113
+ /// HTTP_HOST value for requests that carry the name in the URI (the h2
114
+ /// :authority pseudo-header) rather than a Host header. Lazily filled:
115
+ /// Host-header entries and the NUL-prefixed socket-fallback entries
116
+ /// never allocate it.
117
+ pub struct HostEntry {
118
+ name: CachedStr,
119
+ port: CachedStr,
120
+ host: Option<CachedStr>,
121
+ }
66
122
 
67
123
  /// A frozen RString rooted via BoxValue (GC-registered address; unregisters
68
124
  /// on Drop, so LRU eviction actually frees the string).
@@ -84,6 +140,16 @@ impl CachedStr {
84
140
  CachedStr(magnus::value::BoxValue::new(string))
85
141
  }
86
142
 
143
+ /// Header values are bytes on the wire (not guaranteed UTF-8), so
144
+ /// they cache as the same binary strings `str_from_slice` builds on
145
+ /// the uncached path; interning must not change the encoding an
146
+ /// app observes.
147
+ fn new_from_slice(ruby: &Ruby, bytes: &[u8]) -> Self {
148
+ let string = ruby.str_from_slice(bytes);
149
+ string.freeze();
150
+ CachedStr(magnus::value::BoxValue::new(string))
151
+ }
152
+
87
153
  fn get(&self) -> RString {
88
154
  *self.0
89
155
  }
@@ -142,6 +208,8 @@ const COMMON_HEADERS: &[&str] = &[
142
208
  "sec-ch-ua-mobile",
143
209
  "sec-ch-ua-platform",
144
210
  "keep-alive",
211
+ "priority",
212
+ "alt-used",
145
213
  ];
146
214
 
147
215
  pub fn cgi_name(lower: &str) -> String {
@@ -194,6 +262,7 @@ pub fn init(ruby: &Ruby) {
194
262
  https: frozen(ruby, "https"),
195
263
  http10: frozen(ruby, "HTTP/1.0"),
196
264
  http11: frozen(ruby, "HTTP/1.1"),
265
+ http2: frozen(ruby, "HTTP/2"),
197
266
  methods,
198
267
  header_names,
199
268
  hosts: Mutex::new(lru::LruCache::with_hasher(
@@ -204,6 +273,11 @@ pub fn init(ruby: &Ruby) {
204
273
  std::num::NonZeroUsize::new(ADDR_CACHE_CAP).unwrap(),
205
274
  ahash::RandomState::new(),
206
275
  )),
276
+ values: Mutex::new(lru::LruCache::with_hasher(
277
+ std::num::NonZeroUsize::new(VALUE_CACHE_CAP).unwrap(),
278
+ ahash::RandomState::new(),
279
+ )),
280
+ internable: INTERNABLE_VALUES.iter().copied().collect(),
207
281
  errors_stream: RwLock::new(None),
208
282
  null_input: RwLock::new(None),
209
283
  };
@@ -249,20 +323,98 @@ pub fn set_host_env(
249
323
  let s = get();
250
324
  let mut hosts = s.hosts.lock();
251
325
  let (name, port) = match hosts.get(host) {
252
- Some((name, port)) => (name.get(), port.get()),
326
+ Some(entry) => (entry.name.get(), entry.port.get()),
253
327
  None => {
254
328
  let (name_s, port_n) = make();
255
- let entry = (
256
- CachedStr::new(ruby, &name_s),
257
- CachedStr::new(ruby, &port_n.to_string()),
329
+ let entry = HostEntry {
330
+ name: CachedStr::new(ruby, &name_s),
331
+ port: CachedStr::new(ruby, &port_n.to_string()),
332
+ host: None,
333
+ };
334
+ let values = (entry.name.get(), entry.port.get());
335
+ hosts.put(host.to_vec(), entry); // may evict + free an old entry
336
+ values
337
+ }
338
+ };
339
+ env.aset(ruby.get_inner(s.server_name), name)?;
340
+ env.aset(ruby.get_inner(s.server_port), port)?;
341
+ Ok(())
342
+ }
343
+
344
+ /// Set SERVER_NAME/SERVER_PORT *and* HTTP_HOST on `env` from the URI
345
+ /// authority (every h2 request via :authority; also h1 absolute-form).
346
+ /// HTTP_HOST is set here because such requests carry no Host header for
347
+ /// the header loop to surface. Same cache and locking contract as
348
+ /// [`set_host_env`]; an entry first created by a Host header upgrades in
349
+ /// place, gaining the full-authority string on first use.
350
+ pub fn set_authority_env(
351
+ ruby: &Ruby,
352
+ env: magnus::RHash,
353
+ authority: &str,
354
+ make: impl FnOnce() -> (String, u16),
355
+ ) -> Result<(), magnus::Error> {
356
+ let s = get();
357
+ let mut hosts = s.hosts.lock();
358
+ let (name, port, host) = match hosts.get_mut(authority.as_bytes()) {
359
+ Some(entry) => {
360
+ if entry.host.is_none() {
361
+ entry.host = Some(CachedStr::new(ruby, authority));
362
+ }
363
+ (
364
+ entry.name.get(),
365
+ entry.port.get(),
366
+ entry.host.as_ref().expect("just filled").get(),
367
+ )
368
+ }
369
+ None => {
370
+ let (name_s, port_n) = make();
371
+ let entry = HostEntry {
372
+ name: CachedStr::new(ruby, &name_s),
373
+ port: CachedStr::new(ruby, &port_n.to_string()),
374
+ host: Some(CachedStr::new(ruby, authority)),
375
+ };
376
+ let values = (
377
+ entry.name.get(),
378
+ entry.port.get(),
379
+ entry.host.as_ref().expect("just built").get(),
258
380
  );
259
- let values = (entry.0.get(), entry.1.get());
260
- hosts.put(host.to_vec(), entry); // may evict + free an old pair
381
+ hosts.put(authority.as_bytes().to_vec(), entry);
261
382
  values
262
383
  }
263
384
  };
264
385
  env.aset(ruby.get_inner(s.server_name), name)?;
265
386
  env.aset(ruby.get_inner(s.server_port), port)?;
387
+ let host_key = *s.header_names.get("host").expect("host is a common header");
388
+ env.aset(ruby.get_inner(host_key), host)?;
389
+ Ok(())
390
+ }
391
+
392
+ /// Set one header's value on `env` under `key`: through the interned
393
+ /// value cache when the header qualifies (low-cardinality name, bounded
394
+ /// length), else a fresh per-request string. The cached aset happens
395
+ /// under the cache lock; see CachedStr's safety contract.
396
+ pub fn set_value_env(
397
+ ruby: &Ruby,
398
+ env: magnus::RHash,
399
+ key: RString,
400
+ name: &str,
401
+ value: &[u8],
402
+ ) -> Result<(), magnus::Error> {
403
+ let s = get();
404
+ if value.len() > VALUE_INTERN_MAX_LEN || !s.internable.contains(name) {
405
+ return env.aset(key, ruby.str_from_slice(value));
406
+ }
407
+ let mut values = s.values.lock();
408
+ let cached = match values.get(value) {
409
+ Some(cached) => cached.get(),
410
+ None => {
411
+ let entry = CachedStr::new_from_slice(ruby, value);
412
+ let string = entry.get();
413
+ values.put(value.to_vec(), entry); // may evict + free an old value
414
+ string
415
+ }
416
+ };
417
+ env.aset(key, cached)?;
266
418
  Ok(())
267
419
  }
268
420
 
@@ -302,7 +454,11 @@ mod tests {
302
454
  fn common_headers_are_lowercase_and_unique() {
303
455
  let mut seen = std::collections::HashSet::new();
304
456
  for header in COMMON_HEADERS {
305
- assert_eq!(*header, header.to_ascii_lowercase(), "{header} must be lowercase");
457
+ assert_eq!(
458
+ *header,
459
+ header.to_ascii_lowercase(),
460
+ "{header} must be lowercase"
461
+ );
306
462
  assert!(seen.insert(*header), "{header} listed twice");
307
463
  }
308
464
  }
@@ -311,7 +467,11 @@ mod tests {
311
467
  fn common_methods_are_uppercase_and_unique() {
312
468
  let mut seen = std::collections::HashSet::new();
313
469
  for method in COMMON_METHODS {
314
- assert_eq!(*method, method.to_ascii_uppercase(), "{method} must be uppercase");
470
+ assert_eq!(
471
+ *method,
472
+ method.to_ascii_uppercase(),
473
+ "{method} must be uppercase"
474
+ );
315
475
  assert!(seen.insert(*method), "{method} listed twice");
316
476
  }
317
477
  }
@@ -0,0 +1,437 @@
1
+ //! Current-thread Tokio runtimes for HTTP I/O.
2
+ //!
3
+ //! One accept thread owns the listener and assigns accepted connections to
4
+ //! the least-loaded shard. Each shard then owns that connection for its
5
+ //! lifetime, avoiding the shared Tokio worker pool on hot HTTP paths.
6
+
7
+ use std::net::SocketAddr;
8
+ use std::sync::atomic::{AtomicUsize, Ordering};
9
+ use std::sync::Arc;
10
+ use std::thread::JoinHandle;
11
+
12
+ use crate::listen::Listener;
13
+ use crate::log::{self, Level};
14
+ use crate::registry::{ServerInner, STATE_DRAINING};
15
+ use crate::server::{serve_conn, AsyncListener, Conn};
16
+
17
+ /// A connection in transit from the acceptor to its shard. Tokio streams
18
+ /// are bound to the runtime that registered them, so the handoff carries
19
+ /// the std stream and the shard re-registers it on arrival.
20
+ enum StdConn {
21
+ Tcp(std::net::TcpStream),
22
+ Unix(std::os::unix::net::UnixStream),
23
+ }
24
+
25
+ impl StdConn {
26
+ /// Register with the calling (shard) runtime.
27
+ fn into_tokio(self) -> std::io::Result<Conn> {
28
+ Ok(match self {
29
+ StdConn::Tcp(stream) => Conn::Tcp(tokio::net::TcpStream::from_std(stream)?),
30
+ StdConn::Unix(stream) => Conn::Unix(tokio::net::UnixStream::from_std(stream)?),
31
+ })
32
+ }
33
+ }
34
+
35
+ /// Detach an accepted stream from the acceptor's runtime for the handoff.
36
+ fn into_std(conn: Conn) -> std::io::Result<StdConn> {
37
+ Ok(match conn {
38
+ Conn::Tcp(stream) => StdConn::Tcp(stream.into_std()?),
39
+ Conn::Unix(stream) => StdConn::Unix(stream.into_std()?),
40
+ })
41
+ }
42
+
43
+ /// One accepted connection en route to a shard: the detached stream, the
44
+ /// addresses hyper reports, and the slot it holds against max_connections.
45
+ struct Accepted {
46
+ conn: StdConn,
47
+ remote_addr: SocketAddr,
48
+ local_addr: SocketAddr,
49
+ permit: tokio::sync::OwnedSemaphorePermit,
50
+ }
51
+
52
+ /// Shard count: an explicit `io_threads` wins; the default is half the
53
+ /// available CPUs. Framing requests is cheap next to running the app, so
54
+ /// the I/O plane gets the smaller share and Ruby workers keep the rest.
55
+ pub(crate) fn thread_count(io_threads: usize) -> usize {
56
+ if io_threads > 0 {
57
+ return io_threads;
58
+ }
59
+ default_thread_count(std::thread::available_parallelism().map_or(1, usize::from))
60
+ }
61
+
62
+ fn default_thread_count(cpus: usize) -> usize {
63
+ cpus.div_ceil(2)
64
+ }
65
+
66
+ /// Boot the shard threads, then the acceptor. Any thread that fails to
67
+ /// come up fails the whole boot: the already started threads are drained
68
+ /// (their senders drop) and joined before the error reaches Ruby.
69
+ pub(crate) fn spawn(
70
+ listener: Listener,
71
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
72
+ server: Arc<ServerInner>,
73
+ max_connections: usize,
74
+ accept_shutdown_rx: tokio::sync::watch::Receiver<bool>,
75
+ runtime_shutdown_rx: tokio::sync::watch::Receiver<bool>,
76
+ shard_count: usize,
77
+ ) -> std::io::Result<Vec<JoinHandle<()>>> {
78
+ let shard_count = shard_count.max(1);
79
+ let mut handles = Vec::with_capacity(shard_count + 1);
80
+ let mut shard_txs = Vec::with_capacity(shard_count);
81
+ let mut loads = Vec::with_capacity(shard_count);
82
+
83
+ for i in 0..shard_count {
84
+ let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
85
+ let load = Arc::new(AtomicUsize::new(0));
86
+ let spawned = spawn_shard(
87
+ i,
88
+ rx,
89
+ acceptor.clone(),
90
+ server.clone(),
91
+ load.clone(),
92
+ runtime_shutdown_rx.clone(),
93
+ );
94
+ match spawned {
95
+ Ok(handle) => {
96
+ shard_txs.push(tx);
97
+ loads.push(load);
98
+ handles.push(handle);
99
+ }
100
+ Err(error) => {
101
+ drop(tx);
102
+ drop(shard_txs);
103
+ join_all(handles);
104
+ return Err(error);
105
+ }
106
+ }
107
+ }
108
+
109
+ match spawn_acceptor(
110
+ listener,
111
+ server,
112
+ max_connections,
113
+ accept_shutdown_rx,
114
+ shard_txs,
115
+ loads,
116
+ ) {
117
+ Ok(handle) => handles.push(handle),
118
+ Err(error) => {
119
+ join_all(handles);
120
+ return Err(error);
121
+ }
122
+ }
123
+ Ok(handles)
124
+ }
125
+
126
+ fn join_all(handles: Vec<JoinHandle<()>>) {
127
+ for handle in handles {
128
+ let _ = handle.join();
129
+ }
130
+ }
131
+
132
+ fn current_thread_runtime() -> std::io::Result<tokio::runtime::Runtime> {
133
+ tokio::runtime::Builder::new_current_thread()
134
+ .enable_all()
135
+ .build()
136
+ }
137
+
138
+ /// Wait for a just spawned I/O thread to report its runtime up, so a
139
+ /// startup failure becomes the boot error Ruby sees instead of a silently
140
+ /// dead thread.
141
+ fn await_ready(
142
+ handle: JoinHandle<()>,
143
+ ready_rx: std::sync::mpsc::Receiver<std::io::Result<()>>,
144
+ what: &str,
145
+ ) -> std::io::Result<JoinHandle<()>> {
146
+ match ready_rx.recv() {
147
+ Ok(Ok(())) => Ok(handle),
148
+ Ok(Err(error)) => {
149
+ let _ = handle.join();
150
+ Err(error)
151
+ }
152
+ Err(_) => Err(std::io::Error::other(format!(
153
+ "{what} thread exited during startup"
154
+ ))),
155
+ }
156
+ }
157
+
158
+ fn spawn_shard(
159
+ index: usize,
160
+ rx: tokio::sync::mpsc::UnboundedReceiver<Accepted>,
161
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
162
+ server: Arc<ServerInner>,
163
+ load: Arc<AtomicUsize>,
164
+ mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
165
+ ) -> std::io::Result<JoinHandle<()>> {
166
+ let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
167
+ let handle = std::thread::Builder::new()
168
+ .name(format!("kino-io-{index}"))
169
+ .spawn(move || {
170
+ let runtime = match current_thread_runtime() {
171
+ Ok(runtime) => runtime,
172
+ Err(error) => {
173
+ let _ = ready_tx.send(Err(error));
174
+ return;
175
+ }
176
+ };
177
+ let _ = ready_tx.send(Ok(()));
178
+ runtime.block_on(shard_loop(rx, acceptor, server, load, &mut shutdown_rx));
179
+ })?;
180
+ await_ready(handle, ready_rx, "shard")
181
+ }
182
+
183
+ fn spawn_acceptor(
184
+ listener: Listener,
185
+ server: Arc<ServerInner>,
186
+ max_connections: usize,
187
+ shutdown_rx: tokio::sync::watch::Receiver<bool>,
188
+ shard_txs: Vec<tokio::sync::mpsc::UnboundedSender<Accepted>>,
189
+ loads: Vec<Arc<AtomicUsize>>,
190
+ ) -> std::io::Result<JoinHandle<()>> {
191
+ let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
192
+ let handle = std::thread::Builder::new()
193
+ .name("kino-accept".to_string())
194
+ .spawn(move || {
195
+ let runtime = match current_thread_runtime() {
196
+ Ok(runtime) => runtime,
197
+ Err(error) => {
198
+ let _ = ready_tx.send(Err(error));
199
+ return;
200
+ }
201
+ };
202
+ runtime.block_on(async move {
203
+ // Registration must happen on this runtime; a failure is
204
+ // routed through the same ready channel as a build error.
205
+ let listener = match AsyncListener::from_std(listener) {
206
+ Ok(listener) => listener,
207
+ Err(error) => {
208
+ let _ = ready_tx.send(Err(error));
209
+ return;
210
+ }
211
+ };
212
+ accept_loop(
213
+ listener,
214
+ server,
215
+ max_connections,
216
+ shutdown_rx,
217
+ shard_txs,
218
+ loads,
219
+ ready_tx,
220
+ )
221
+ .await;
222
+ });
223
+ })?;
224
+ await_ready(handle, ready_rx, "accept")
225
+ }
226
+
227
+ /// Serve handed-over connections until the acceptor is gone, then let the
228
+ /// remaining ones finish. The final teardown signal cuts either phase
229
+ /// short: dropping the runtime cancels connection tasks at their next
230
+ /// await point.
231
+ async fn shard_loop(
232
+ mut rx: tokio::sync::mpsc::UnboundedReceiver<Accepted>,
233
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
234
+ server: Arc<ServerInner>,
235
+ load: Arc<AtomicUsize>,
236
+ shutdown_rx: &mut tokio::sync::watch::Receiver<bool>,
237
+ ) {
238
+ let mut connections = tokio::task::JoinSet::new();
239
+ loop {
240
+ tokio::select! {
241
+ _ = shutdown_rx.changed() => return,
242
+ accepted = rx.recv() => {
243
+ let Some(accepted) = accepted else { break };
244
+ let acceptor = acceptor.clone();
245
+ let server = server.clone();
246
+ let guard = LoadGuard(load.clone());
247
+ connections.spawn(async move {
248
+ let _guard = guard;
249
+ serve_accepted(accepted, acceptor, server).await;
250
+ });
251
+ }
252
+ // Reap closed connections so the set stays small.
253
+ Some(_) = connections.join_next(), if !connections.is_empty() => {}
254
+ }
255
+ }
256
+ // The acceptor is gone: drain. Every join is one connection closing.
257
+ while !connections.is_empty() {
258
+ tokio::select! {
259
+ _ = shutdown_rx.changed() => return,
260
+ _ = connections.join_next() => {}
261
+ }
262
+ }
263
+ }
264
+
265
+ /// Keeps the shard's connection count honest whichever way the task ends:
266
+ /// return, panic, or cancellation at teardown. A plain decrement after the
267
+ /// await would never run on the last two.
268
+ struct LoadGuard(Arc<AtomicUsize>);
269
+
270
+ impl Drop for LoadGuard {
271
+ fn drop(&mut self) {
272
+ self.0.fetch_sub(1, Ordering::Relaxed);
273
+ }
274
+ }
275
+
276
+ /// The shard's half of the handoff: re-register the stream on this
277
+ /// runtime, then run the shared connection pipeline (TLS handshake,
278
+ /// protocol layer) exactly as the default runtime would.
279
+ async fn serve_accepted(
280
+ accepted: Accepted,
281
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
282
+ server: Arc<ServerInner>,
283
+ ) {
284
+ // Held for the connection's lifetime; dropping it frees a slot.
285
+ let _permit = accepted.permit;
286
+ let conn = match accepted.conn.into_tokio() {
287
+ Ok(conn) => conn,
288
+ Err(_) => {
289
+ log::emit(
290
+ Level::Warn,
291
+ "tokio",
292
+ "failed to register a stream on an I/O shard",
293
+ );
294
+ return;
295
+ }
296
+ };
297
+ serve_conn(
298
+ conn,
299
+ acceptor,
300
+ server,
301
+ accepted.remote_addr,
302
+ accepted.local_addr,
303
+ )
304
+ .await;
305
+ }
306
+
307
+ /// The sharded accept loop. Same backpressure as the default loop: the
308
+ /// permit is acquired before accept, so past max_connections the excess
309
+ /// waits in the kernel backlog instead of being accepted and dropped.
310
+ /// A shard whose channel is gone is marked dead and routed around; with
311
+ /// no shard left the loop stops accepting and flips the server to
312
+ /// draining, so the control plane stops reporting ready.
313
+ async fn accept_loop(
314
+ listener: AsyncListener,
315
+ server: Arc<ServerInner>,
316
+ max_connections: usize,
317
+ mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
318
+ shard_txs: Vec<tokio::sync::mpsc::UnboundedSender<Accepted>>,
319
+ loads: Vec<Arc<AtomicUsize>>,
320
+ ready_tx: std::sync::mpsc::SyncSender<std::io::Result<()>>,
321
+ ) {
322
+ let conn_limit = Arc::new(tokio::sync::Semaphore::new(max_connections));
323
+ let mut live = vec![true; shard_txs.len()];
324
+ let _ = ready_tx.send(Ok(()));
325
+ 'accept: loop {
326
+ let permit = tokio::select! {
327
+ _ = shutdown_rx.changed() => break,
328
+ permit = conn_limit.clone().acquire_owned() => match permit {
329
+ Ok(permit) => permit,
330
+ Err(_) => break,
331
+ },
332
+ };
333
+ let (conn, remote_addr, local_addr) = tokio::select! {
334
+ _ = shutdown_rx.changed() => break,
335
+ accepted = listener.accept() => match accepted {
336
+ Ok(accepted) => accepted,
337
+ Err(_) => continue,
338
+ },
339
+ };
340
+ let conn = match into_std(conn) {
341
+ Ok(conn) => conn,
342
+ Err(_) => {
343
+ log::emit(
344
+ Level::Warn,
345
+ "tokio",
346
+ "failed to detach an accepted stream; connection dropped",
347
+ );
348
+ continue;
349
+ }
350
+ };
351
+ let mut accepted = Accepted {
352
+ conn,
353
+ remote_addr,
354
+ local_addr,
355
+ permit,
356
+ };
357
+ loop {
358
+ let Some(index) = least_loaded(&loads, &live) else {
359
+ log::emit(
360
+ Level::Error,
361
+ "tokio",
362
+ "all I/O shards are down; not accepting connections",
363
+ );
364
+ server.state.store(STATE_DRAINING, Ordering::Relaxed);
365
+ break 'accept;
366
+ };
367
+ loads[index].fetch_add(1, Ordering::Relaxed);
368
+ match shard_txs[index].send(accepted) {
369
+ Ok(()) => break,
370
+ Err(returned) => {
371
+ loads[index].fetch_sub(1, Ordering::Relaxed);
372
+ live[index] = false;
373
+ log::emit(Level::Warn, "tokio", "I/O shard is down; routing around it");
374
+ accepted = returned.0;
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ /// The live shard with the fewest open connections.
382
+ fn least_loaded(loads: &[Arc<AtomicUsize>], live: &[bool]) -> Option<usize> {
383
+ loads
384
+ .iter()
385
+ .enumerate()
386
+ .filter(|(index, _)| live[*index])
387
+ .min_by_key(|(_, load)| load.load(Ordering::Relaxed))
388
+ .map(|(index, _)| index)
389
+ }
390
+
391
+ #[cfg(test)]
392
+ mod tests {
393
+ use super::{default_thread_count, least_loaded, thread_count, Arc, AtomicUsize};
394
+
395
+ fn loads(counts: &[usize]) -> Vec<Arc<AtomicUsize>> {
396
+ counts
397
+ .iter()
398
+ .map(|&n| Arc::new(AtomicUsize::new(n)))
399
+ .collect()
400
+ }
401
+
402
+ #[test]
403
+ fn explicit_io_threads_win() {
404
+ assert_eq!(thread_count(3), 3);
405
+ }
406
+
407
+ #[test]
408
+ fn least_loaded_picks_the_emptiest_live_shard() {
409
+ assert_eq!(
410
+ least_loaded(&loads(&[3, 0, 1]), &[true, true, true]),
411
+ Some(1)
412
+ );
413
+ }
414
+
415
+ #[test]
416
+ fn least_loaded_routes_around_dead_shards() {
417
+ // The dead shard's count is frozen at 0; it must not win anyway.
418
+ assert_eq!(
419
+ least_loaded(&loads(&[3, 0, 1]), &[true, false, true]),
420
+ Some(2)
421
+ );
422
+ }
423
+
424
+ #[test]
425
+ fn least_loaded_reports_when_no_shard_is_left() {
426
+ assert_eq!(least_loaded(&loads(&[0, 0]), &[false, false]), None);
427
+ }
428
+
429
+ #[test]
430
+ fn default_is_half_the_cpus() {
431
+ assert_eq!(default_thread_count(1), 1);
432
+ assert_eq!(default_thread_count(2), 1);
433
+ assert_eq!(default_thread_count(3), 2);
434
+ assert_eq!(default_thread_count(12), 6);
435
+ assert_eq!(default_thread_count(128), 64);
436
+ }
437
+ }