kino 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  //! intake. Ruby is never on these threads; the only contact points are the
3
3
  //! flume queue (in) and each request's Responder (out).
4
4
 
5
- use std::net::SocketAddr;
5
+ use std::net::{IpAddr, Ipv4Addr, SocketAddr};
6
6
  use std::sync::atomic::Ordering;
7
7
  use std::sync::Arc;
8
8
  use std::time::Duration;
@@ -13,7 +13,8 @@ use hyper_util::rt::TokioIo;
13
13
  use magnus::{Error, Ruby};
14
14
  use parking_lot::{Mutex, RwLock};
15
15
 
16
- use crate::registry::{self, BoxedCtx, ServerInner, WorkerSlot};
16
+ use crate::listen::Listener;
17
+ use crate::registry::{self, BoxedCtx, RuntimeHandle, ServerInner, WorkerSlot};
17
18
  use crate::request::RequestCtx;
18
19
  use crate::response::{plain_response, HyperResponse, Responder};
19
20
 
@@ -55,6 +56,8 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
55
56
  let request_timeout_ms: u64 = cfg_opt::<u64>(ruby, config, "request_timeout_ms")?.unwrap_or(0);
56
57
  let max_body_size: usize = cfg_opt::<usize>(ruby, config, "max_body_size")?.unwrap_or(0);
57
58
  let max_connections: usize = cfg_opt::<usize>(ruby, config, "max_connections")?.unwrap_or(1024);
59
+ let io_shards: bool = cfg_opt(ruby, config, "io_shards")?.unwrap_or(false);
60
+ let io_threads: usize = cfg_opt::<usize>(ruby, config, "io_threads")?.unwrap_or(0);
58
61
  let tokio_threads: usize = cfg_opt::<usize>(ruby, config, "tokio_threads")?.unwrap_or(0);
59
62
  let tls_cert: Option<String> = cfg_opt(ruby, config, "tls_cert")?;
60
63
  let tls_key: Option<String> = cfg_opt(ruby, config, "tls_key")?;
@@ -78,15 +81,22 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
78
81
  }
79
82
  };
80
83
 
81
- let listener = std::net::TcpListener::bind((bind.as_str(), port))
82
- .map_err(|e| io_error(ruby, "bind failed", e))?;
83
- listener
84
- .set_nonblocking(true)
85
- .map_err(|e| io_error(ruby, "listener setup failed", e))?;
84
+ let listener = Listener::bind(&bind, port).map_err(|e| io_error(ruby, "bind failed", e))?;
85
+ // Ruby refuses this combination up front; this guards embedders
86
+ // calling the native layer directly.
87
+ if acceptor.is_some() && matches!(listener, Listener::Unix(..)) {
88
+ return Err(Error::new(
89
+ ruby.exception_arg_error(),
90
+ "TLS is not supported on a unix socket bind",
91
+ ));
92
+ }
86
93
  let local_port = listener
87
- .local_addr()
88
- .map_err(|e| io_error(ruby, "listener setup failed", e))?
89
- .port();
94
+ .port()
95
+ .map_err(|e| io_error(ruby, "listener setup failed", e))?;
96
+ let unix_path = match &listener {
97
+ Listener::Unix(_, path) => Some(path.clone()),
98
+ Listener::Tcp(_) => None,
99
+ };
90
100
 
91
101
  let control_bind_addr: Option<String> = cfg_opt(ruby, config, "control_bind")?;
92
102
  let control_token: Option<String> = cfg_opt(ruby, config, "control_token")?;
@@ -97,15 +107,6 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
97
107
  })
98
108
  .transpose()?;
99
109
 
100
- let mut builder = tokio::runtime::Builder::new_multi_thread();
101
- builder.enable_all().thread_name("kino-tokio");
102
- if tokio_threads > 0 {
103
- builder.worker_threads(tokio_threads);
104
- }
105
- let runtime = builder
106
- .build()
107
- .map_err(|e| io_error(ruby, "tokio runtime failed", e))?;
108
-
109
110
  let (req_tx, req_rx) = flume::bounded(queue_depth);
