kino 0.5.0 → 0.7.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.
@@ -9,7 +9,8 @@ use std::time::Duration;
9
9
 
10
10
  use http_body_util::BodyExt;
11
11
  use hyper::service::service_fn;
12
- use hyper_util::rt::TokioIo;
12
+ use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
13
+ use hyper_util::server::conn::auto;
13
14
  use magnus::{Error, Ruby};
14
15
  use parking_lot::{Mutex, RwLock};
15
16
 
@@ -62,14 +63,20 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
62
63
  let tls_cert: Option<String> = cfg_opt(ruby, config, "tls_cert")?;
63
64
  let tls_key: Option<String> = cfg_opt(ruby, config, "tls_key")?;
64
65
  let lanes: bool = cfg_opt(ruby, config, "lanes")?.unwrap_or(false);
66
+ // Default true guards embedders calling the native layer directly.
67
+ let http2: bool = cfg_opt(ruby, config, "http2")?.unwrap_or(true);
65
68
  let log_requests: bool = cfg_opt(ruby, config, "log_requests")?.unwrap_or(false);
66
69
  let mode: String = cfg_opt(ruby, config, "mode")?.unwrap_or_else(|| "threaded".to_string());
67
70
  let workers: usize = cfg_opt(ruby, config, "workers")?.unwrap_or(0);
71
+ // Absent or below the floor means a fixed pool.
72
+ let max_workers: usize = cfg_opt::<usize>(ruby, config, "max_workers")?
73
+ .unwrap_or(workers)
74
+ .max(workers);
68
75
  let threads: usize = cfg_opt(ruby, config, "threads")?.unwrap_or(0);
69
76
  let batch: usize = cfg_opt(ruby, config, "batch")?.unwrap_or(1);
