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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/Cargo.lock +115 -24
- data/README.md +113 -41
- data/doc/architecture.md +61 -7
- data/doc/benchmarks.md +120 -26
- data/ext/kino/Cargo.toml +8 -3
- data/ext/kino/src/control.rs +267 -60
- data/ext/kino/src/env_strings.rs +172 -12
- data/ext/kino/src/io_shards.rs +437 -0
- data/ext/kino/src/lib.rs +12 -7
- data/ext/kino/src/listen.rs +4 -1
- data/ext/kino/src/log.rs +10 -2
- data/ext/kino/src/queue.rs +61 -44
- data/ext/kino/src/registry.rs +103 -10
- data/ext/kino/src/request.rs +150 -69
- data/ext/kino/src/response.rs +40 -9
- data/ext/kino/src/server.rs +759 -97
- data/ext/kino/src/tls.rs +129 -12
- data/lib/kino/configuration.rb +16 -0
- data/lib/kino/server.rb +10 -0
- data/lib/kino/templates/kino.rb.tt +13 -2
- data/lib/kino/version.rb +1 -1
- data/lib/kino/worker.rb +14 -11
- data/sig/kino.rbs +3 -0
- metadata +16 -1
data/ext/kino/src/env_strings.rs
CHANGED
|
@@ -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
|
|
51
|
-
///
|
|
52
|
-
///
|
|
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>,
|
|
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(
|
|
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
|
-
|
|
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!(
|
|
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!(
|
|
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
|
+
}
|