110
111
  let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
111
112
 
@@ -114,7 +115,7 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
114
115
  req_tx: Mutex::new(Some(req_tx)),
115
116
  req_rx,
116
117
  shutdown_tx,
117
- runtime: Mutex::new(None),
118
+ runtime: Mutex::new(RuntimeHandle::None),
118
119
  slots: RwLock::new(Vec::new()),
119
120
  in_flight: std::sync::atomic::AtomicUsize::new(0),
120
121
  served: std::sync::atomic::AtomicU64::new(0),
@@ -126,8 +127,14 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
126
127
  state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
127
128
  respawns: std::sync::atomic::AtomicU64::new(0),
128
129
  quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
129
- topology: registry::Topology { mode, workers, threads, batch },
130
+ topology: registry::Topology {
131
+ mode,
132
+ workers,
133
+ threads,
134
+ batch,
135
+ },
130
136
  https: acceptor.is_some(),
137
+ unix_path,
131
138
  access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
132
139
  lanes,
133
140
  lane_cursor: std::sync::atomic::AtomicUsize::new(0),
@@ -135,19 +142,48 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
135
142
  queue_histogram: registry::QueueHistogram::new(),
136
143
  });
137
144
 
138
- let tokio_listener = {
139
- let _guard = runtime.enter();
140
- tokio::net::TcpListener::from_std(listener)
141
- .map_err(|e| io_error(ruby, "listener setup failed", e))?
142
- };
143
- runtime.spawn(accept_loop(
144
- tokio_listener,
145
- acceptor,
146
- server.clone(),
147
- max_connections,
148
- shutdown_rx,
149
- ));
150
- *server.runtime.lock() = Some(runtime);
145
+ if io_shards {
146
+ // The shards keep serving accepted connections while the acceptor
147
+ // drains on `shutdown_rx`; this second signal stops them only at
148
+ // final teardown.
149
+ let (runtime_shutdown_tx, runtime_shutdown_rx) = tokio::sync::watch::channel(false);
150
+ let threads = crate::io_shards::spawn(
151
+ listener,
152
+ acceptor,
153
+ server.clone(),
154
+ max_connections,
155
+ shutdown_rx,
156
+ runtime_shutdown_rx,
157
+ crate::io_shards::thread_count(io_threads),
158
+ )
159
+ .map_err(|e| io_error(ruby, "sharded runtime failed", e))?;
160
+ *server.runtime.lock() = RuntimeHandle::Shards {
161
+ shutdown_tx: runtime_shutdown_tx,
162
+ threads,
163
+ };
164
+ } else {
165
+ let mut builder = tokio::runtime::Builder::new_multi_thread();
166
+ builder.enable_all().thread_name("kino-tokio");
167
+ if tokio_threads > 0 {
168
+ builder.worker_threads(tokio_threads);
169
+ }
170
+ let runtime = builder
171
+ .build()
172
+ .map_err(|e| io_error(ruby, "tokio runtime failed", e))?;
173
+ let tokio_listener = {
174
+ let _guard = runtime.enter();
175
+ AsyncListener::from_std(listener)
176
+ .map_err(|e| io_error(ruby, "listener setup failed", e))?
177
+ };
178
+ runtime.spawn(accept_loop(
179
+ tokio_listener,
180
+ acceptor,
181
+ server.clone(),
182
+ max_connections,
183
+ shutdown_rx,
184
+ ));
185
+ *server.runtime.lock() = RuntimeHandle::MultiThread(runtime);
186
+ }
151
187
 
152
188
  let id = server.id;
