kino 0.1.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 +7 -0
- data/.yardopts +14 -0
- data/CHANGELOG.md +54 -0
- data/Cargo.lock +993 -0
- data/Cargo.toml +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/doc/README.md +6 -0
- data/doc/architecture.md +161 -0
- data/doc/benchmarks.md +321 -0
- data/doc/rails-on-ractors.md +50 -0
- data/doc/why-kino.md +91 -0
- data/exe/kino +26 -0
- data/ext/kino/Cargo.toml +49 -0
- data/ext/kino/build.rs +5 -0
- data/ext/kino/extconf.rb +6 -0
- data/ext/kino/src/env_strings.rs +318 -0
- data/ext/kino/src/gvl.rs +103 -0
- data/ext/kino/src/lib.rs +90 -0
- data/ext/kino/src/logsink.rs +155 -0
- data/ext/kino/src/queue.rs +207 -0
- data/ext/kino/src/registry.rs +268 -0
- data/ext/kino/src/request.rs +432 -0
- data/ext/kino/src/response.rs +214 -0
- data/ext/kino/src/server.rs +621 -0
- data/ext/kino/src/style.rs +87 -0
- data/ext/kino/src/test_support.rs +82 -0
- data/ext/kino/src/timer.rs +57 -0
- data/ext/kino/src/tls.rs +96 -0
- data/lib/kino/check.rb +199 -0
- data/lib/kino/cli.rb +254 -0
- data/lib/kino/configuration.rb +190 -0
- data/lib/kino/errors_stream.rb +25 -0
- data/lib/kino/input.rb +77 -0
- data/lib/kino/logger.rb +56 -0
- data/lib/kino/null_input.rb +37 -0
- data/lib/kino/ractor_supervisor.rb +103 -0
- data/lib/kino/server.rb +271 -0
- data/lib/kino/stream.rb +61 -0
- data/lib/kino/templates/kino.rb.tt +141 -0
- data/lib/kino/version.rb +6 -0
- data/lib/kino/worker.rb +124 -0
- data/lib/kino.rb +53 -0
- data/sig/kino.rbs +178 -0
- metadata +219 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
//! tokio + hyper front-end: owns the listener, the runtime, and request
|
|
2
|
+
//! intake. Ruby is never on these threads; the only contact points are the
|
|
3
|
+
//! flume queue (in) and each request's Responder (out).
|
|
4
|
+
|
|
5
|
+
use std::net::SocketAddr;
|
|
6
|
+
use std::sync::atomic::Ordering;
|
|
7
|
+
use std::sync::Arc;
|
|
8
|
+
use std::time::Duration;
|
|
9
|
+
|
|
10
|
+
use http_body_util::BodyExt;
|
|
11
|
+
use hyper::service::service_fn;
|
|
12
|
+
use hyper_util::rt::TokioIo;
|
|
13
|
+
use magnus::{Error, Ruby};
|
|
14
|
+
use parking_lot::{Mutex, RwLock};
|
|
15
|
+
|
|
16
|
+
use crate::registry::{self, BoxedCtx, ServerInner, WorkerSlot};
|
|
17
|
+
use crate::request::RequestCtx;
|
|
18
|
+
use crate::response::{plain_response, HyperResponse, Responder};
|
|
19
|
+
|
|
20
|
+
fn io_error(ruby: &Ruby, what: &str, e: std::io::Error) -> Error {
|
|
21
|
+
Error::new(ruby.exception_runtime_error(), format!("{what}: {e}"))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Required key from the boot-config Hash.
|
|
25
|
+
fn cfg<T: magnus::TryConvert>(ruby: &Ruby, config: magnus::RHash, key: &str) -> Result<T, Error> {
|
|
26
|
+
cfg_opt::<T>(ruby, config, key)?.ok_or_else(|| {
|
|
27
|
+
Error::new(
|
|
28
|
+
ruby.exception_arg_error(),
|
|
29
|
+
format!("server_start: missing config key :{key}"),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Optional key from the boot-config Hash (absent or nil → None).
|
|
35
|
+
fn cfg_opt<T: magnus::TryConvert>(
|
|
36
|
+
ruby: &Ruby,
|
|
37
|
+
config: magnus::RHash,
|
|
38
|
+
key: &str,
|
|
39
|
+
) -> Result<Option<T>, Error> {
|
|
40
|
+
config.lookup(ruby.to_symbol(key))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Bind + spawn the accept loop. Takes one config Hash: this runs once
|
|
44
|
+
/// at boot, so Hash-lookup cost is irrelevant and the interface stays
|
|
45
|
+
/// extensible. Binding is synchronous so address errors raise in Ruby at
|
|
46
|
+
/// `start` time; returns the actual port for `port: 0`. TLS config errors
|
|
47
|
+
/// (bad cert/key) also raise here, before any traffic.
|
|
48
|
+
pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16), Error> {
|
|
49
|
+
let bind: String = cfg(ruby, config, "bind")?;
|
|
50
|
+
let port: u16 = cfg(ruby, config, "port")?;
|
|
51
|
+
let queue_depth: usize = cfg(ruby, config, "queue_depth")?;
|
|
52
|
+
let queue_timeout_ms: u64 = cfg(ruby, config, "queue_timeout_ms")?;
|
|
53
|
+
let request_timeout_ms: u64 = cfg_opt::<u64>(ruby, config, "request_timeout_ms")?.unwrap_or(0);
|
|
54
|
+
let tokio_threads: usize = cfg_opt::<usize>(ruby, config, "tokio_threads")?.unwrap_or(0);
|
|
55
|
+
let tls_cert: Option<String> = cfg_opt(ruby, config, "tls_cert")?;
|
|
56
|
+
let tls_key: Option<String> = cfg_opt(ruby, config, "tls_key")?;
|
|
57
|
+
let lanes: bool = cfg_opt(ruby, config, "lanes")?.unwrap_or(false);
|
|
58
|
+
let log_requests: bool = cfg_opt(ruby, config, "log_requests")?.unwrap_or(false);
|
|
59
|
+
let acceptor = match (&tls_cert, &tls_key) {
|
|
60
|
+
(Some(cert), Some(key)) => Some(
|
|
61
|
+
crate::tls::build_acceptor(cert, key)
|
|
62
|
+
.map_err(|e| Error::new(ruby.exception_runtime_error(), format!("TLS: {e}")))?,
|
|
63
|
+
),
|
|
64
|
+
(None, None) => None,
|
|
65
|
+
_ => {
|
|
66
|
+
return Err(Error::new(
|
|
67
|
+
ruby.exception_arg_error(),
|
|
68
|
+
"TLS requires both cert and key",
|
|
69
|
+
))
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let listener = std::net::TcpListener::bind((bind.as_str(), port))
|
|
74
|
+
.map_err(|e| io_error(ruby, "bind failed", e))?;
|
|
75
|
+
listener
|
|
76
|
+
.set_nonblocking(true)
|
|
77
|
+
.map_err(|e| io_error(ruby, "listener setup failed", e))?;
|
|
78
|
+
let local_port = listener
|
|
79
|
+
.local_addr()
|
|
80
|
+
.map_err(|e| io_error(ruby, "listener setup failed", e))?
|
|
81
|
+
.port();
|
|
82
|
+
|
|
83
|
+
let mut builder = tokio::runtime::Builder::new_multi_thread();
|
|
84
|
+
builder.enable_all().thread_name("kino-tokio");
|
|
85
|
+
if tokio_threads > 0 {
|
|
86
|
+
builder.worker_threads(tokio_threads);
|
|
87
|
+
}
|
|
88
|
+
let runtime = builder
|
|
89
|
+
.build()
|
|
90
|
+
.map_err(|e| io_error(ruby, "tokio runtime failed", e))?;
|
|
91
|
+
|
|
92
|
+
let (req_tx, req_rx) = flume::bounded(queue_depth);
|
|
93
|
+
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
|
94
|
+
|
|
95
|
+
let server = Arc::new(ServerInner {
|
|
96
|
+
id: registry::next_server_id(),
|
|
97
|
+
req_tx: Mutex::new(Some(req_tx)),
|
|
98
|
+
req_rx,
|
|
99
|
+
shutdown_tx,
|
|
100
|
+
runtime: Mutex::new(None),
|
|
101
|
+
slots: RwLock::new(Vec::new()),
|
|
102
|
+
in_flight: std::sync::atomic::AtomicUsize::new(0),
|
|
103
|
+
served: std::sync::atomic::AtomicU64::new(0),
|
|
104
|
+
rejected: std::sync::atomic::AtomicU64::new(0),
|
|
105
|
+
queue_timeout_ms,
|
|
106
|
+
request_timeout_ms,
|
|
107
|
+
timeouts: std::sync::atomic::AtomicU64::new(0),
|
|
108
|
+
https: acceptor.is_some(),
|
|
109
|
+
access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
|
|
110
|
+
lanes,
|
|
111
|
+
lane_cursor: std::sync::atomic::AtomicUsize::new(0),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let tokio_listener = {
|
|
115
|
+
let _guard = runtime.enter();
|
|
116
|
+
tokio::net::TcpListener::from_std(listener)
|
|
117
|
+
.map_err(|e| io_error(ruby, "listener setup failed", e))?
|
|
118
|
+
};
|
|
119
|
+
runtime.spawn(accept_loop(
|
|
120
|
+
tokio_listener,
|
|
121
|
+
acceptor,
|
|
122
|
+
server.clone(),
|
|
123
|
+
shutdown_rx,
|
|
124
|
+
));
|
|
125
|
+
*server.runtime.lock() = Some(runtime);
|
|
126
|
+
|
|
127
|
+
let id = server.id;
|
|
128
|
+
registry::insert(server);
|
|
129
|
+
Ok((id, local_port))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async fn accept_loop(
|
|
133
|
+
listener: tokio::net::TcpListener,
|
|
134
|
+
acceptor: Option<tokio_rustls::TlsAcceptor>,
|
|
135
|
+
server: Arc<ServerInner>,
|
|
136
|
+
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
|
137
|
+
) {
|
|
138
|
+
loop {
|
|
139
|
+
tokio::select! {
|
|
140
|
+
_ = 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
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async fn serve_connection<I>(
|
|
167
|
+
io: I,
|
|
168
|
+
server: Arc<ServerInner>,
|
|
169
|
+
remote_addr: SocketAddr,
|
|
170
|
+
local_addr: SocketAddr,
|
|
171
|
+
) where
|
|
172
|
+
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
|
173
|
+
{
|
|
174
|
+
let service =
|
|
175
|
+
service_fn(move |req| handle_request(server.clone(), remote_addr, local_addr, req));
|
|
176
|
+
// No auto Date header: it costs a clock read per response (together
|
|
177
|
+
// with timer reads, ~7% of tokio-side cycles in the profile); it's a
|
|
178
|
+
// SHOULD not a MUST, and apps that need it can set it themselves.
|
|
179
|
+
let _ = hyper::server::conn::http1::Builder::new()
|
|
180
|
+
.auto_date_header(false)
|
|
181
|
+
.serve_connection(TokioIo::new(io), service)
|
|
182
|
+
.await;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// The 503 every rejection path returns; counted for stats. Branding
|
|
186
|
+
/// happens at handle_request's single exit.
|
|
187
|
+
fn unavailable(server: &ServerInner) -> HyperResponse {
|
|
188
|
+
server.rejected.fetch_add(1, Ordering::Relaxed);
|
|
189
|
+
plain_response(503, "Service Unavailable\n")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Every response carries `Server: kino` unless the app set its own.
|
|
193
|
+
fn branded(mut response: HyperResponse) -> HyperResponse {
|
|
194
|
+
response
|
|
195
|
+
.headers_mut()
|
|
196
|
+
.entry(http::header::SERVER)
|
|
197
|
+
.or_insert(http::HeaderValue::from_static("Kino"));
|
|
198
|
+
response
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async fn handle_request(
|
|
202
|
+
server: Arc<ServerInner>,
|
|
203
|
+
remote_addr: SocketAddr,
|
|
204
|
+
local_addr: SocketAddr,
|
|
205
|
+
req: hyper::Request<hyper::body::Incoming>,
|
|
206
|
+
) -> Result<HyperResponse, std::convert::Infallible> {
|
|
207
|
+
let (parts, body) = req.into_parts();
|
|
208
|
+
|
|
209
|
+
// Access-log metadata is captured only when logging is on: one Instant
|
|
210
|
+
// read plus one small String per request.
|
|
211
|
+
let log_meta = server.access_log.as_ref().map(|_| {
|
|
212
|
+
let target = match parts.uri.query() {
|
|
213
|
+
Some(q) => format!("{}?{}", parts.uri.path(), q),
|
|
214
|
+
None => parts.uri.path().to_string(),
|
|
215
|
+
};
|
|
216
|
+
(
|
|
217
|
+
std::time::Instant::now(),
|
|
218
|
+
parts.method.to_string(),
|
|
219
|
+
target,
|
|
220
|
+
parts.version,
|
|
221
|
+
)
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Stream the request body through a bounded channel: hyper is polled
|
|
225
|
+
// only as fast as the Ruby side consumes (inbound backpressure), and the
|
|
226
|
+
// forwarder dropping the sender is EOF. Bodyless requests (most GETs)
|
|
227
|
+
// skip the forwarder task entirely: dropping the sender IS the EOF.
|
|
228
|
+
let (body_tx, body_rx) = flume::bounded::<bytes::Bytes>(8);
|
|
229
|
+
if hyper::body::Body::is_end_stream(&body) {
|
|
230
|
+
drop(body_tx);
|
|
231
|
+
} else {
|
|
232
|
+
tokio::spawn(async move {
|
|
233
|
+
let mut body = body;
|
|
234
|
+
while let Some(frame) = body.frame().await {
|
|
235
|
+
let Ok(frame) = frame else { break };
|
|
236
|
+
if let Ok(data) = frame.into_data() {
|
|
237
|
+
if body_tx.send_async(data).await.is_err() {
|
|
238
|
+
break; // request handle dropped; stop pulling
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let (head_tx, head_rx) = tokio::sync::oneshot::channel();
|
|
246
|
+
let responder = Arc::new(Responder::new(head_tx));
|
|
247
|
+
let ctx = Box::new(RequestCtx {
|
|
248
|
+
method: parts.method,
|
|
249
|
+
uri: parts.uri,
|
|
250
|
+
version: parts.version,
|
|
251
|
+
headers: parts.headers,
|
|
252
|
+
remote_addr,
|
|
253
|
+
local_addr,
|
|
254
|
+
https: server.https,
|
|
255
|
+
body_rx,
|
|
256
|
+
leftover: None,
|
|
257
|
+
slot: None,
|
|
258
|
+
responder,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Drop guard, not manual decrement: when a client aborts mid-request,
|
|
262
|
+
// hyper DROPS this future at the next await point: a plain decrement
|
|
263
|
+
// after the await would never run, leaking in_flight upward and making
|
|
264
|
+
// shutdown's drain wait its full deadline (observed as a Ctrl-C "hang").
|
|
265
|
+
struct InFlight(Arc<ServerInner>);
|
|
266
|
+
impl Drop for InFlight {
|
|
267
|
+
fn drop(&mut self) {
|
|
268
|
+
self.0.in_flight.fetch_sub(1, Ordering::Relaxed);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
server.in_flight.fetch_add(1, Ordering::Relaxed);
|
|
272
|
+
let _in_flight = InFlight(server.clone());
|
|
273
|
+
|
|
274
|
+
// Single exit point so the access log sees every outcome, 503s included.
|
|
275
|
+
let response: HyperResponse = 'resp: {
|
|
276
|
+
if server.lanes {
|
|
277
|
+
if !dispatch_to_lane(&server, ctx).await {
|
|
278
|
+
break 'resp unavailable(&server);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
// Clone the sender per request (not per connection) so a
|
|
282
|
+
// long-lived keep-alive connection can't hold the queue open
|
|
283
|
+
// during drain.
|
|
284
|
+
let Some(tx) = server.req_tx.lock().clone() else {
|
|
285
|
+
break 'resp unavailable(&server);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// try_send first: when the queue has room (the overwhelmingly
|
|
289
|
+
// common case) this skips registering a tokio timer + its
|
|
290
|
+
// clock reads. Only a genuinely full queue pays for the timed
|
|
291
|
+
// wait before the 503.
|
|
292
|
+
match tx.try_send(ctx) {
|
|
293
|
+
Ok(()) => {}
|
|
294
|
+
Err(flume::TrySendError::Disconnected(_)) => {
|
|
295
|
+
break 'resp unavailable(&server);
|
|
296
|
+
}
|
|
297
|
+
Err(flume::TrySendError::Full(ctx)) => {
|
|
298
|
+
let timeout = Duration::from_millis(server.queue_timeout_ms);
|
|
299
|
+
let enqueued = tokio::time::timeout(timeout, tx.send_async(ctx)).await;
|
|
300
|
+
if !matches!(enqueued, Ok(Ok(()))) {
|
|
301
|
+
// Timed out or queue closed mid-send; ctx was
|
|
302
|
+
// dropped unsent either way.
|
|
303
|
+
break 'resp unavailable(&server);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// request_timeout: the response head must arrive within the
|
|
310
|
+
// deadline or the client gets an immediate 504; the worker keeps
|
|
311
|
+
// running and its eventual response is dropped harmlessly (the
|
|
312
|
+
// responder's first-claimant race makes the late send a no-op).
|
|
313
|
+
// Caveat by design: a CPU-stuck handler still occupies its slot
|
|
314
|
+
// until it finishes; interrupting arbitrary Ruby would require
|
|
315
|
+
// Thread#raise-style unsafety.
|
|
316
|
+
if server.request_timeout_ms > 0 {
|
|
317
|
+
let deadline = Duration::from_millis(server.request_timeout_ms);
|
|
318
|
+
match tokio::time::timeout(deadline, head_rx).await {
|
|
319
|
+
Ok(result) => {
|
|
320
|
+
result.unwrap_or_else(|_| plain_response(500, "Internal Server Error\n"))
|
|
321
|
+
}
|
|
322
|
+
Err(_elapsed) => {
|
|
323
|
+
server.timeouts.fetch_add(1, Ordering::Relaxed);
|
|
324
|
+
plain_response(504, "Gateway Timeout\n")
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
head_rx
|
|
329
|
+
.await
|
|
330
|
+
.unwrap_or_else(|_| plain_response(500, "Internal Server Error\n"))
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if let (Some(log), Some((start, method, target, version))) =
|
|
335
|
+
(server.access_log.as_ref(), log_meta)
|
|
336
|
+
{
|
|
337
|
+
let status = response.status().as_u16();
|
|
338
|
+
let line = format!(
|
|
339
|
+
"{} [{}] \"{method} {target} {version:?}\" {status} {:.1}ms",
|
|
340
|
+
remote_addr.ip(),
|
|
341
|
+
httpdate::fmt_http_date(std::time::SystemTime::now()),
|
|
342
|
+
start.elapsed().as_secs_f64() * 1000.0
|
|
343
|
+
);
|
|
344
|
+
log.write_line(crate::style::status_colored(status, &line));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
Ok(branded(response))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/// One dispatch attempt's outcome; `Full` hands the ctx back for a retry.
|
|
351
|
+
enum Dispatch {
|
|
352
|
+
Sent,
|
|
353
|
+
Full(BoxedCtx),
|
|
354
|
+
Closed,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/// Lane dispatch: prefer an awake (non-parked) lane with room (a hot
|
|
358
|
+
/// worker keeps taking without ever paying a futex wake), then any lane
|
|
359
|
+
/// with room. All lanes full = genuine overload: retry briefly up to
|
|
360
|
+
/// queue_timeout, then give up (caller 503s).
|
|
361
|
+
fn try_dispatch(server: &ServerInner, mut ctx: BoxedCtx) -> Dispatch {
|
|
362
|
+
let slots = server.slots.read();
|
|
363
|
+
let n = slots.len();
|
|
364
|
+
if n == 0 {
|
|
365
|
+
return Dispatch::Full(ctx);
|
|
366
|
+
}
|
|
367
|
+
let start = server.lane_cursor.fetch_add(1, Ordering::Relaxed);
|
|
368
|
+
let mut any_open = false;
|
|
369
|
+
for pass in 0..2 {
|
|
370
|
+
for k in 0..n {
|
|
371
|
+
let slot = &slots[(start + k) % n];
|
|
372
|
+
if pass == 0 && slot.parked.load(Ordering::Relaxed) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
let guard = slot.lane_tx.lock();
|
|
376
|
+
let Some(tx) = guard.as_ref() else { continue };
|
|
377
|
+
any_open = true;
|
|
378
|
+
match tx.try_send(ctx) {
|
|
379
|
+
Ok(()) => return Dispatch::Sent,
|
|
380
|
+
Err(flume::TrySendError::Full(c)) | Err(flume::TrySendError::Disconnected(c)) => {
|
|
381
|
+
ctx = c;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if any_open {
|
|
387
|
+
Dispatch::Full(ctx) // overload: every open lane is full
|
|
388
|
+
} else {
|
|
389
|
+
Dispatch::Closed // draining: all lanes closed
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async fn dispatch_to_lane(server: &Arc<ServerInner>, ctx: BoxedCtx) -> bool {
|
|
394
|
+
let mut pending = ctx;
|
|
395
|
+
let deadline = tokio::time::Instant::now() + Duration::from_millis(server.queue_timeout_ms);
|
|
396
|
+
loop {
|
|
397
|
+
match try_dispatch(server, pending) {
|
|
398
|
+
Dispatch::Sent => return true,
|
|
399
|
+
Dispatch::Closed => return false,
|
|
400
|
+
Dispatch::Full(ctx) => {
|
|
401
|
+
if tokio::time::Instant::now() >= deadline {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
pending = ctx;
|
|
405
|
+
tokio::time::sleep(Duration::from_micros(500)).await;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- lifecycle natives ---
|
|
412
|
+
// All tolerant of an already-removed server: shutdown must be idempotent and
|
|
413
|
+
// late-waking workers must see "gone" as a no-op, never an exception.
|
|
414
|
+
|
|
415
|
+
pub fn register_worker(ruby: &Ruby, server_id: u64) -> Result<usize, Error> {
|
|
416
|
+
Ok(registry::get(ruby, server_id)?.register_worker())
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
pub fn stop_accepting(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
420
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
421
|
+
let _ = server.shutdown_tx.send(true);
|
|
422
|
+
}
|
|
423
|
+
Ok(())
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
pub fn close_queue(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
427
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
428
|
+
server.req_tx.lock().take();
|
|
429
|
+
for slot in server.slots.read().iter() {
|
|
430
|
+
slot.lane_tx.lock().take();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
Ok(())
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
pub fn queue_stats(_ruby: &Ruby, server_id: u64) -> Result<(usize, usize), Error> {
|
|
437
|
+
match registry::try_get(server_id) {
|
|
438
|
+
Some(server) => Ok((server.queued(), server.in_flight.load(Ordering::Relaxed))),
|
|
439
|
+
None => Ok((0, 0)),
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
fn abort_slot(slot: &WorkerSlot) {
|
|
444
|
+
for weak in slot.current.lock().drain(..) {
|
|
445
|
+
if let Some(responder) = weak.upgrade() {
|
|
446
|
+
responder.respond_500_if_unsent();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Lane mode: this worker is dead. Close its lane so the dispatcher
|
|
450
|
+
// skips it, and drain anything queued; dropping each ctx fires the
|
|
451
|
+
// Drop-500 backstop so those clients aren't left hanging.
|
|
452
|
+
slot.lane_tx.lock().take();
|
|
453
|
+
slot.parked.store(true, Ordering::Relaxed);
|
|
454
|
+
if let Some(rx) = slot.lane_rx.as_ref() {
|
|
455
|
+
while rx.try_recv().is_ok() {}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
pub fn abort_inflight(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
|
|
460
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
461
|
+
let slot = server.slot(ruby, worker_id)?;
|
|
462
|
+
abort_slot(&slot);
|
|
463
|
+
}
|
|
464
|
+
Ok(())
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
pub fn abort_all_inflight(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
468
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
469
|
+
for slot in server.slots.read().iter() {
|
|
470
|
+
abort_slot(slot);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
Ok(())
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
pub fn interrupt_all_workers(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
|
|
477
|
+
if let Some(server) = registry::try_get(server_id) {
|
|
478
|
+
for slot in server.slots.read().iter() {
|
|
479
|
+
slot.interrupted.store(true, Ordering::SeqCst);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
Ok(())
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
pub fn shutdown_runtime(_ruby: &Ruby, server_id: u64, timeout_ms: u64) -> Result<(), Error> {
|
|
486
|
+
if let Some(server) = registry::remove(server_id) {
|
|
487
|
+
if let Some(runtime) = server.runtime.lock().take() {
|
|
488
|
+
runtime.shutdown_timeout(Duration::from_millis(timeout_ms));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
Ok(())
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/// Errors print in red on color terminals. Covers worker errors,
|
|
495
|
+
/// supervisor crash reports, and everything apps write to rack.errors.
|
|
496
|
+
pub fn log_error(message: String) {
|
|
497
|
+
eprintln!("{}", crate::style::red(&format!("[Kino] {message}")));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// Full stats snapshot: [queued, in_flight, served, rejected, timeouts,
|
|
501
|
+
/// lane_depths]. lane_depths is nil unless lane dispatch is on.
|
|
502
|
+
#[allow(clippy::type_complexity)]
|
|
503
|
+
pub fn server_stats(
|
|
504
|
+
_ruby: &Ruby,
|
|
505
|
+
server_id: u64,
|
|
506
|
+
) -> Result<(usize, usize, u64, u64, u64, Option<Vec<usize>>), Error> {
|
|
507
|
+
let Some(server) = registry::try_get(server_id) else {
|
|
508
|
+
return Ok((0, 0, 0, 0, 0, None));
|
|
509
|
+
};
|
|
510
|
+
let lane_depths = server.lane_depths();
|
|
511
|
+
let queued = server.req_rx.len() + lane_depths.as_ref().map_or(0, |d| d.iter().sum::<usize>());
|
|
512
|
+
Ok((
|
|
513
|
+
queued,
|
|
514
|
+
server.in_flight.load(Ordering::Relaxed),
|
|
515
|
+
server.served.load(Ordering::Relaxed),
|
|
516
|
+
server.rejected.load(Ordering::Relaxed),
|
|
517
|
+
server.timeouts.load(Ordering::Relaxed),
|
|
518
|
+
lane_depths,
|
|
519
|
+
))
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#[cfg(test)]
|
|
523
|
+
mod tests {
|
|
524
|
+
use super::*;
|
|
525
|
+
use crate::registry::{test_server, LANE_DEPTH};
|
|
526
|
+
use crate::request::test_ctx;
|
|
527
|
+
|
|
528
|
+
#[test]
|
|
529
|
+
fn dispatch_with_no_slots_reports_full() {
|
|
530
|
+
let server = test_server(true, 4);
|
|
531
|
+
|
|
532
|
+
assert!(matches!(
|
|
533
|
+
try_dispatch(&server, test_ctx()),
|
|
534
|
+
Dispatch::Full(_)
|
|
535
|
+
));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#[test]
|
|
539
|
+
fn dispatch_sends_to_an_open_lane() {
|
|
540
|
+
let server = test_server(true, 4);
|
|
541
|
+
server.register_worker();
|
|
542
|
+
|
|
543
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
544
|
+
assert_eq!(server.lane_depths(), Some(vec![1]));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
#[test]
|
|
548
|
+
fn dispatch_hands_the_ctx_back_when_every_lane_is_full() {
|
|
549
|
+
let server = test_server(true, 4);
|
|
550
|
+
server.register_worker();
|
|
551
|
+
|
|
552
|
+
for _ in 0..LANE_DEPTH {
|
|
553
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
554
|
+
}
|
|
555
|
+
// The overload path must return the ctx for the timed retry loop.
|
|
556
|
+
assert!(matches!(
|
|
557
|
+
try_dispatch(&server, test_ctx()),
|
|
558
|
+
Dispatch::Full(_)
|
|
559
|
+
));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
#[test]
|
|
563
|
+
fn dispatch_reports_closed_when_lanes_are_draining() {
|
|
564
|
+
let server = test_server(true, 4);
|
|
565
|
+
server.register_worker();
|
|
566
|
+
server.slots.read()[0].lane_tx.lock().take();
|
|
567
|
+
|
|
568
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Closed));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
#[test]
|
|
572
|
+
fn branded_adds_the_server_header_unless_the_app_set_one() {
|
|
573
|
+
let response = branded(plain_response(200, "x"));
|
|
574
|
+
assert_eq!(response.headers().get("server").unwrap(), "Kino");
|
|
575
|
+
|
|
576
|
+
let mut custom = plain_response(200, "x");
|
|
577
|
+
custom.headers_mut().insert(
|
|
578
|
+
http::header::SERVER,
|
|
579
|
+
http::HeaderValue::from_static("custom"),
|
|
580
|
+
);
|
|
581
|
+
assert_eq!(branded(custom).headers().get("server").unwrap(), "custom");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#[test]
|
|
585
|
+
fn unavailable_counts_rejections_and_returns_503() {
|
|
586
|
+
let server = test_server(false, 1);
|
|
587
|
+
|
|
588
|
+
let response = unavailable(&server);
|
|
589
|
+
assert_eq!(response.status(), 503);
|
|
590
|
+
assert_eq!(server.rejected.load(Ordering::Relaxed), 1);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#[test]
|
|
594
|
+
fn dispatch_round_robins_across_awake_lanes() {
|
|
595
|
+
let server = test_server(true, 4);
|
|
596
|
+
server.register_worker();
|
|
597
|
+
server.register_worker();
|
|
598
|
+
|
|
599
|
+
for _ in 0..4 {
|
|
600
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
601
|
+
}
|
|
602
|
+
// The rotating cursor spreads load instead of pinning one lane.
|
|
603
|
+
assert_eq!(server.lane_depths(), Some(vec![2, 2]));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#[test]
|
|
607
|
+
fn dispatch_skips_parked_lanes_when_an_awake_one_has_room() {
|
|
608
|
+
let server = test_server(true, 4);
|
|
609
|
+
server.register_worker();
|
|
610
|
+
server.register_worker();
|
|
611
|
+
server.slots.read()[0]
|
|
612
|
+
.parked
|
|
613
|
+
.store(true, Ordering::Relaxed);
|
|
614
|
+
|
|
615
|
+
// Both dispatches land on the awake lane (slot 1), regardless of
|
|
616
|
+
// where the rotating cursor starts.
|
|
617
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
618
|
+
assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
|
|
619
|
+
assert_eq!(server.lane_depths(), Some(vec![0, 2]));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//! ANSI styling for the few places the native layer writes to the
|
|
2
|
+
//! terminal: stderr error lines and the stdout access log. (The Ruby-side
|
|
3
|
+
//! startup banner has its own twin in Kino::CLI.) Color-capability is
|
|
4
|
+
//! decided once per stream; every styled string resets at its end so
|
|
5
|
+
//! nothing bleeds.
|
|
6
|
+
|
|
7
|
+
use std::sync::OnceLock;
|
|
8
|
+
|
|
9
|
+
#[derive(Clone, Copy)]
|
|
10
|
+
enum Stream {
|
|
11
|
+
Stdout,
|
|
12
|
+
Stderr,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fn enabled(stream: Stream) -> bool {
|
|
16
|
+
use std::io::IsTerminal;
|
|
17
|
+
static STDOUT: OnceLock<bool> = OnceLock::new();
|
|
18
|
+
static STDERR: OnceLock<bool> = OnceLock::new();
|
|
19
|
+
|
|
20
|
+
let env_ok = || {
|
|
21
|
+
std::env::var_os("NO_COLOR").is_none()
|
|
22
|
+
&& std::env::var_os("TERM").is_none_or(|t| t != "dumb")
|
|
23
|
+
};
|
|
24
|
+
match stream {
|
|
25
|
+
Stream::Stdout => *STDOUT.get_or_init(|| std::io::stdout().is_terminal() && env_ok()),
|
|
26
|
+
Stream::Stderr => *STDERR.get_or_init(|| std::io::stderr().is_terminal() && env_ok()),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Wrap `text` in an SGR code (e.g. "31" red, "1" bold, "38;5;208"
|
|
31
|
+
/// 256-color), plain when the stream isn't a color terminal.
|
|
32
|
+
fn paint(stream: Stream, code: &str, text: &str) -> String {
|
|
33
|
+
if enabled(stream) {
|
|
34
|
+
format!("\x1b[{code}m{text}\x1b[0m")
|
|
35
|
+
} else {
|
|
36
|
+
text.to_string()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Errors on stderr are bright red (91): the base red slot (31) is
|
|
41
|
+
/// remapped to odd hues by some terminal themes; 91 stays red.
|
|
42
|
+
pub fn red(text: &str) -> String {
|
|
43
|
+
paint(Stream::Stderr, "91", text)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// The SGR code for a status class (basic 16-color palette only):
|
|
47
|
+
/// 2xx green, 3xx yellow, 4xx maroon (ANSI color 1, plain dark red),
|
|
48
|
+
/// 5xx bright red, anything else uncolored.
|
|
49
|
+
fn status_sgr(status: u16) -> Option<&'static str> {
|
|
50
|
+
match status {
|
|
51
|
+
200..=299 => Some("32"),
|
|
52
|
+
300..=399 => Some("33"),
|
|
53
|
+
400..=499 => Some("31"),
|
|
54
|
+
500..=599 => Some("91"),
|
|
55
|
+
_ => None,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Access-log lines on stdout, tinted by status class.
|
|
60
|
+
pub fn status_colored(status: u16, line: &str) -> String {
|
|
61
|
+
match status_sgr(status) {
|
|
62
|
+
Some(code) => paint(Stream::Stdout, code, line),
|
|
63
|
+
None => line.to_string(),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[cfg(test)]
|
|
68
|
+
mod tests {
|
|
69
|
+
use super::status_sgr;
|
|
70
|
+
|
|
71
|
+
#[test]
|
|
72
|
+
fn status_classes_map_to_their_colors() {
|
|
73
|
+
assert_eq!(status_sgr(200), Some("32")); // green
|
|
74
|
+
assert_eq!(status_sgr(299), Some("32"));
|
|
75
|
+
assert_eq!(status_sgr(301), Some("33")); // yellow
|
|
76
|
+
assert_eq!(status_sgr(404), Some("31")); // maroon
|
|
77
|
+
assert_eq!(status_sgr(500), Some("91")); // bright red
|
|
78
|
+
assert_eq!(status_sgr(599), Some("91"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn out_of_class_statuses_stay_plain() {
|
|
83
|
+
assert_eq!(status_sgr(100), None);
|
|
84
|
+
assert_eq!(status_sgr(199), None);
|
|
85
|
+
assert_eq!(status_sgr(600), None);
|
|
86
|
+
}
|
|
87
|
+
}
|