70
77
  let acceptor = match (&tls_cert, &tls_key) {
71
78
  (Some(cert), Some(key)) => Some(
72
- crate::tls::build_acceptor(cert, key)
79
+ crate::tls::build_acceptor(cert, key, http2)
73
80
  .map_err(|e| Error::new(ruby.exception_runtime_error(), format!("TLS: {e}")))?,
74
81
  ),
75
82
  (None, None) => None,
@@ -127,13 +134,18 @@ pub fn server_start(ruby: &Ruby, config: magnus::RHash) -> Result<(u64, u16, Opt
127
134
  state: std::sync::atomic::AtomicU8::new(registry::STATE_BOOTING),
128
135
  respawns: std::sync::atomic::AtomicU64::new(0),
129
136
  quarantine_replacements: std::sync::atomic::AtomicU64::new(0),
137
+ active_workers: std::sync::atomic::AtomicUsize::new(workers),
138
+ scale_ups: std::sync::atomic::AtomicU64::new(0),
139
+ scale_downs: std::sync::atomic::AtomicU64::new(0),
130
140
  topology: registry::Topology {
131
141
  mode,
132
142
  workers,
143
+ max_workers,
133
144
  threads,
134
145
  batch,
135
146
  },
136
147
  https: acceptor.is_some(),
148
+ http2,
137
149
  unix_path,
138
150
  access_log: log_requests.then(|| crate::logsink::Sink::new(std::io::stdout())),
139
151
  lanes,
@@ -340,9 +352,77 @@ pub(crate) async fn serve_conn(
340
352
  /// headers within this window. Long enough never to trip a real client (even
341
353
  /// on a slow mobile link), short enough to reap a stalled one. Deliberately a
342
354
  /// constant, not a config knob: fine-tuning intake limits is the fronting
343
- /// proxy's job; the actual hazard was having no default at all.
355
+ /// proxy's job; the actual hazard was having no default at all. Guards the
356
+ /// HTTP/1 side only: h2 intake is bounded by the TLS-handshake timeout and
357
+ /// the h2 codec's own SETTINGS handling.
344
358
  const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
345
359
 
360
+ /// The connection builder every data-plane connection is served with,
361
+ /// in both accept topologies. `http2: true` is the protocol-auto shape:
362
+ /// ALPN-negotiated h2 over TLS, prior-knowledge h2c on plaintext (the
363
+ /// builder sniffs the 24-byte preface once per connection), HTTP/1.x
364
+ /// for everything else. `http2: false` pins the HTTP/1 codec: no sniff,
365
+ /// same wire behavior as a server built without h2.
366
+ ///
367
+ /// No auto Date header on either protocol: it costs a clock read per
368
+ /// response (together with timer reads, ~7% of tokio-side cycles in the
369
+ /// profile); it's a SHOULD not a MUST, and apps that need it can set it
370
+ /// themselves.
371
+ ///
372
+ /// The http1 timer is installed so header_read_timeout actually fires:
373
+ /// hyper's slow-header guard is inert without one. It arms only while
374
+ /// the request head is being read, so it adds no per-response cost on
375
+ /// the hot path. The h2 side gets a timer too so its own timed
376
+ /// machinery (keep-alive, shutdown deadlines) can fire if ever enabled.
377
+ /// SETTINGS_MAX_CONCURRENT_STREAMS, derived from worker-slot capacity
378
+ /// (workers × threads) instead of hyper's flat 200. Two jobs: an
379
+ /// h2-aware balancer sees the server's real admission and spreads
380
+ /// streams across upstream connections instead of queueing blind, and a
381
+ /// hostile client can't multiply one connection into 200 queued
382
+ /// requests. The floor keeps tiny topologies browser-friendly (one
383
+ /// page's fetches still parallelize; excess streams just queue), the
384
+ /// cap bounds per-connection bookkeeping, and an unknown topology (an
385
+ /// embedder passing zeros) keeps hyper's default.
386
+ fn advertised_streams(workers: usize, threads: usize) -> u32 {
387
+ let slots = workers.saturating_mul(threads);
388
+ if slots == 0 {
389
+ return 200;
390
+ }
391
+ slots.clamp(8, 1024) as u32
392
+ }
393
+
394
+ /// The pool can grow to its ceiling to meet what an h2 balancer sends, so
395
+ /// the ceiling is the admission to advertise (the floor for a fixed pool).
396
+ fn stream_capacity(topology: &registry::Topology) -> u32 {
397
+ advertised_streams(topology.max_workers, topology.threads)
398
+ }
399
+
400
+ fn conn_builder(http2: bool, max_streams: u32) -> auto::Builder<TokioExecutor> {
401
+ let mut builder = auto::Builder::new(TokioExecutor::new());
402
+ builder
403
+ .http1()
404
+ .timer(TokioTimer::new())
405
+ .header_read_timeout(HEADER_READ_TIMEOUT)
406
+ .auto_date_header(false);
407
+ // Beyond the stream cap, hyper's h2 defaults (16 KB frames, 1 MB
408
+ // windows) stay: a knob sweep (frame size 64K/256K, adaptive
409
+ // windows, 4/8 MB windows) moved the upload lane nowhere or slightly
410
+ // down once read_body coalesced its channel drain: the crossings
411
+ // were the cost, not the codec. The codec's abuse bounds also ship
412
+ // as defaults: 16 KB header lists, 20 pending remote resets then
413
+ // GOAWAY (rapid reset), reset-churn and empty-frame budgets.
414
+ builder
415
+ .http2()
416
+ .timer(TokioTimer::new())
417
+ .auto_date_header(false)
418
+ .max_concurrent_streams(max_streams);
419
+ if http2 {
420
+ builder
421
+ } else {
422
+ builder.http1_only()
423
+ }
424
+ }
425
+
346
426
  async fn serve_connection<I>(
347
427
  io: I,
348
428
  server: Arc<ServerInner>,
@@ -351,21 +431,38 @@ async fn serve_connection<I>(
351
431
  ) where
352
432
  I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
353
433
  {
434
+ let http2 = server.http2;
435
+ let max_streams = stream_capacity(&server.topology);
436
+ let mut drain = server.shutdown_tx.subscribe();
354
437
  let service =
355
438
  service_fn(move |req| handle_request(server.clone(), remote_addr, local_addr, req));
356
- // No auto Date header: it costs a clock read per response (together
357
- // with timer reads, ~7% of tokio-side cycles in the profile); it's a
358
- // SHOULD not a MUST, and apps that need it can set it themselves.
359
- //
360
- // The timer is installed so header_read_timeout actually fires: hyper's
361
- // slow-header guard is inert without one. It arms only while the request
362
- // head is being read, so it adds no per-response cost on the hot path.
363
- let _ = hyper::server::conn::http1::Builder::new()
364
- .timer(hyper_util::rt::TokioTimer::new())
365
- .header_read_timeout(HEADER_READ_TIMEOUT)
366
- .auto_date_header(false)
367
- .serve_connection(TokioIo::new(io), service)
368
- .await;
439
+ let builder = conn_builder(http2, max_streams);
440
+ let conn = builder.serve_connection(TokioIo::new(io), service);
441
+ let mut conn = std::pin::pin!(conn);
442
+ // Serve until done, or switch to graceful shutdown when the drain
443
+ // signal fires (stop_accepting): the in-flight request finishes and
444
+ // the connection then closes (`Connection: close` on HTTP/1,
445
+ // GOAWAY on h2), so a balancer moves on instead of feeding a
446
+ // draining server, and teardown never cuts a response mid-stream.
447
+ // A connection that outlives the drain deadline is still cut by the
448
+ // runtime teardown, as before.
449
+ tokio::select! {
450
+ _ = conn.as_mut() => {}
451
+ _ = drain_signal(&mut drain) => {
452
+ conn.as_mut().graceful_shutdown();
453
+ let _ = conn.as_mut().await;
454
+ }
455
+ }
456
+ }
457
+
458
+ /// Resolve when the server starts draining. A connection accepted just
459
+ /// before the signal may subscribe just after it, and `changed()` alone
460
+ /// would miss that edge, so the current value is checked first; a
461
+ /// dropped sender (teardown) counts as draining too.
462
+ async fn drain_signal(rx: &mut tokio::sync::watch::Receiver<bool>) {
463
+ if !*rx.borrow_and_update() {
464
+ let _ = rx.changed().await;
465
+ }
369
466
  }
370
467
 
371
468
  /// The 503 every rejection path returns; counted for stats. Branding
@@ -437,18 +534,18 @@ async fn handle_request(
437
534
  let oversize =
438
535
  max_body > 0 && content_length(&parts.headers).is_some_and(|len| len > max_body as u64);
439
536
 
537
+ let (head_tx, head_rx) = tokio::sync::oneshot::channel();
538
+ let responder = Arc::new(Responder::new(head_tx));
539
+
440
540
  // Stream the request body through a bounded channel: hyper is polled
441
541
  // only as fast as the Ruby side consumes (inbound backpressure), and the
442
542
  // forwarder dropping the sender is EOF. Bodyless requests (most GETs)
443
- // skip the forwarder task entirely: dropping the sender IS the EOF.
444
- let (body_tx, body_rx) = flume::bounded::<bytes::Bytes>(8);
445
- let body_overflow = Arc::new(std::sync::atomic::AtomicBool::new(false));
446
- let body_timeout = Arc::new(std::sync::atomic::AtomicBool::new(false));
447
- if oversize || hyper::body::Body::is_end_stream(&body) {
448
- drop(body_tx);
543
+ // skip the channel and the forwarder task entirely: no channel IS the EOF.
544
+ let body_rx = if oversize || hyper::body::Body::is_end_stream(&body) {
545
+ None
449
546
  } else {
450
- let overflow = body_overflow.clone();
451
- let timed_out = body_timeout.clone();
547
+ let (body_tx, body_rx) = flume::bounded::<bytes::Bytes>(8);
548
+ let responder = responder.clone();
452
549
  tokio::spawn(async move {
453
550
  let mut body = body;
454
551
  let mut total: u64 = 0;
@@ -461,16 +558,19 @@ async fn handle_request(
461
558
  Ok(Some(Ok(frame))) => frame,
462
559
  Ok(Some(Err(_))) | Ok(None) => break, // body error or clean EOF
463
560
  Err(_) => {
464
- timed_out.store(true, Ordering::Relaxed);
561
+ responder.abandon_body(crate::response::BodyAbandon::TimedOut);
465
562
  break;
466
563
  }
467
564
  };
565
+ // Non-data frames (h2 trailers) are dropped by design:
566
+ // Rack has no trailer surface. DATA frames around them
567
+ // still forward, and hyper reports EOF right after.
468
568
  if let Ok(data) = frame.into_data() {
469
569
  total += data.len() as u64;
470
570
  if max_body > 0 && total > max_body as u64 {
471
571
  // Past the cap: flag it and stop pulling. Dropping the
472
572
  // sender unblocks read_body, which then raises.
473
- overflow.store(true, Ordering::Relaxed);
573
+ responder.abandon_body(crate::response::BodyAbandon::Oversize);
474
574
  break;
475
575
  }
476
576
  if body_tx.send_async(data).await.is_err() {
@@ -479,10 +579,9 @@ async fn handle_request(
479
579
  }
480
580
  }
481
581
  });
482
- }
582
+ Some(body_rx)
583
+ };
483
584
 
484
- let (head_tx, head_rx) = tokio::sync::oneshot::channel();
485
- let responder = Arc::new(Responder::new(head_tx));
486
585
  let now = std::time::Instant::now();
487
586
  let ctx = Box::new(RequestCtx {
488
587
  method: parts.method,
@@ -493,8 +592,6 @@ async fn handle_request(
493
592
  local_addr,
494
593
  https: server.https,
495
594
  body_rx,
496
- body_overflow,
497
- body_timeout,
498
595
  leftover: None,
499
596
  slot: None,
500
597
  pin_slab: server.pin_slab.clone(),
@@ -629,6 +726,11 @@ fn try_dispatch(server: &ServerInner, mut ctx: BoxedCtx) -> Dispatch {
629
726
  let guard = slot.lane_tx.lock();
630
727
  let Some(tx) = guard.as_ref() else { continue };
631
728
  any_open = true;
729
+ // Checked under the lane lock, which WorkerSlot::retire also
730
+ // takes: see there for the ordering.
731
+ if slot.retired.load(Ordering::SeqCst) {
732
+ continue;
733
+ }
632
734
  match tx.try_send(ctx) {
633
735
  Ok(()) => return Dispatch::Sent,
634
736
  Err(flume::TrySendError::Full(c)) | Err(flume::TrySendError::Disconnected(c)) => {
@@ -817,7 +919,7 @@ pub fn queue_time(_ruby: &Ruby, server_id: u64) -> Result<(u64, f64), Error> {
817
919
  }
818
920
 
819
921
  /// One worker slot's [index, served, in_flight, busy_ms, quarantined] row.
820
- pub type WorkerStatRow = (usize, u64, usize, u64, bool);
922
+ pub type WorkerStatRow = (usize, u64, usize, u64, bool, bool);
821
923
 
822
924
  /// Per-slot rows for Server#stats parity: [index, served, in_flight,
823
925
  /// busy_ms, quarantined] each. Empty when the server is gone.
@@ -827,7 +929,16 @@ pub fn worker_stats(_ruby: &Ruby, server_id: u64) -> Result<Vec<WorkerStatRow>,
827
929
  };
828
930
  Ok(crate::control::collect_worker_status(&server)
829
931
  .into_iter()
830
- .map(|w| (w.index, w.served, w.in_flight, w.busy_ms, w.quarantined))
932
+ .map(|w| {
933
+ (
934
+ w.index,
935
+ w.served,
936
+ w.in_flight,
937
+ w.busy_ms,
938
+ w.quarantined,
939
+ w.retired,
940
+ )
941
+ })
831
942
  .collect())
832
943
  }
833
944
 
@@ -840,6 +951,61 @@ pub fn quarantine_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<
840
951
  Ok(())
841
952
  }
842
953
 
954
+ /// Send a slot's worker home once idle (pool scale-down); see
955
+ /// registry::WorkerSlot::retire. An unknown id is a caller bug: raise.
956
+ pub fn retire_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
957
+ if let Some(server) = registry::try_get(server_id) {
958
+ server.slot(ruby, worker_id)?.retire();
959
+ }
960
+ Ok(())
961
+ }
962
+
963
+ /// Hand a retired slot to a new worker (pool scale-up reusing a slot).
964
+ pub fn reset_slot(ruby: &Ruby, server_id: u64, worker_id: usize) -> Result<(), Error> {
965
+ if let Some(server) = registry::try_get(server_id) {
966
+ server.slot(ruby, worker_id)?.reset();
967
+ }
968
+ Ok(())
969
+ }
970
+
971
+ /// The Ruby pool reports how many workers are alive and serving.
972
+ pub fn set_active_workers(_ruby: &Ruby, server_id: u64, count: usize) -> Result<(), Error> {
973
+ if let Some(server) = registry::try_get(server_id) {
974
+ server.active_workers.store(count, Ordering::Relaxed);
975
+ }
976
+ Ok(())
977
+ }
978
+
979
+ /// One worker added by the pool scaler.
980
+ pub fn record_scale_up(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
981
+ if let Some(server) = registry::try_get(server_id) {
982
+ server.scale_ups.fetch_add(1, Ordering::Relaxed);
983
+ }
984
+ Ok(())
985
+ }
986
+
987
+ /// One worker retired by the pool scaler.
988
+ pub fn record_scale_down(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
989
+ if let Some(server) = registry::try_get(server_id) {
990
+ server.scale_downs.fetch_add(1, Ordering::Relaxed);
991
+ }
992
+ Ok(())
993
+ }
994
+
995
+ /// [active_workers, max_workers, scale_ups, scale_downs] for Server#stats.
996
+ /// Zeros when the server is gone.
997
+ pub fn pool_stats(_ruby: &Ruby, server_id: u64) -> Result<(usize, usize, u64, u64), Error> {
998
+ let Some(server) = registry::try_get(server_id) else {
999
+ return Ok((0, 0, 0, 0));
1000
+ };
1001
+ Ok((
1002
+ server.active_workers.load(Ordering::Relaxed),
1003
+ server.topology.max_workers,
1004
+ server.scale_ups.load(Ordering::Relaxed),
1005
+ server.scale_downs.load(Ordering::Relaxed),
1006
+ ))
1007
+ }
1008
+
843
1009
  /// One replacement spawned by the quarantine monitor.
844
1010
  pub fn record_quarantine_replacement(_ruby: &Ruby, server_id: u64) -> Result<(), Error> {
845
1011
  if let Some(server) = registry::try_get(server_id) {
@@ -856,6 +1022,565 @@ mod tests {
856
1022
  use crate::registry::{test_server, LANE_DEPTH};
857
1023
  use crate::request::test_ctx;
858
1024
 
1025
+ /// Serve one real connection through the production `serve_connection`
1026
+ /// over an in-memory pipe, against a queue the test consumes itself (a
1027
+ /// stand-in for the Ruby worker). Returns the client end.
1028
+ fn spawn_conn(server: Arc<ServerInner>) -> tokio::io::DuplexStream {
1029
+ let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1030
+ tokio::spawn(serve_connection(
1031
+ server_io,
1032
+ server,
1033
+ "127.0.0.1:40000".parse().expect("static addr"),
1034
+ "127.0.0.1:9292".parse().expect("static addr"),
1035
+ ));
1036
+ client_io
1037
+ }
1038
+
1039
+ async fn take_ctx(server: &Arc<ServerInner>) -> BoxedCtx {
1040
+ server.req_rx.recv_async().await.expect("a queued request")
1041
+ }
1042
+
1043
+ /// Read the request body the way a worker does: leftover first, then
1044
+ /// the forwarder channel until the sender drops (EOF).
1045
+ async fn drain_body(ctx: &mut RequestCtx) -> Vec<u8> {
1046
+ let mut out = Vec::new();
1047
+ if let Some(leftover) = ctx.leftover.take() {
1048
+ out.extend_from_slice(&leftover);
1049
+ }
1050
+ if let Some(rx) = &ctx.body_rx {
1051
+ while let Ok(chunk) = rx.recv_async().await {
1052
+ out.extend_from_slice(&chunk);
1053
+ }
1054
+ }
1055
+ out
1056
+ }
1057
+
1058
+ #[tokio::test]
1059
+ async fn h2c_prior_knowledge_reaches_the_queue_as_http2() {
1060
+ use http_body_util::{BodyExt, Full};
1061
+
1062
+ let server = test_server(false, 4);
1063
+ let client_io = spawn_conn(server.clone());
1064
+
1065
+ let worker = tokio::spawn(async move {
1066
+ let ctx = take_ctx(&server).await;
1067
+ assert_eq!(ctx.version, http::Version::HTTP_2);
1068
+ // The :authority pseudo-header arrives in the URI, where
1069
+ // build_env picks it up; no Host header exists on h2.
1070
+ assert_eq!(
1071
+ ctx.uri.authority().map(|a| a.as_str()),
1072
+ Some("kino.test:8443")
1073
+ );
1074
+ assert!(ctx.headers.get(http::header::HOST).is_none());
1075
+ assert!(ctx.responder.send_response(plain_response(200, "ok\n")));
1076
+ });
1077
+
1078
+ let (mut sender, conn) = hyper::client::conn::http2::handshake(
1079
+ hyper_util::rt::TokioExecutor::new(),
1080
+ TokioIo::new(client_io),
1081
+ )
1082
+ .await
1083
+ .expect("h2c prior-knowledge handshake");
1084
+ tokio::spawn(conn);
1085
+ let request = hyper::Request::builder()
1086
+ .uri("http://kino.test:8443/")
1087
+ .body(Full::new(bytes::Bytes::new()))
1088
+ .expect("request");
1089
+ let response = sender.send_request(request).await.expect("h2 response");
1090
+ assert_eq!(response.status(), 200);
1091
+ assert_eq!(
1092
+ response.headers().get("server").expect("branded"),
1093
+ "Kino",
1094
+ "responses stay branded over h2"
1095
+ );
1096
+ let body = response.into_body().collect().await.expect("body");
1097
+ assert_eq!(&body.to_bytes()[..], b"ok\n");
1098
+ worker.await.expect("worker assertions");
1099
+ }
1100
+
1101
+ #[tokio::test]
1102
+ async fn h1_is_still_served_by_the_auto_builder() {
1103
+ use http_body_util::{BodyExt, Full};
1104
+
1105
+ let server = test_server(false, 4);
1106
+ let client_io = spawn_conn(server.clone());
1107
+
1108
+ let worker = tokio::spawn(async move {
1109
+ let ctx = take_ctx(&server).await;
1110
+ assert_eq!(ctx.version, http::Version::HTTP_11);
1111
+ assert!(ctx.responder.send_response(plain_response(200, "h1\n")));
1112
+ });
1113
+
1114
+ let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(client_io))
1115
+ .await
1116
+ .expect("h1 handshake");
1117
+ tokio::spawn(conn);
1118
+ let request = hyper::Request::builder()
1119
+ .uri("/")
1120
+ .header("host", "kino.test")
1121
+ .body(Full::new(bytes::Bytes::new()))
1122
+ .expect("request");
1123
+ let response = sender.send_request(request).await.expect("h1 response");
1124
+ assert_eq!(response.status(), 200);
1125
+ let body = response.into_body().collect().await.expect("body");
1126
+ assert_eq!(&body.to_bytes()[..], b"h1\n");
1127
+ worker.await.expect("worker assertions");
1128
+ }
1129
+
1130
+ #[tokio::test]
1131
+ async fn http2_off_refuses_the_preface_but_serves_h1() {
1132
+ use http_body_util::{BodyExt, Full};
1133
+ use hyper::service::service_fn;
1134
+
1135
+ // Driven at the builder seam: conn_builder(false) is what a
1136
+ // ServerInner with http2 off serves every connection with.
1137
+ let stub = || {
1138
+ service_fn(|_req: hyper::Request<hyper::body::Incoming>| async {
1139
+ Ok::<_, std::convert::Infallible>(plain_response(200, "pinned\n"))
1140
+ })
1141
+ };
1142
+
1143
+ // An h2 prior-knowledge client must fail: the pinned h1 codec
1144
+ // reads the preface as a malformed request line.
1145
+ let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1146
+ tokio::spawn(async move {
1147
+ let _ = conn_builder(false, 200)
1148
+ .serve_connection(TokioIo::new(server_io), stub())
1149
+ .await;
1150
+ });
1151
+ let refused = async {
1152
+ let (mut sender, conn) = hyper::client::conn::http2::handshake(
1153
+ hyper_util::rt::TokioExecutor::new(),
1154
+ TokioIo::new(client_io),
1155
+ )
1156
+ .await?;
1157
+ tokio::spawn(conn);
1158
+ let request = hyper::Request::builder()
1159
+ .uri("http://kino.test/")
1160
+ .body(Full::new(bytes::Bytes::new()))?;
1161
+ sender.send_request(request).await?;
1162
+ Ok::<_, Box<dyn std::error::Error>>(())
1163
+ }
1164
+ .await;
1165
+ assert!(refused.is_err(), "h2 must not be served when pinned to h1");
1166
+
1167
+ // The same pinned builder serves a plain h1 client.
1168
+ let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1169
+ tokio::spawn(async move {
1170
+ let _ = conn_builder(false, 200)
1171
+ .serve_connection(TokioIo::new(server_io), stub())
1172
+ .await;
1173
+ });
1174
+ let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(client_io))
1175
+ .await
1176
+ .expect("h1 handshake");
1177
+ tokio::spawn(conn);
1178
+ let request = hyper::Request::builder()
1179
+ .uri("/")
1180
+ .header("host", "kino.test")
1181
+ .body(Full::new(bytes::Bytes::new()))
1182
+ .expect("request");
1183
+ let response = sender.send_request(request).await.expect("h1 response");
1184
+ assert_eq!(response.status(), 200);
1185
+ let body = response.into_body().collect().await.expect("body");
1186
+ assert_eq!(&body.to_bytes()[..], b"pinned\n");
1187
+ }
1188
+
1189
+ #[tokio::test]
1190
+ async fn h2_upload_forwards_data_and_drops_trailers() {
1191
+ use http_body_util::{BodyExt, StreamBody};
1192
+ use hyper::body::Frame;
1193
+
1194
+ let server = test_server(false, 4);
1195
+ let client_io = spawn_conn(server.clone());
1196
+
1197
+ let worker = tokio::spawn(async move {
1198
+ let mut ctx = take_ctx(&server).await;
1199
+ assert_eq!(ctx.version, http::Version::HTTP_2);
1200
+ let body = drain_body(&mut ctx).await;
1201
+ assert_eq!(&body[..], b"hello world");
1202
+ assert!(
1203
+ ctx.responder.body_abandoned().is_none(),
1204
+ "a trailer frame must not abort the body read"
1205
+ );
1206
+ let response = hyper::Response::builder()
1207
+ .status(200)
1208
+ .body(crate::response::full_body(bytes::Bytes::from(
1209
+ body.len().to_string(),
1210
+ )))
1211
+ .expect("response");
1212
+ assert!(ctx.responder.send_response(response));
1213
+ });
1214
+
1215
+ let (frames_tx, frames_rx) =
1216
+ flume::bounded::<Result<Frame<bytes::Bytes>, std::io::Error>>(4);
1217
+ frames_tx
1218
+ .send(Ok(Frame::data(bytes::Bytes::from_static(b"hello "))))
1219
+ .expect("frame");
1220
+ frames_tx
1221
+ .send(Ok(Frame::data(bytes::Bytes::from_static(b"world"))))
1222
+ .expect("frame");
1223
+ let mut trailers = http::HeaderMap::new();
1224
+ trailers.insert("x-checksum", "ignored".parse().expect("value"));
1225
+ frames_tx
1226
+ .send(Ok(Frame::trailers(trailers)))
1227
+ .expect("frame");
1228
+ drop(frames_tx); // EOS
1229
+
1230
+ let (mut sender, conn) = hyper::client::conn::http2::handshake(
1231
+ hyper_util::rt::TokioExecutor::new(),
1232
+ TokioIo::new(client_io),
1233
+ )
1234
+ .await
1235
+ .expect("h2 handshake");
1236
+ tokio::spawn(conn);
1237
+ let request = hyper::Request::builder()
1238
+ .method("POST")
1239
+ .uri("http://kino.test/upload")
1240
+ .body(StreamBody::new(frames_rx.into_stream()))
1241
+ .expect("request");
1242
+ let response = sender.send_request(request).await.expect("h2 response");
1243
+ assert_eq!(response.status(), 200);
1244
+ let body = response.into_body().collect().await.expect("body");
1245
+ assert_eq!(&body.to_bytes()[..], b"11");
1246
+ worker.await.expect("worker assertions");
1247
+ }
1248
+
1249
+ #[tokio::test]
1250
+ async fn h2_streaming_response_arrives_chunked() {
1251
+ use http_body_util::{BodyExt, Full};
1252
+
1253
+ let server = test_server(false, 4);
1254
+ let client_io = spawn_conn(server.clone());
1255
+
1256
+ let worker = tokio::spawn(async move {
1257
+ let ctx = take_ctx(&server).await;
1258
+ let started = ctx
1259
+ .responder
1260
+ .send_stream_head(hyper::Response::builder().status(200))
1261
+ .expect("valid head");
1262
+ assert!(started);
1263
+ let frames = ctx.responder.body_sender().expect("open stream");
1264
+ for chunk in [&b"alpha "[..], &b"beta "[..], &b"gamma"[..]] {
1265
+ frames
1266
+ .send_async(Ok(hyper::body::Frame::data(bytes::Bytes::from_static(
1267
+ chunk,
1268
+ ))))
1269
+ .await
1270
+ .expect("chunk accepted");
1271
+ }
1272
+ ctx.responder.finish_stream();
1273
+ });
1274
+
1275
+ let (mut sender, conn) = hyper::client::conn::http2::handshake(
1276
+ hyper_util::rt::TokioExecutor::new(),
1277
+ TokioIo::new(client_io),
1278
+ )
1279
+ .await
1280
+ .expect("h2 handshake");
1281
+ tokio::spawn(conn);
1282
+ let request = hyper::Request::builder()
1283
+ .uri("http://kino.test/stream")
1284
+ .body(Full::new(bytes::Bytes::new()))
1285
+ .expect("request");
1286
+ let response = sender.send_request(request).await.expect("h2 response");
1287
+ assert_eq!(response.status(), 200);
1288
+ let body = response.into_body().collect().await.expect("body");
1289
+ assert_eq!(&body.to_bytes()[..], b"alpha beta gamma");
1290
+ worker.await.expect("worker assertions");
1291
+ }
1292
+
1293
+ #[tokio::test]
1294
+ async fn h2_multiplexes_concurrent_streams_into_the_queue() {
1295
+ use http_body_util::{BodyExt, Full};
1296
+
1297
+ let server = test_server(false, 4);
1298
+ let client_io = spawn_conn(server.clone());
1299
+
1300
+ // Both streams must be queued before either is answered: that is
1301
+ // multiplexing observable at the worker boundary; and answering
1302
+ // them in reverse order proves stream completion is not FIFO.
1303
+ let worker = tokio::spawn(async move {
1304
+ let first = take_ctx(&server).await;
1305
+ let second = take_ctx(&server).await;
1306
+ let order = [second.uri.path().to_string(), first.uri.path().to_string()];
1307
+ assert!(second.responder.send_response(plain_response(200, "two\n")));
1308
+ assert!(first.responder.send_response(plain_response(200, "one\n")));
1309
+ order
1310
+ });
1311
+
1312
+ let (mut sender, conn) = hyper::client::conn::http2::handshake(
1313
+ hyper_util::rt::TokioExecutor::new(),
1314
+ TokioIo::new(client_io),
1315
+ )
1316
+ .await
1317
+ .expect("h2 handshake");
1318
+ tokio::spawn(conn);
1319
+ let req = |path: &str| {
1320
+ hyper::Request::builder()
1321
+ .uri(format!("http://kino.test{path}"))
1322
+ .body(Full::new(bytes::Bytes::new()))
1323
+ .expect("request")
1324
+ };
1325
+ let (one, two) = tokio::join!(
1326
+ sender.send_request(req("/one")),
1327
+ sender.send_request(req("/two"))
1328
+ );
1329
+ let one = one.expect("first stream");
1330
+ let two = two.expect("second stream");
1331
+ assert_eq!(one.status(), 200);
1332
+ assert_eq!(two.status(), 200);
1333
+ let one = one.into_body().collect().await.expect("body").to_bytes();
1334
+ let two = two.into_body().collect().await.expect("body").to_bytes();
1335
+ assert_eq!(&one[..], b"one\n");
1336
+ assert_eq!(&two[..], b"two\n");
1337
+ let order = worker.await.expect("worker assertions");
1338
+ assert_eq!(order, ["/two".to_string(), "/one".to_string()]);
1339
+ }
1340
+
1341
+ #[tokio::test]
1342
+ async fn drain_finishes_the_in_flight_h2_stream_then_goaways() {
1343
+ use http_body_util::{BodyExt, Full};
1344
+
1345
+ let server = test_server(false, 4);
1346
+ let client_io = spawn_conn(server.clone());
1347
+
1348
+ let (sender, conn) = hyper::client::conn::http2::handshake(
1349
+ hyper_util::rt::TokioExecutor::new(),
1350
+ TokioIo::new(client_io),
1351
+ )
1352
+ .await
1353
+ .expect("h2 handshake");
1354
+ tokio::spawn(conn);
1355
+
1356
+ let mut in_flight_sender = sender.clone();
1357
+ let in_flight = tokio::spawn(async move {
1358
+ let request = hyper::Request::builder()
1359
+ .uri("http://kino.test/inflight")
1360
+ .body(Full::new(bytes::Bytes::new()))
1361
+ .expect("request");
1362
+ in_flight_sender.send_request(request).await
1363
+ });
1364
+
1365
+ // The request is with the worker when the drain fires; its
1366
+ // response must still reach the client through the shutdown.
1367
+ let ctx = take_ctx(&server).await;
1368
+ let _ = server.shutdown_tx.send(true);
1369
+ tokio::time::sleep(Duration::from_millis(20)).await;
1370
+ assert!(ctx.responder.send_response(plain_response(200, "late\n")));
1371
+
1372
+ let response = in_flight.await.expect("join").expect("in-flight served");
1373
+ assert_eq!(response.status(), 200);
1374
+ let body = response.into_body().collect().await.expect("body");
1375
+ assert_eq!(&body.to_bytes()[..], b"late\n");
1376
+
1377
+ // The connection is now GOAWAY'd: a new stream must fail.
1378
+ let mut sender = sender;
1379
+ let request = hyper::Request::builder()
1380
+ .uri("http://kino.test/after")
1381
+ .body(Full::new(bytes::Bytes::new()))
1382
+ .expect("request");
1383
+ assert!(
1384
+ sender.send_request(request).await.is_err(),
1385
+ "a drained connection must not accept new streams"
1386
+ );
1387
+ }
1388
+
1389
+ #[tokio::test]
1390
+ async fn drain_closes_an_h1_connection_after_its_response() {
1391
+ use http_body_util::Full;
1392
+
1393
+ let server = test_server(false, 4);
1394
+ let client_io = spawn_conn(server.clone());
1395
+
1396
+ let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(client_io))
1397
+ .await
1398
+ .expect("h1 handshake");
1399
+ tokio::spawn(conn);
1400
+
1401
+ // h1's SendRequest is not Clone: one task drives both requests;
1402
+ // the in-flight one, then (once it completed, so the drain has
1403
+ // been seen) the keep-alive follow-up that must be refused.
1404
+ let req = |path: &str| {
1405
+ hyper::Request::builder()
1406
+ .uri(path.to_string())
1407
+ .header("host", "kino.test")
1408
+ .body(Full::new(bytes::Bytes::new()))
1409
+ .expect("request")
1410
+ };
1411
+ let client = tokio::spawn(async move {
1412
+ let first = sender.send_request(req("/inflight")).await;
1413
+ let second_failed = sender.send_request(req("/after")).await.is_err();
1414
+ (first, second_failed)
1415
+ });
1416
+
1417
+ let ctx = take_ctx(&server).await;
1418
+ let _ = server.shutdown_tx.send(true);
1419
+ tokio::time::sleep(Duration::from_millis(20)).await;
1420
+ assert!(ctx.responder.send_response(plain_response(200, "late\n")));
1421
+
1422
+ let (first, second_failed) = client.await.expect("join");
1423
+ assert_eq!(first.expect("in-flight served").status(), 200);
1424
+ assert!(
1425
+ second_failed,
1426
+ "a drained keep-alive connection must close after its response"
1427
+ );
1428
+ }
1429
+
1430
+ #[test]
1431
+ fn advertised_streams_tracks_slot_capacity() {
1432
+ // Slot capacity, floored for tiny topologies and capped.
1433
+ assert_eq!(advertised_streams(8, 3), 24);
1434
+ assert_eq!(advertised_streams(2, 1), 8, "floor");
1435
+ assert_eq!(advertised_streams(64, 32), 1024, "cap");
1436
+ // Unknown topology (embedder passing zeros): hyper's default.
1437
+ assert_eq!(advertised_streams(0, 0), 200);
1438
+ assert_eq!(advertised_streams(8, 0), 200);
1439
+ }
1440
+
1441
+ #[test]
1442
+ fn stream_capacity_is_advertised_from_the_pool_ceiling() {
1443
+ // An elastic pool grows to meet load an h2 balancer sends, so the
1444
+ // ceiling, not the floor, is the admission to advertise.
1445
+ let topology = registry::Topology {
1446
+ mode: "ractor".to_string(),
1447
+ workers: 2,
1448
+ max_workers: 8,
1449
+ threads: 3,
1450
+ batch: 1,
1451
+ };
1452
+ assert_eq!(stream_capacity(&topology), 24);
1453
+ }
1454
+
1455
+ #[test]
1456
+ fn stream_capacity_of_a_fixed_pool_is_its_slot_count() {
1457
+ let topology = registry::Topology {
1458
+ mode: "threaded".to_string(),
1459
+ workers: 4,
1460
+ max_workers: 4,
1461
+ threads: 3,
1462
+ batch: 1,
1463
+ };
1464
+ assert_eq!(stream_capacity(&topology), 12);
1465
+ }
1466
+
1467
+ #[tokio::test]
1468
+ async fn streams_beyond_the_advertised_cap_queue_instead_of_failing() {
1469
+ use http_body_util::Full;
1470
+ use hyper::service::service_fn;
1471
+
1472
+ // Cap of 2: six concurrent requests must all complete; the h2
1473
+ // client holds excess streams locally until the server frees a
1474
+ // slot; nothing is refused or reset.
1475
+ let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1476
+ tokio::spawn(async move {
1477
+ let stub = service_fn(|_req: hyper::Request<hyper::body::Incoming>| async {
1478
+ Ok::<_, std::convert::Infallible>(plain_response(200, "capped\n"))
1479
+ });
1480
+ let _ = conn_builder(true, 2)
1481
+ .serve_connection(TokioIo::new(server_io), stub)
1482
+ .await;
1483
+ });
1484
+ let (mut sender, conn) = hyper::client::conn::http2::handshake(
1485
+ hyper_util::rt::TokioExecutor::new(),
1486
+ TokioIo::new(client_io),
1487
+ )
1488
+ .await
1489
+ .expect("h2 handshake");
1490
+ tokio::spawn(conn);
1491
+ let mut requests = Vec::new();
1492
+ for i in 0..6 {
1493
+ let request = hyper::Request::builder()
1494
+ .uri(format!("http://kino.test/{i}"))
1495
+ .body(Full::new(bytes::Bytes::new()))
1496
+ .expect("request");
1497
+ requests.push(sender.send_request(request));
1498
+ }
1499
+ for request in requests {
1500
+ let response = request.await.expect("queued stream served");
1501
+ assert_eq!(response.status(), 200);
1502
+ }
1503
+ }
1504
+
1505
+ #[tokio::test]
1506
+ async fn rapid_stream_resets_do_not_wedge_the_pipeline() {
1507
+ use http_body_util::{BodyExt, Full};
1508
+
1509
+ let server = test_server(false, 64);
1510
+ let client_io = spawn_conn(server.clone());
1511
+
1512
+ // A worker that answers everything it sees until told to stop;
1513
+ // answers to already-reset streams just vanish, as in production.
1514
+ let worker = tokio::spawn(async move {
1515
+ loop {
1516
+ let Ok(ctx) = server.req_rx.recv_async().await else {
1517
+ break;
1518
+ };
1519
+ let done = ctx.uri.path() == "/done";
1520
+ ctx.responder.send_response(plain_response(200, "ok\n"));
1521
+ if done {
1522
+ break;
1523
+ }
1524
+ }
1525
+ });
1526
+
1527
+ let (mut sender, conn) = hyper::client::conn::http2::handshake(
1528
+ hyper_util::rt::TokioExecutor::new(),
1529
+ TokioIo::new(client_io),
1530
+ )
1531
+ .await
1532
+ .expect("h2 handshake");
1533
+ tokio::spawn(conn);
1534
+
1535
+ // Fire-and-cancel: dropping the response future resets the
1536
+ // stream. The codec bounds reset churn; the server must keep
1537
+ // serving afterwards.
1538
+ for i in 0..40 {
1539
+ let request = hyper::Request::builder()
1540
+ .uri(format!("http://kino.test/cancel/{i}"))
1541
+ .body(Full::new(bytes::Bytes::new()))
1542
+ .expect("request");
1543
+ drop(sender.send_request(request));
1544
+ }
1545
+ let request = hyper::Request::builder()
1546
+ .uri("http://kino.test/done")
1547
+ .body(Full::new(bytes::Bytes::new()))
1548
+ .expect("request");
1549
+ let response = sender.send_request(request).await.expect("still served");
1550
+ assert_eq!(response.status(), 200);
1551
+ let body = response.into_body().collect().await.expect("body");
1552
+ assert_eq!(&body.to_bytes()[..], b"ok\n");
1553
+ worker.await.expect("worker loop");
1554
+ }
1555
+
1556
+ #[tokio::test(start_paused = true)]
1557
+ async fn header_read_timeout_still_fires_through_the_auto_builder() {
1558
+ use hyper::service::service_fn;
1559
+ use tokio::io::AsyncWriteExt;
1560
+
1561
+ let (mut client_io, server_io) = tokio::io::duplex(64 * 1024);
1562
+ let conn = tokio::spawn(async move {
1563
+ let _ = conn_builder(true, 200)
1564
+ .serve_connection(
1565
+ TokioIo::new(server_io),
1566
+ service_fn(|_req: hyper::Request<hyper::body::Incoming>| async {
1567
+ Ok::<_, std::convert::Infallible>(plain_response(200, "never\n"))
1568
+ }),
1569
+ )
1570
+ .await;
1571
+ });
1572
+ // A partial h1 request line, then silence: the slow-header guard
1573
+ // must reap the connection (paused clock auto-advances past 15s).
1574
+ client_io
1575
+ .write_all(b"GET / HT")
1576
+ .await
1577
+ .expect("partial write");
1578
+ tokio::time::timeout(Duration::from_secs(60), conn)
1579
+ .await
1580
+ .expect("connection reaped by header_read_timeout")
1581
+ .expect("serve task join");
1582
+ }
1583
+
859
1584
  #[test]
860
1585
  fn dispatch_with_no_slots_reports_full() {
861
1586
  let server = test_server(true, 4);
@@ -950,4 +1675,110 @@ mod tests {
950
1675
  assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
951
1676
  assert_eq!(server.lane_depths(), Some(vec![0, 2]));
952
1677
  }
1678
+
1679
+ #[test]
1680
+ fn dispatch_skips_retired_lanes() {
1681
+ let server = test_server(true, 4);
1682
+ server.register_worker();
1683
+ server.register_worker();
1684
+ server.slots.read()[0].retire();
1685
+
1686
+ // A retiring worker gets nothing new: both land on slot 1.
1687
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1688
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1689
+ assert_eq!(server.lane_depths(), Some(vec![0, 2]));
1690
+ }
1691
+
1692
+ #[test]
1693
+ fn dispatch_retries_rather_than_closing_when_only_retired_lanes_remain() {
1694
+ let server = test_server(true, 4);
1695
+ server.register_worker();
1696
+ server.slots.read()[0].retire();
1697
+
1698
+ // Retired is not draining: the caller keeps retrying until the
1699
+ // queue timeout, so a replacement worker can still pick this up.
1700
+ assert!(matches!(
1701
+ try_dispatch(&server, test_ctx()),
1702
+ Dispatch::Full(_)
1703
+ ));
1704
+ }
1705
+
1706
+ #[test]
1707
+ fn dispatch_skips_a_retired_lane_even_when_it_is_the_only_awake_one() {
1708
+ let server = test_server(true, 4);
1709
+ server.register_worker();
1710
+ server.register_worker();
1711
+ server.slots.read()[0].retire();
1712
+ server.slots.read()[1].parked.store(true, Ordering::Relaxed);
1713
+
1714
+ // The second pass (parked lanes allowed) still skips the retired one.
1715
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1716
+ assert_eq!(server.lane_depths(), Some(vec![0, 1]));
1717
+ }
1718
+
1719
+ #[test]
1720
+ fn dispatch_keeps_retrying_when_the_other_lanes_are_closed() {
1721
+ let server = test_server(true, 4);
1722
+ server.register_worker();
1723
+ server.register_worker();
1724
+ server.slots.read()[0].lane_tx.lock().take(); // a crashed worker's lane
1725
+ server.slots.read()[1].retire();
1726
+
1727
+ assert!(matches!(
1728
+ try_dispatch(&server, test_ctx()),
1729
+ Dispatch::Full(_)
1730
+ ));
1731
+ }
1732
+
1733
+ #[test]
1734
+ fn a_reset_lane_receives_dispatches_again() {
1735
+ let server = test_server(true, 4);
1736
+ server.register_worker();
1737
+ server.register_worker();
1738
+ server.slots.read()[0].retire();
1739
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1740
+ assert_eq!(server.lane_depths(), Some(vec![0, 1]));
1741
+
1742
+ server.slots.read()[0].reset();
1743
+ for _ in 0..2 {
1744
+ assert!(matches!(try_dispatch(&server, test_ctx()), Dispatch::Sent));
1745
+ }
1746
+ // Back in the round-robin.
1747
+ assert_eq!(server.lane_depths(), Some(vec![1, 2]));
1748
+ }
1749
+
1750
+ #[test]
1751
+ fn nothing_lands_in_a_lane_after_its_retirement_and_final_drain() {
1752
+ let server = test_server(true, 4);
1753
+ server.register_worker();
1754
+ server.register_worker();
1755
+ let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1756
+ let dispatcher = std::thread::spawn({
1757
+ let server = server.clone();
1758
+ let stop = stop.clone();
1759
+ move || {
1760
+ while !stop.load(Ordering::Relaxed) {
1761
+ let _ = try_dispatch(&server, test_ctx());
1762
+ // Lane 1 keeps draining, so dispatch never stalls on Full.
1763
+ let slots = server.slots.read();
1764
+ if let Some(rx) = slots[1].lane_rx.as_ref() {
1765
+ while rx.try_recv().is_ok() {}
1766
+ }
1767
+ }
1768
+ }
1769
+ });
1770
+ std::thread::sleep(Duration::from_millis(20));
1771
+
1772
+ // The scaler retires slot 0; its worker then drains what it holds.
1773
+ let slot = server.slots.read()[0].clone();
1774
+ slot.retire();
1775
+ if let Some(rx) = slot.lane_rx.as_ref() {
1776
+ while rx.try_recv().is_ok() {}
1777
+ }
1778
+
1779
+ std::thread::sleep(Duration::from_millis(50));
1780
+ stop.store(true, Ordering::Relaxed);
1781
+ dispatcher.join().expect("dispatcher thread");
1782
+ assert_eq!(server.lane_depths(), Some(vec![0, 0]));
1783
+ }
953
1784
  }