153
189
  let control_port = match control_bind {
@@ -161,10 +197,11 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
161
197
  Some(bind) => match crate::control::start(bind, server.clone(), control_token) {
162
198
  Ok(port) => port,
163
199
  Err(e) => {
164
- // A plain drop blocks until the accept loop's task (its
165
- // only task, idling on accept/shutdown) is torn down; the
166
- // runtime only ever had this one thing to cancel.
167
- drop(server.runtime.lock().take());
200
+ // Nothing is serving yet, so the bound only matters for a
201
+ // wedged shard thread; the default runtime just cancels
202
+ // its one idle accept task.
203
+ let _ = server.shutdown_tx.send(true);
204
+ std::mem::take(&mut *server.runtime.lock()).shutdown(Duration::from_millis(1_000));
168
205
  return Err(io_error(ruby, "control start failed", e));
169
206
  }
170
207
  },
@@ -182,8 +219,61 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
182
219
  /// timeout: not a knob.
183
220
  const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
184
221
 
222
+ /// What a unix-socket connection reports as its addresses. The peer is
223
+ /// local by definition (REMOTE_ADDR 127.0.0.1), and a socket has no port,
224
+ /// so SERVER_PORT falls back to http's default when the Host header names
225
+ /// none, the way Puma reports unix-socket requests.
226
+ const UNIX_PEER: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
227
+ const UNIX_LOCAL: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 80);
228
+
229
+ /// The accept loop's listener: TCP (optionally behind TLS), or a unix
230
+ /// socket, which carries plain HTTP only.
231
+ pub(crate) enum AsyncListener {
232
+ Tcp(tokio::net::TcpListener),
233
+ Unix(tokio::net::UnixListener),
234
+ }
235
+
236
+ /// One accepted connection, before the protocol layer sees it.
237
+ pub(crate) enum Conn {
238
+ Tcp(tokio::net::TcpStream),
239
+ Unix(tokio::net::UnixStream),
240
+ }
241
+
242
+ impl AsyncListener {
243
+ /// Register the bound listener with the current runtime.
244
+ pub(crate) fn from_std(listener: Listener) -> std::io::Result<AsyncListener> {
245
+ Ok(match listener {
246
+ Listener::Tcp(listener) => {
247
+ AsyncListener::Tcp(tokio::net::TcpListener::from_std(listener)?)
248
+ }
249
+ Listener::Unix(listener, _) => {
250
+ AsyncListener::Unix(tokio::net::UnixListener::from_std(listener)?)
251
+ }
252
+ })
253
+ }
254
+
255
+ /// The next connection with its (peer, local) addresses.
256
+ pub(crate) async fn accept(&self) -> std::io::Result<(Conn, SocketAddr, SocketAddr)> {
257
+ match self {
258
+ AsyncListener::Tcp(listener) => {
259
+ let (stream, remote_addr) = listener.accept().await?;
260
+ // Small responses must not wait on Nagle + delayed ACK.
261
+ let _ = stream.set_nodelay(true);
262
+ let local_addr = stream
263
+ .local_addr()
264
+ .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
265
+ Ok((Conn::Tcp(stream), remote_addr, local_addr))
266
+ }
267
+ AsyncListener::Unix(listener) => {
268
+ let (stream, _) = listener.accept().await?;
269
+ Ok((Conn::Unix(stream), UNIX_PEER, UNIX_LOCAL))
270
+ }
271
+ }
272
+ }
273
+ }
274
+
185
275
  async fn accept_loop(
186
- listener: tokio::net::TcpListener,
276
+ listener: AsyncListener,
187
277
  acceptor: Option<tokio_rustls::TlsAcceptor>,
188
278
  server: Arc<ServerInner>,
189
279
  max_connections: usize,
@@ -202,38 +292,50 @@ async fn accept_loop(
202
292
  Err(_) => break, // semaphore closed
203
293
  },
204
294
  };
