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.
@@ -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
+ }