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.
@@ -24,14 +24,10 @@ pub struct RequestCtx {
24
24
  pub local_addr: SocketAddr,
25
25
  pub https: bool,
26
26
  /// Request body, streamed from hyper through a bounded channel: hyper is
27
- /// only polled as Ruby consumes, so inbound backpressure is free.
28
- pub body_rx: flume::Receiver<Bytes>,
29
- /// Set by the body forwarder when the body exceeded max_body_size: turns
30
- /// the next read into an error instead of a (truncated) clean EOF.
31
- pub body_overflow: Arc<std::sync::atomic::AtomicBool>,
32
- /// Set by the body forwarder when the client stalled past the idle
33
- /// deadline: the next read raises so the worker reclaims its slot.
34
- pub body_timeout: Arc<std::sync::atomic::AtomicBool>,
27
+ /// only polled as Ruby consumes, so inbound backpressure is free. None
28
+ /// when no byte can ever arrive (most GETs): bodyless requests skip the
29
+ /// channel allocation entirely.
30
+ pub body_rx: Option<flume::Receiver<Bytes>>,
35
31
  /// When a frame is bigger than read_body's max_len, the rest waits here.
36
32
  pub leftover: Option<Bytes>,
37
33
  /// The owning worker slot (set at admit time, queue.rs); its interrupt
@@ -142,7 +138,10 @@ pub fn build_env(ruby: &Ruby, ctx: &RequestCtx) -> Result<RHash, Error> {
142
138
  use crate::env_strings;
143
139
 
144
140
  let s = env_strings::get();
145
- let env = ruby.hash_new();
141
+ // Pre-sized: a dozen static keys plus one per header lands near 30
142
+ // entries, which a default Hash reaches only via the ar-to-st
143
+ // migration and a rehash per doubling.
144
+ let env = ruby.hash_new_capa(32);
146
145
  // Opaque -> live RString for this thread's Ruby handle.
147
146
  let live = |o: magnus::value::Opaque<RString>| ruby.get_inner(o);
148
147
 
@@ -163,6 +162,7 @@ pub fn build_env(ruby: &Ruby, ctx: &RequestCtx) -> Result<RHash, Error> {
163
162
  env.aset(live(s.query_string), query)?;
164
163
  let protocol = match ctx.version {
165
164
  http::Version::HTTP_10 => s.http10,
165
+ http::Version::HTTP_2 => s.http2,
166
166
  _ => s.http11,
167
167
  };
168
168
  env.aset(live(s.server_protocol), live(protocol))?;
@@ -183,37 +183,25 @@ pub fn build_env(ruby: &Ruby, ctx: &RequestCtx) -> Result<RHash, Error> {
183
183
  }
184
184
  }
185
185
 
186
- // SERVER_NAME/PORT: prefer the Host header (HTTP/1.1 requires it),
187
- // fall back to the accepted socket's local address.
188
- let host_header = ctx.headers.get(http::header::HOST);
189
- match host_header {
190
- Some(host) => {
191
- let local_port = ctx.local_addr.port();
192
- env_strings::set_host_env(ruby, env, host.as_bytes(), || {
193
- match std::str::from_utf8(host.as_bytes()) {
194
- Ok(h) => split_host_port(h, local_port),
195
- Err(_) => (
196
- String::from_utf8_lossy(host.as_bytes()).into_owned(),
197
- local_port,
198
- ),
199
- }
200
- })?;
201
- }
202
- None => {
203
- let ip = ctx.local_addr.ip();
204
- let port = ctx.local_addr.port();
205
- env_strings::set_host_env(ruby, env, format!("\0{ip}:{port}").as_bytes(), || {
206
- (ip.to_string(), port)
207
- })?;
208
- }
209
- }
210
-
211
186
  for name in ctx.headers.keys() {
212
- // Single-value fast path (the overwhelming majority).
187
+ let key = if *name == http::header::CONTENT_TYPE {
188
+ live(s.content_type)
189
+ } else if *name == http::header::CONTENT_LENGTH {
190
+ live(s.content_length)
191
+ } else {
192
+ match s.header_names.get(name.as_str()) {
193
+ Some(cached) => live(*cached),
194
+ None => ruby.str_new(&env_strings::cgi_name(name.as_str())),
195
+ }
196
+ };
197
+
198
+ // Single-value fast path (the overwhelming majority); the value
199
+ // cache turns low-cardinality repeats (UA, accept-*, sec-ch-*)
200
+ // into a lookup of one frozen string instead of an allocation.
213
201
  let mut values = ctx.headers.get_all(name).iter();
214
202
  let first = values.next().map(|v| v.as_bytes()).unwrap_or(b"");
215
- let value = match values.next() {
216
- None => ruby.str_from_slice(first),
203
+ match values.next() {
204
+ None => env_strings::set_value_env(ruby, env, key, name.as_str(), first)?,
217
205
  Some(second) => {
218
206
  let sep: &[u8] = if name == http::header::COOKIE {
219
207
  b"; "
@@ -230,30 +218,63 @@ pub fn build_env(ruby: &Ruby, ctx: &RequestCtx) -> Result<RHash, Error> {
230
218
  joined.extend_from_slice(sep);
231
219
  joined.extend_from_slice(v.as_bytes());
232
220
  }
233
- ruby.str_from_slice(&joined)
221
+ env.aset(key, ruby.str_from_slice(&joined))?;
234
222
  }
235
- };
223
+ }
224
+ }
236
225
 
237
- let key = if *name == http::header::CONTENT_TYPE {
238
- live(s.content_type)
239
- } else if *name == http::header::CONTENT_LENGTH {
240
- live(s.content_length)
241
- } else {
242
- match s.header_names.get(name.as_str()) {
243
- Some(cached) => live(*cached),
244
- None => ruby.str_new(&env_strings::cgi_name(name.as_str())),
226
+ // SERVER_NAME/SERVER_PORT/HTTP_HOST, best source first: the request
227
+ // URI's authority (every h2 request via :authority; also h1
228
+ // absolute-form, where it beats Host per RFC 9112), then the Host
229
+ // header (HTTP/1.1 requires it), then the accepted socket's local
230
+ // address. Runs after the header loop so an authority-derived
231
+ // HTTP_HOST wins over a literal host header a proxy forwarded.
232
+ if let Some(authority) = ctx.uri.authority() {
233
+ let local_port = ctx.local_addr.port();
234
+ env_strings::set_authority_env(ruby, env, authority.as_str(), || {
235
+ (
236
+ authority.host().to_string(),
237
+ authority.port_u16().unwrap_or(local_port),
238
+ )
239
+ })?;
240
+ } else {
241
+ match ctx.headers.get(http::header::HOST) {
242
+ Some(host) => {
243
+ let local_port = ctx.local_addr.port();
244
+ env_strings::set_host_env(
245
+ ruby,
246
+ env,
247
+ host.as_bytes(),
248
+ || match std::str::from_utf8(host.as_bytes()) {
249
+ Ok(h) => split_host_port(h, local_port),
250
+ Err(_) => (
251
+ String::from_utf8_lossy(host.as_bytes()).into_owned(),
252
+ local_port,
253
+ ),
254
+ },
255
+ )?;
245
256
  }
246
- };
247
- env.aset(key, value)?;
257
+ None => {
258
+ let ip = ctx.local_addr.ip();
259
+ let port = ctx.local_addr.port();
260
+ env_strings::set_host_env(ruby, env, format!("\0{ip}:{port}").as_bytes(), || {
261
+ (ip.to_string(), port)
262
+ })?;
263
+ }
264
+ }
248
265
  }
249
266
 
250
267
  Ok(env)
251
268
  }
252
269
 
253
- /// Bodyless requests get their channel sender dropped at accept time, so
254
- /// "disconnected and empty" means no byte can ever arrive.
270
+ /// Bodyless requests carry no channel at all; a present channel that is
271
+ /// disconnected and empty (EOF already reached) also has nothing left.
255
272
  fn body_possible(ctx: &RequestCtx) -> bool {
256
- ctx.leftover.is_some() || !(ctx.body_rx.is_empty() && ctx.body_rx.is_disconnected())
273
+ ctx.leftover.is_some()
274
+ || ctx
275
+ .body_rx
276
+ .as_ref()
277
+ .is_some_and(|rx| !(rx.is_empty() && rx.is_disconnected()))
257
278
  }
258
279
 
259
280
  /// Complete response in one shot: status, the Rack headers Hash (iterated
@@ -307,7 +328,9 @@ impl Request {
307
328
  let mut chunk = match ctx.leftover.take() {
308
329
  Some(bytes) => bytes,
309
330
  None => {
310
- let body_rx = ctx.body_rx.clone();
331
+ let Some(body_rx) = ctx.body_rx.clone() else {
332
+ return Ok(None); // bodyless request: EOF from the start
333
+ };
311
334
  let outcome = block_on(&ctx.slot, || {
312
335
  match body_rx.recv_timeout(crate::queue::TICK) {
313
336
  Ok(bytes) => Some(Some(bytes)),
@@ -320,13 +343,12 @@ impl Request {
320
343
  Some(None) => {
321
344
  // Disconnected: a clean EOF, unless the forwarder
322
345
  // abandoned the body (too large, or the client stalled).
323
- if ctx.body_overflow.load(std::sync::atomic::Ordering::Relaxed) {
324
- return Err(body_too_large_error(ruby));
325
- }
326
- if ctx.body_timeout.load(std::sync::atomic::Ordering::Relaxed) {
327
- return Err(body_timeout_error(ruby));
328
- }
329
- return Ok(None); // EOF
346
+ use crate::response::BodyAbandon;
347
+ return match ctx.responder.body_abandoned() {
348
+ Some(BodyAbandon::Oversize) => Err(body_too_large_error(ruby)),
349
+ Some(BodyAbandon::TimedOut) => Err(body_timeout_error(ruby)),
350
+ None => Ok(None), // EOF
351
+ };
330
352
  }
331
353
  None => return Err(interrupted_error(ruby)),
332
354
  }
@@ -335,8 +357,39 @@ impl Request {
335
357
 
336
358
  if chunk.len() > max_len {
337
359
  ctx.leftover = Some(chunk.split_off(max_len));
360
+ return Ok(Some(ruby.str_from_slice(&chunk)));
338
361
  }
339
- Ok(Some(ruby.str_from_slice(&chunk)))
362
+
363
+ // Greedy drain: whatever the forwarder has already queued rides
364
+ // out in this same crossing, up to max_len. A body arriving as
365
+ // small DATA frames (HTTP/2 caps them at 16 KB unless negotiated
366
+ // higher) then costs one GVL round-trip and one Ruby string, not
367
+ // one per frame. Non-blocking: a chunk that has not arrived yet
368
+ // is next call's business, as are EOF and abandonment (try_recv's
369
+ // Disconnected included; the recv above maps them to errors).
370
+ let mut assembled: Option<RString> = None;
371
+ if let Some(body_rx) = ctx.body_rx.clone() {
372
+ let mut total = chunk.len();
373
+ while total < max_len {
374
+ let Ok(mut more) = body_rx.try_recv() else {
375
+ break;
376
+ };
377
+ if more.len() > max_len - total {
378
+ ctx.leftover = Some(more.split_off(max_len - total));
379
+ }
380
+ total += more.len();
381
+ let out = *assembled.get_or_insert_with(|| {
382
+ let out = ruby.str_buf_new(max_len);
383
+ out.cat(&chunk[..]);
384
+ out
385
+ });
386
+ out.cat(&more[..]);
387
+ }
388
+ }
389
+ Ok(Some(match assembled {
390
+ Some(out) => out,
391
+ None => ruby.str_from_slice(&chunk),
392
+ }))
340
393
  }
341
394
 
342
395
  /// Start a streaming response: head goes out now, chunks follow via
@@ -439,7 +492,7 @@ fn coerce_str(value: Value) -> Result<RString, Error> {
439
492
  }
440
493
  }
441
494
 
442
- fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
495
+ pub(crate) fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
443
496
  match host.rsplit_once(':') {
444
497
  Some((name, port)) if !name.is_empty() => match port.parse() {
445
498
  Ok(p) => (name.to_string(), p),
@@ -453,7 +506,6 @@ fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
453
506
  /// fires the Drop-500 backstop into a dropped receiver, which is a no-op.
454
507
  #[cfg(test)]
455
508
  pub fn test_ctx() -> crate::registry::BoxedCtx {
456
- let (_body_tx, body_rx) = flume::bounded(1);
457
509
  let (head_tx, _head_rx) = tokio::sync::oneshot::channel();
458
510
  Box::new(RequestCtx {
459
511
  method: http::Method::GET,
@@ -463,9 +515,7 @@ pub fn test_ctx() -> crate::registry::BoxedCtx {
463
515
  remote_addr: "127.0.0.1:40000".parse().expect("static addr"),
464
516
  local_addr: "127.0.0.1:9292".parse().expect("static addr"),
465
517
  https: false,
466
- body_rx,
467
- body_overflow: Arc::new(std::sync::atomic::AtomicBool::new(false)),
468
- body_timeout: Arc::new(std::sync::atomic::AtomicBool::new(false)),
518
+ body_rx: None,
469
519
  leftover: None,
470
520
  slot: None,
471
521
  pin_slab: Arc::new(crate::pin::PinSlab::new()),
@@ -511,10 +561,34 @@ mod tests {
511
561
  assert_eq!(split_host_port(":8080", 80), (":8080".into(), 80));
512
562
  }
513
563
 
564
+ #[test]
565
+ fn authority_host_port_derivation() {
566
+ // The same derivation build_env applies to a URI authority (the
567
+ // h2 :authority pseudo-header): http::uri does the split, the
568
+ // socket's local port fills a missing port.
569
+ let with_port: http::Uri = "https://example.com:8443/x".parse().expect("uri");
570
+ let authority = with_port.authority().expect("authority");
571
+ assert_eq!(authority.host(), "example.com");
572
+ assert_eq!(authority.port_u16(), Some(8443));
573
+ assert_eq!(authority.as_str(), "example.com:8443");
574
+
575
+ let bare: http::Uri = "https://example.com/x".parse().expect("uri");
576
+ let authority = bare.authority().expect("authority");
577
+ assert_eq!(authority.host(), "example.com");
578
+ assert_eq!(authority.port_u16().unwrap_or(9292), 9292);
579
+
580
+ // Bracketed IPv6: host() keeps the brackets, matching the h1
581
+ // split_host_port convention for SERVER_NAME.
582
+ let v6: http::Uri = "https://[::1]:8443/x".parse().expect("uri");
583
+ let authority = v6.authority().expect("authority");
584
+ assert_eq!(authority.host(), "[::1]");
585
+ assert_eq!(authority.port_u16(), Some(8443));
586
+ assert_eq!(authority.as_str(), "[::1]:8443");
587
+ }
588
+
514
589
  #[test]
515
590
  fn body_possible_reflects_channel_and_leftover_state() {
516
- // test_ctx drops its sender immediately: disconnected + empty = no
517
- // byte can ever arrive.
591
+ // test_ctx carries no channel: bodyless by construction.
518
592
  let ctx = test_ctx();
519
593
  assert!(!body_possible(&ctx));
520
594
 
@@ -526,16 +600,23 @@ mod tests {
526
600
  // A live sender means bytes may still arrive.
527
601
  let (body_tx, body_rx) = flume::bounded(1);
528
602
  let mut ctx = test_ctx();
529
- ctx.body_rx = body_rx;
603
+ ctx.body_rx = Some(body_rx);
530
604
  assert!(body_possible(&ctx));
531
605
  drop(body_tx);
532
606
 
607
+ // A dropped sender with nothing queued: EOF already reached.
608
+ let (body_tx, body_rx) = flume::bounded::<bytes::Bytes>(1);
609
+ drop(body_tx);
610
+ let mut ctx = test_ctx();
611
+ ctx.body_rx = Some(body_rx);
612
+ assert!(!body_possible(&ctx));
613
+
533
614
  // Disconnected but with a queued chunk: still readable.
534
615
  let (body_tx, body_rx) = flume::bounded(1);
535
616
  body_tx.send(bytes::Bytes::from_static(b"x")).unwrap();
536
617
  drop(body_tx);
537
618
  let mut ctx = test_ctx();
538
- ctx.body_rx = body_rx;
619
+ ctx.body_rx = Some(body_rx);
539
620
  assert!(body_possible(&ctx));
540
621
  }
541
622
  }
@@ -10,21 +10,35 @@
10
10
  //! makes `write_chunk` block in Ruby, with the GVL released.
11
11
 
12
12
  use std::io;
13
- use std::sync::atomic::{AtomicBool, Ordering};
13
+ use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
14
14
 
15
15
  use bytes::Bytes;
16
- use http_body_util::{combinators::BoxBody, BodyExt, Full, StreamBody};
16
+ use http_body_util::{Either, Full, StreamBody};
17
17
  use parking_lot::Mutex;
18
18
 
19
19
  pub type BodyFrame = hyper::body::Frame<Bytes>;
20
20
  pub type FrameResult = Result<BodyFrame, io::Error>;
21
- pub type ResponseBody = BoxBody<Bytes, io::Error>;
21
+ /// Both response shapes as one concrete type: no per-response Box or
22
+ /// vtable, unlike BoxBody. Either erases the two error types itself.
23
+ pub type ResponseBody =
24
+ Either<Full<Bytes>, StreamBody<flume::r#async::RecvStream<'static, FrameResult>>>;
22
25
  pub type HyperResponse = hyper::Response<ResponseBody>;
23
26
 
24
27
  const STREAM_BUFFER: usize = 8;
25
28
 
29
+ /// Why the body forwarder stopped pulling a request body: read_body turns
30
+ /// these into raised errors instead of a (truncated) clean EOF.
31
+ #[derive(Clone, Copy, PartialEq, Eq, Debug)]
32
+ pub enum BodyAbandon {
33
+ Oversize,
34
+ TimedOut,
35
+ }
36
+
26
37
  pub struct Responder {
27
38
  responded: AtomicBool,
39
+ /// The forwarder's abandonment flag rides in the one per-request shared
40
+ /// allocation rather than its own Arc. 0 = none, 1/2 = BodyAbandon.
41
+ body_abandoned: AtomicU8,
28
42
  head_tx: Mutex<Option<tokio::sync::oneshot::Sender<HyperResponse>>>,
29
43
  body_tx: Mutex<Option<flume::Sender<FrameResult>>>,
30
44
  }
@@ -33,11 +47,31 @@ impl Responder {
33
47
  pub fn new(head_tx: tokio::sync::oneshot::Sender<HyperResponse>) -> Self {
34
48
  Responder {
35
49
  responded: AtomicBool::new(false),
50
+ body_abandoned: AtomicU8::new(0),
36
51
  head_tx: Mutex::new(Some(head_tx)),
37
52
  body_tx: Mutex::new(None),
38
53
  }
39
54
  }
40
55
 
56
+ /// Record why the body forwarder gave up; the store happens before the
57
+ /// sender drops, so a reader that saw Disconnected sees the flag too.
58
+ pub fn abandon_body(&self, why: BodyAbandon) {
59
+ let code = match why {
60
+ BodyAbandon::Oversize => 1,
61
+ BodyAbandon::TimedOut => 2,
62
+ };
63
+ self.body_abandoned.store(code, Ordering::Relaxed);
64
+ }
65
+
66
+ /// Whether (and why) the forwarder abandoned the request body.
67
+ pub fn body_abandoned(&self) -> Option<BodyAbandon> {
68
+ match self.body_abandoned.load(Ordering::Relaxed) {
69
+ 1 => Some(BodyAbandon::Oversize),
70
+ 2 => Some(BodyAbandon::TimedOut),
71
+ _ => None,
72
+ }
73
+ }
74
+
41
75
  /// Claim the right to respond. First caller wins; everyone else gets None.
42
76
  fn claim(&self) -> Option<tokio::sync::oneshot::Sender<HyperResponse>> {
43
77
  if self.responded.swap(true, Ordering::SeqCst) {
@@ -65,7 +99,7 @@ impl Responder {
65
99
  return Ok(false);
66
100
  };
67
101
  let (body_tx, body_rx) = flume::bounded::<FrameResult>(STREAM_BUFFER);
68
- let response = builder.body(StreamBody::new(body_rx.into_stream()).boxed())?;
102
+ let response = builder.body(Either::Right(StreamBody::new(body_rx.into_stream())))?;
69
103
  *self.body_tx.lock() = Some(body_tx);
70
104
  let _ = tx.send(response);
71
105
  Ok(true)
@@ -98,7 +132,7 @@ impl Responder {
98
132
  }
99
133
 
100
134
  pub fn full_body(bytes: Bytes) -> ResponseBody {
101
- Full::new(bytes).map_err(|never| match never {}).boxed()
135
+ Either::Left(Full::new(bytes))
102
136
  }
103
137
 
104
138
  /// Canned plain-text response, built entirely on the Rust side (used for