205
- let (stream, remote_addr) = tokio::select! {
295
+ let (conn, remote_addr, local_addr) = tokio::select! {
206
296
  _ = shutdown_rx.changed() => break,
207
297
  accepted = listener.accept() => match accepted {
208
- Ok(pair) => pair,
298
+ Ok(accepted) => accepted,
209
299
  Err(_) => continue, // transient accept error; permit drops, retry
210
300
  },
211
301
  };
212
- // Small responses must not wait on Nagle + delayed ACK.
213
- let _ = stream.set_nodelay(true);
214
- let local_addr = stream
215
- .local_addr()
216
- .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
217
302
  let server = server.clone();
218
303
  let acceptor = acceptor.clone();
219
304
  tokio::spawn(async move {
220
305
  // Held for the connection's lifetime; dropping it frees a slot.
221
306
  let _permit = permit;
222
- match acceptor {
223
- Some(acceptor) => {
224
- // Handshake failures (port scans, plain HTTP to a TLS
225
- // port) and stalled handshakes (slowloris) just drop the
226
- // connection; the timeout bounds the latter.
227
- let handshake = tokio::time::timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream));
228
- let Ok(Ok(tls)) = handshake.await else { return };
229
- serve_connection(tls, server, remote_addr, local_addr).await;
230
- }
231
- None => serve_connection(stream, server, remote_addr, local_addr).await,
232
- }
307
+ serve_conn(conn, acceptor, server, remote_addr, local_addr).await;
233
308
  });
234
309
  }
235
310
  }
236
311
 
312
+ /// Everything between an accepted connection and hyper: the optional TLS
313
+ /// handshake, then the protocol layer. Shared by the default accept loop
314
+ /// and the sharded one, so connection policy exists exactly once.
315
+ pub(crate) async fn serve_conn(
316
+ conn: Conn,
317
+ acceptor: Option<tokio_rustls::TlsAcceptor>,
318
+ server: Arc<ServerInner>,
319
+ remote_addr: SocketAddr,
320
+ local_addr: SocketAddr,
321
+ ) {
322
+ match (conn, acceptor) {
323
+ (Conn::Tcp(stream), Some(acceptor)) => {
324
+ // Handshake failures (port scans, plain HTTP to a TLS
325
+ // port) and stalled handshakes (slowloris) just drop the
326
+ // connection; the timeout bounds the latter.
327
+ let handshake = tokio::time::timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream));
328
+ let Ok(Ok(tls)) = handshake.await else { return };
329
+ serve_connection(tls, server, remote_addr, local_addr).await;
330
+ }
331
+ (Conn::Tcp(stream), None) => {
332
+ serve_connection(stream, server, remote_addr, local_addr).await
333
+ }
334
+ // TLS over a unix socket is refused at bind time.
335
+ (Conn::Unix(stream), _) => serve_connection(stream, server, remote_addr, local_addr).await,
336
+ }
337
+ }
338
+
237
339
  /// Slowloris guard: drop a connection that has not sent its complete request
238
340
  /// headers within this window. Long enough never to trip a real client (even
239
341
  /// on a slow mobile link), short enough to reap a stalled one. Deliberately a
@@ -310,18 +412,21 @@ async fn handle_request(
310
412
  let (parts, body) = req.into_parts();
311
413
 
312
414
  // Access-log metadata is captured only when logging is on: one Instant
313
- // read plus one small String per request.
314
- let log_meta = server.access_log.as_ref().map(|_| {
415
+ // read plus two small Strings per request. The arrival record is
416
+ // queued now, before the app sees the request, so a hang shows as an
417
+ // arrow with no answer; the completion record follows the response.
418
+ let log_meta = server.access_log.as_ref().map(|log| {
315
419
  let target = match parts.uri.query() {
316
420
  Some(q) => format!("{}?{}", parts.uri.path(), q),
317
421
  None => parts.uri.path().to_string(),
318
422
  };
319
- (
320
- std::time::Instant::now(),
321
- parts.method.to_string(),
322
- target,
323
- parts.version,
324
- )
423
+ let method = parts.method.to_string();
424
+ log.write_line(crate::access_log::arrival(
425
+ &method,
426
+ &target,
427
+ remote_addr.ip(),
428
+ ));
429
+ (std::time::Instant::now(), method, target)
325
430
  });
326
431
 
327
432
  // Body-size guard: an honestly-declared oversize body is refused with a
@@ -378,6 +483,7 @@ async fn handle_request(
378
483
 
379
484
  let (head_tx, head_rx) = tokio::sync::oneshot::channel();
380
485
  let responder = Arc::new(Responder::new(head_tx));
486
+ let now = std::time::Instant::now();
381
487
  let ctx = Box::new(RequestCtx {
382
488
  method: parts.method,
383
489
  uri: parts.uri,
@@ -393,7 +499,11 @@ async fn handle_request(
393
499
  slot: None,
394
500
  pin_slab: server.pin_slab.clone(),
395
501
  responder,
396
- enqueued_at: std::time::Instant::now(),
502
+ enqueued_at: now,
503
+ timed: server.access_log.is_some(),
504
+ wait: Duration::ZERO,
505
+ admitted_at: now,
506
+ gc: None,
397
507
  });
398
508
 
399
509
  // Drop guard, not manual decrement: when a client aborts mid-request,
@@ -472,17 +582,20 @@ async fn handle_request(
472
582
  }
473
583
  };
474
584
 
475
- if let (Some(log), Some((start, method, target, version))) =
476
- (server.access_log.as_ref(), log_meta)
477
- {
478
- let status = response.status().as_u16();
479
- let line = format!(
480
- "{} [{}] \"{method} {target} {version:?}\" {status} {:.1}ms",
481
- remote_addr.ip(),
482
- httpdate::fmt_http_date(std::time::SystemTime::now()),
483
- start.elapsed().as_secs_f64() * 1000.0
484
- );
485
- log.write_line(crate::style::status_colored(status, &line));
585
+ if let (Some(log), Some((start, method, target))) = (server.access_log.as_ref(), log_meta) {
586
+ // The worker attached its timing to the response head; a 503 or
587
+ // 504 never reached a worker and carries none.
588
+ let timing = response
589
+ .extensions()
590
+ .get::<crate::access_log::Timing>()
591
+ .copied();
592
+ log.write_line(crate::access_log::completion(
593
+ response.status().as_u16(),
594
+ &method,
595
+ &target,
596
+ start.elapsed(),
597
+ timing,
598
+ ));
486
599
  }
487
600
 
488
601
  Ok(branded(response))
@@ -559,7 +672,9 @@ pub fn register_worker(ruby: &Ruby, server_id: u64) -> Result<usize, Error> {
559
672
 
560
673
  pub fn stop_accepting(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
561
674
  if let Some(server) = registry::try_get(server_id) {
562
- server.state.store(registry::STATE_DRAINING, Ordering::Relaxed);
675
+ server
676
+ .state
677
+ .store(registry::STATE_DRAINING, Ordering::Relaxed);
563
678
  let _ = server.shutdown_tx.send(true);
564
679
  }
565
680
  Ok(())
@@ -648,8 +763,11 @@ pub fn interrupt_all_workers(_ruby: &Ruby, server_id: u64) -> Result<(), Error>
648
763
 
649
764
  pub fn shutdown_runtime(_ruby: &Ruby, server_id: u64, timeout_ms: u64) -> Result<(), Error> {
650
765
  if let Some(server) = registry::remove(server_id) {
651
- if let Some(runtime) = server.runtime.lock().take() {
652
- runtime.shutdown_timeout(Duration::from_millis(timeout_ms));
766
+ let _ = server.shutdown_tx.send(true);
767
+ std::mem::take(&mut *server.runtime.lock()).shutdown(Duration::from_millis(timeout_ms));
768
+ // The listener is closed with the runtime; its socket file is not.
769
+ if let Some(path) = &server.unix_path {
770
+ crate::listen::cleanup_unix(path);
653
771
  }
654
772
  }
655
773
  Ok(())
@@ -665,12 +783,6 @@ pub fn pin_keeper(
665
783
  Ok(ruby.obj_wrap(crate::pin::PinKeeper(server.pin_slab.clone())))
666
784
  }
667
785
 
668
- /// Errors print in red on color terminals. Covers worker errors,
669
- /// supervisor crash reports, and everything apps write to rack.errors.
670
- pub fn log_error(message: String) {
671
- eprintln!("{}", crate::style::red(&format!("[Kino] {message}")));
672
- }
673
-
674
786
  /// Full stats snapshot: [queued, in_flight, served, rejected, timeouts,
675
787
  /// respawns, lane_depths]. lane_depths is nil unless lane dispatch is on.
676
788
  #[allow(clippy::type_complexity)]
@@ -709,10 +821,7 @@ pub type WorkerStatRow = (usize, u64, usize, u64, bool);
709
821
 
710
822
  /// Per-slot rows for Server#stats parity: [index, served, in_flight,
711
823
  /// busy_ms, quarantined] each. Empty when the server is gone.
712
- pub fn worker_stats(
713
- _ruby: &Ruby,
714
- server_id: u64,
715
- ) -> Result<Vec<WorkerStatRow>, Error> {
824
+ pub fn worker_stats(_ruby: &Ruby, server_id: u64) -> Result<Vec<WorkerStatRow>, Error> {
716
825
  let Some(server) = registry::try_get(server_id) else {
717
826
  return Ok(Vec::new());
718
827
  };
@@ -734,7 +843,9 @@ pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<
734
843
  /// One replacement spawned by the quarantine monitor.
735
844
  pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
736
845
  if let Some(server) = registry::try_get(server_id) {
737
- server.quarantine_replacements.fetch_add(1, Ordering::Relaxed);
846
+ server
847
+ .quarantine_replacements
848
+ .fetch_add(1, Ordering::Relaxed);
738
849
  }
739
850
  Ok(())
740
851
  }
@@ -785,7 +896,10 @@ mod tests {
785
896
  server.register_worker();
786
897
  server.slots.read()[0].lane_tx.lock().take();
787
898
 
788
- assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Closed));
899
+ assert!(matches!(
900
+ try_dispatch(&server, test_ctx()),
901
+ Dispatch::Closed
902
+ ));
789
903
  }
790
904
 
791
905
  #[test]
@@ -828,9 +942,7 @@ mod tests {
828
942
  let server = test_server(true, 4);
829
943
  server.register_worker();
830
944
  server.register_worker();
831
- server.slots.read()[0]
832
- .parked
833
- .store(true, Ordering::Relaxed);
945
+ server.slots.read()[0].parked.store(true, Ordering::Relaxed);
834
946
 
835
947
  // Both dispatches land on the awake lane (slot 1), regardless of
836
948
  // where the rotating cursor starts.
@@ -1,18 +1,19 @@
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.
1
+ //! ANSI styling for what the native layer writes to the terminal: the
2
+ //! access log on stdout and server log lines on either stream. (The
3
+ //! Ruby-side startup banner has its own twin in Kino::CLI.) Color
4
+ //! capability is decided once per stream; every styled string resets at
5
+ //! its end so nothing bleeds.
6
6
 
7
7
  use std::sync::OnceLock;
8
8
 
9
9
  #[derive(Clone, Copy)]
10
- enum Stream {
10
+ pub enum Stream {
11
11
  Stdout,
12
12
  Stderr,
13
13
  }
14
14
 
15
- fn enabled(stream: Stream) -> bool {
15
+ /// Whether `stream` is a color terminal: a tty, no NO_COLOR, TERM not dumb.
16
+ pub fn enabled(stream: Stream) -> bool {
16
17
  use std::io::IsTerminal;
17
18
  static STDOUT: OnceLock<bool> = OnceLock::new();
18
19
  static STDERR: OnceLock<bool> = OnceLock::new();
@@ -27,55 +28,51 @@ fn enabled(stream: Stream) -> bool {
27
28
  }
28
29
  }
29
30
 
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) {
31
+ /// Wrap `text` in an SGR code ("1" bold, "91" bright red, "1;32" bold
32
+ /// green) when `color`, resetting at the end; plain otherwise.
33
+ pub fn sgr(code: &str, text: &str, color: bool) -> String {
34
+ if color {
34
35
  format!("\x1b[{code}m{text}\x1b[0m")
35
36
  } else {
36
37
  text.to_string()
37
38
  }
38
39
  }
39
40
 
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
- }
41
+ /// Dark gray: timestamps and the timing breakdown recede behind the record.
42
+ pub const DIM: &str = "90";
43
+ /// Bold bright white: the arrival line, which has no status to color by.
44
+ pub const BOLD_WHITE: &str = "1;97";
45
+ /// Warnings are yellow.
46
+ pub const WARN: &str = "33";
47
+ /// Errors are bright red (91): the base red slot (31) is remapped to odd
48
+ /// hues by some terminal themes; 91 stays red.
49
+ pub const ERROR: &str = "91";
45
50
 
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> {
51
+ /// The SGR code for a status class, bold so the record leads its line
52
+ /// (basic 16-color palette only): 2xx green, 3xx yellow, 4xx maroon
53
+ /// (ANSI color 1, plain dark red), 5xx bright red, anything else uncolored.
54
+ pub fn status_sgr(status: u16) -> Option<&'static str> {
50
55
  match status {
51
- 200..=299 => Some("32"),
52
- 300..=399 => Some("33"),
53
- 400..=499 => Some("31"),
54
- 500..=599 => Some("91"),
56
+ 200..=299 => Some("1;32"),
57
+ 300..=399 => Some("1;33"),
58
+ 400..=499 => Some("1;31"),
59
+ 500..=599 => Some("1;91"),
55
60
  _ => None,
56
61
  }
57
62
  }
58
63
 
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
64
  #[cfg(test)]
68
65
  mod tests {
69
- use super::status_sgr;
66
+ use super::{sgr, status_sgr};
70
67
 
71
68
  #[test]
72
69
  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"));
70
+ assert_eq!(status_sgr(200), Some("1;32")); // green
71
+ assert_eq!(status_sgr(299), Some("1;32"));
72
+ assert_eq!(status_sgr(301), Some("1;33")); // yellow
73
+ assert_eq!(status_sgr(404), Some("1;31")); // maroon
74
+ assert_eq!(status_sgr(500), Some("1;91")); // bright red
75
+ assert_eq!(status_sgr(599), Some("1;91"));
79
76
  }
80
77
 
81
78
  #[test]
@@ -84,4 +81,10 @@ mod tests {
84
81
  assert_eq!(status_sgr(199), None);
85
82
  assert_eq!(status_sgr(600), None);
86
83
  }
84
+
85
+ #[test]
86
+ fn sgr_wraps_and_resets_only_when_coloring() {
87
+ assert_eq!(sgr("1;32", "ok", true), "\x1b[1;32mok\x1b[0m");
88
+ assert_eq!(sgr("1;32", "ok", false), "ok");
89
+ }
87
90
  }
data/ext/kino/src/tls.rs CHANGED
@@ -71,7 +71,8 @@ WJ2lRijROyX9v7f8aSQlb6kEwKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAa
71
71
  #[test]
72
72
  fn missing_file_paths_error_without_panicking() {
73
73
  let err = build_acceptor("/nonexistent/cert.pem", "/nonexistent/key.pem")
74
- .err().expect("missing files");
74
+ .err()
75
+ .expect("missing files");
75
76
  assert!(err.contains("cannot read"));
76
77
  }
77
78
 
@@ -83,14 +84,20 @@ WJ2lRijROyX9v7f8aSQlb6kEwKhI8kG8SbeUc+zbKkzGgRXNaZHY/mAa
83
84
 
84
85
  #[test]
85
86
  fn pem_without_a_key_is_rejected() {
86
- let err = build_acceptor(CERT, CERT).err().expect("a cert is not a key");
87
+ let err = build_acceptor(CERT, CERT)
88
+ .err()
89
+ .expect("a cert is not a key");
87
90
  assert!(err.contains("no private key found"));
88
91
  }
89
92
 
90
93
  #[test]
91
94
  fn mismatched_cert_and_garbage_key_are_rejected() {
92
- let err = build_acceptor(CERT, "-----BEGIN PRIVATE KEY-----\ngarbage\n-----END PRIVATE KEY-----")
93
- .err().expect("garbage key");
95
+ let err = build_acceptor(
96
+ CERT,
97
+ "-----BEGIN PRIVATE KEY-----\ngarbage\n-----END PRIVATE KEY-----",
98
+ )
99
+ .err()
100
+ .expect("garbage key");
94
101
  assert!(!err.is_empty());
95
102
  }
96
103
  }