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,432 @@
1
+ //! `Kino::Native::Request`: the opaque per-request handle. Created INSIDE
2
+ //! the worker ractor by the take calls (queue.rs), so the wrapping Ruby
3
+ //! object is owned by the ractor that will use it, and never crosses
4
+ //! ractor boundaries.
5
+
6
+ use std::cell::RefCell;
7
+ use std::net::SocketAddr;
8
+ use std::sync::Arc;
9
+
10
+ use bytes::Bytes;
11
+ use magnus::r_hash::ForEach;
12
+ use magnus::{Error, RArray, RHash, RString, Ruby, TryConvert, Value};
13
+
14
+ use crate::gvl;
15
+ use crate::response::{full_body, BodyFrame, Responder};
16
+
17
+ pub struct RequestCtx {
18
+ pub method: http::Method,
19
+ pub uri: http::Uri,
20
+ pub version: http::Version,
21
+ pub headers: http::HeaderMap,
22
+ pub remote_addr: SocketAddr,
23
+ pub local_addr: SocketAddr,
24
+ pub https: bool,
25
+ /// Request body, streamed from hyper through a bounded channel: hyper is
26
+ /// only polled as Ruby consumes, so inbound backpressure is free.
27
+ pub body_rx: flume::Receiver<Bytes>,
28
+ /// When a frame is bigger than read_body's max_len, the rest waits here.
29
+ pub leftover: Option<Bytes>,
30
+ /// The owning worker slot (set at admit time, queue.rs); its interrupt
31
+ /// flag makes blocked body reads/writes interruptible like the queue pop.
32
+ pub slot: Option<Arc<crate::registry::WorkerSlot>>,
33
+ pub responder: Arc<Responder>,
34
+ }
35
+
36
+ impl Drop for RequestCtx {
37
+ /// Universal backstop: whatever kills this request (Ruby exception that
38
+ /// escaped the worker rescue, ractor death, GC of an abandoned handle),
39
+ /// the client gets a 500/abort instead of a hung connection. Never
40
+ /// touches the Ruby API: safe under `free_immediately`.
41
+ fn drop(&mut self) {
42
+ self.responder.respond_500_if_unsent();
43
+ }
44
+ }
45
+
46
+ /// Block on a channel operation with the GVL released, waking on the
47
+ /// slot's interrupt flag. `attempt` performs one bounded tick and returns
48
+ /// Some when done; None on timeout. Outer None = interrupted. Every Ruby
49
+ /// handle is created by `admit` (queue.rs), which always sets the slot.
50
+ fn block_on<T>(
51
+ slot: &Option<Arc<crate::registry::WorkerSlot>>,
52
+ attempt: impl FnMut() -> Option<T>,
53
+ ) -> Option<T> {
54
+ let slot = slot.as_ref().expect("slot set by admit");
55
+ gvl::interruptible(&slot.interrupted, attempt)
56
+ }
57
+
58
+ fn interrupted_error(ruby: &Ruby) -> Error {
59
+ Error::new(
60
+ ruby.exception_runtime_error(),
61
+ "Kino: request interrupted during shutdown",
62
+ )
63
+ }
64
+
65
+ fn invalid_response(ruby: &Ruby, e: impl std::fmt::Display) -> Error {
66
+ Error::new(
67
+ ruby.exception_runtime_error(),
68
+ format!("invalid response: {e}"),
69
+ )
70
+ }
71
+
72
+ #[magnus::wrap(class = "Kino::Native::Request", free_immediately)]
73
+ pub struct Request(pub RefCell<RequestCtx>);
74
+
75
+ /// Build the complete Rack env for a request. Static keys and common
76
+ /// values come from the frozen, Ractor-shareable cache (env_strings);
77
+ /// only genuinely dynamic strings are allocated. Called by `admit`
78
+ /// (queue.rs) with the GVL held, before the ctx is wrapped.
79
+ pub fn build_env(ruby: &Ruby, ctx: &RequestCtx) -> Result<RHash, Error> {
80
+ use crate::env_strings;
81
+
82
+ let s = env_strings::get();
83
+ let env = ruby.hash_new();
84
+ // Opaque -> live RString for this thread's Ruby handle.
85
+ let live = |o: magnus::value::Opaque<RString>| ruby.get_inner(o);
86
+
87
+ let method = match s.methods.get(ctx.method.as_str()) {
88
+ Some(cached) => live(*cached),
89
+ None => ruby.str_new(ctx.method.as_str()),
90
+ };
91
+ env.aset(live(s.request_method), method)?;
92
+ env.aset(live(s.script_name), live(s.empty))?;
93
+ env.aset(
94
+ live(s.path_info),
95
+ ruby.str_from_slice(ctx.uri.path().as_bytes()),
96
+ )?;
97
+ let query = match ctx.uri.query() {
98
+ Some(q) if !q.is_empty() => ruby.str_from_slice(q.as_bytes()),
99
+ _ => live(s.empty),
100
+ };
101
+ env.aset(live(s.query_string), query)?;
102
+ let protocol = match ctx.version {
103
+ http::Version::HTTP_10 => s.http10,
104
+ _ => s.http11,
105
+ };
106
+ env.aset(live(s.server_protocol), live(protocol))?;
107
+ env_strings::set_addr_env(ruby, env, ctx.remote_addr.ip())?;
108
+ // The scheme is the transport's to know, not the app config's.
109
+ let scheme = if ctx.https { s.https } else { s.http };
110
+ env.aset(live(s.rack_url_scheme), live(scheme))?;
111
+
112
+ // rack.errors: frozen shareable singleton. rack.input: ditto when
113
+ // there is no body (most GETs); the worker only builds a real
114
+ // Input when bytes can actually arrive.
115
+ if let Some(errors) = *s.errors_stream.read() {
116
+ env.aset(live(s.rack_errors), ruby.get_inner(errors))?;
117
+ }
118
+ if !body_possible(ctx) {
119
+ if let Some(null_input) = *s.null_input.read() {
120
+ env.aset(live(s.rack_input), ruby.get_inner(null_input))?;
121
+ }
122
+ }
123
+
124
+ // SERVER_NAME/PORT: prefer the Host header (HTTP/1.1 requires it),
125
+ // fall back to the accepted socket's local address.
126
+ let host_header = ctx.headers.get(http::header::HOST);
127
+ match host_header {
128
+ Some(host) => {
129
+ let local_port = ctx.local_addr.port();
130
+ env_strings::set_host_env(ruby, env, host.as_bytes(), || {
131
+ match std::str::from_utf8(host.as_bytes()) {
132
+ Ok(h) => split_host_port(h, local_port),
133
+ Err(_) => (
134
+ String::from_utf8_lossy(host.as_bytes()).into_owned(),
135
+ local_port,
136
+ ),
137
+ }
138
+ })?;
139
+ }
140
+ None => {
141
+ let ip = ctx.local_addr.ip();
142
+ let port = ctx.local_addr.port();
143
+ env_strings::set_host_env(ruby, env, format!("\0{ip}:{port}").as_bytes(), || {
144
+ (ip.to_string(), port)
145
+ })?;
146
+ }
147
+ }
148
+
149
+ for name in ctx.headers.keys() {
150
+ // Single-value fast path (the overwhelming majority).
151
+ let mut values = ctx.headers.get_all(name).iter();
152
+ let first = values.next().map(|v| v.as_bytes()).unwrap_or(b"");
153
+ let value = match values.next() {
154
+ None => ruby.str_from_slice(first),
155
+ Some(second) => {
156
+ let sep: &[u8] = if name == http::header::COOKIE {
157
+ b"; "
158
+ } else {
159
+ b", "
160
+ };
161
+ // Joined multi-value headers (cookies, mostly) are
162
+ // nearly always short: stay on the stack.
163
+ let mut joined = smallvec::SmallVec::<[u8; 256]>::new();
164
+ joined.extend_from_slice(first);
165
+ joined.extend_from_slice(sep);
166
+ joined.extend_from_slice(second.as_bytes());
167
+ for v in values {
168
+ joined.extend_from_slice(sep);
169
+ joined.extend_from_slice(v.as_bytes());
170
+ }
171
+ ruby.str_from_slice(&joined)
172
+ }
173
+ };
174
+
175
+ let key = if *name == http::header::CONTENT_TYPE {
176
+ live(s.content_type)
177
+ } else if *name == http::header::CONTENT_LENGTH {
178
+ live(s.content_length)
179
+ } else {
180
+ match s.header_names.get(name.as_str()) {
181
+ Some(cached) => live(*cached),
182
+ None => ruby.str_new(&env_strings::cgi_name(name.as_str())),
183
+ }
184
+ };
185
+ env.aset(key, value)?;
186
+ }
187
+
188
+ Ok(env)
189
+ }
190
+
191
+ /// Bodyless requests get their channel sender dropped at accept time, so
192
+ /// "disconnected and empty" means no byte can ever arrive.
193
+ fn body_possible(ctx: &RequestCtx) -> bool {
194
+ ctx.leftover.is_some() || !(ctx.body_rx.is_empty() && ctx.body_rx.is_disconnected())
195
+ }
196
+
197
+ /// Complete response in one shot: status, the Rack headers Hash (iterated
198
+ /// here, no intermediate array on the Ruby side), full body. Free function
199
+ /// shared by the `send_simple` binding and the fused respond_and_take path
200
+ /// (queue.rs).
201
+ pub fn respond_simple(
202
+ ruby: &Ruby,
203
+ request: &Request,
204
+ status: u16,
205
+ headers: RHash,
206
+ body: RString,
207
+ ) -> Result<bool, Error> {
208
+ let ctx = request.0.borrow();
209
+ let builder = build_head(status, headers)?;
210
+ let bytes = Bytes::copy_from_slice(unsafe { body.as_slice() });
211
+ let response = builder
212
+ .body(full_body(bytes))
213
+ .map_err(|e| invalid_response(ruby, e))?;
214
+ Ok(ctx.responder.send_response(response))
215
+ }
216
+
217
+ impl Request {
218
+ /// Next chunk of the request body, at most `max_len` bytes; nil at EOF.
219
+ /// Blocks (GVL released) until the client sends more.
220
+ pub fn read_body(
221
+ ruby: &Ruby,
222
+ rb_self: &Request,
223
+ max_len: usize,
224
+ ) -> Result<Option<RString>, Error> {
225
+ let mut ctx = rb_self.0.borrow_mut();
226
+ let max_len = max_len.max(1);
227
+
228
+ let mut chunk = match ctx.leftover.take() {
229
+ Some(bytes) => bytes,
230
+ None => {
231
+ let body_rx = ctx.body_rx.clone();
232
+ let outcome = block_on(&ctx.slot, || {
233
+ match body_rx.recv_timeout(crate::queue::TICK) {
234
+ Ok(bytes) => Some(Some(bytes)),
235
+ Err(flume::RecvTimeoutError::Timeout) => None,
236
+ Err(flume::RecvTimeoutError::Disconnected) => Some(None),
237
+ }
238
+ });
239
+ match outcome {
240
+ Some(Some(bytes)) => bytes,
241
+ Some(None) => return Ok(None), // EOF
242
+ None => return Err(interrupted_error(ruby)),
243
+ }
244
+ }
245
+ };
246
+
247
+ if chunk.len() > max_len {
248
+ ctx.leftover = Some(chunk.split_off(max_len));
249
+ }
250
+ Ok(Some(ruby.str_from_slice(&chunk)))
251
+ }
252
+
253
+ /// Start a streaming response: head goes out now, chunks follow via
254
+ /// write_chunk, terminated by finish.
255
+ pub fn send_headers(
256
+ ruby: &Ruby,
257
+ rb_self: &Request,
258
+ status: u16,
259
+ headers: RHash,
260
+ ) -> Result<bool, Error> {
261
+ let ctx = rb_self.0.borrow();
262
+ let builder = build_head(status, headers)?;
263
+ ctx.responder
264
+ .send_stream_head(builder)
265
+ .map_err(|e| invalid_response(ruby, e))
266
+ }
267
+
268
+ /// One body chunk. Blocks (GVL released) when the client is slower than
269
+ /// the app: this is the outbound backpressure.
270
+ pub fn write_chunk(ruby: &Ruby, rb_self: &Request, chunk: RString) -> Result<(), Error> {
271
+ let ctx = rb_self.0.borrow();
272
+ let Some(sender) = ctx.responder.body_sender() else {
273
+ return Err(Error::new(
274
+ ruby.exception_runtime_error(),
275
+ "Kino: response stream not started or already finished",
276
+ ));
277
+ };
278
+ let bytes = Bytes::copy_from_slice(unsafe { chunk.as_slice() });
279
+ let mut pending = Some(Ok(BodyFrame::data(bytes)));
280
+
281
+ let outcome = block_on(&ctx.slot, || {
282
+ let frame = pending.take().expect("frame consumed twice");
283
+ match sender.send_timeout(frame, crate::queue::TICK) {
284
+ Ok(()) => Some(true),
285
+ Err(flume::SendTimeoutError::Timeout(frame)) => {
286
+ pending = Some(frame); // client slow; keep waiting
287
+ None
288
+ }
289
+ Err(flume::SendTimeoutError::Disconnected(_)) => Some(false),
290
+ }
291
+ });
292
+ match outcome {
293
+ // Receiver dropped = client went away; the app keeps writing
294
+ // into the void harmlessly (Rack has no error contract here).
295
+ Some(_) => Ok(()),
296
+ None => Err(interrupted_error(ruby)),
297
+ }
298
+ }
299
+
300
+ /// Clean end of a streaming response.
301
+ pub fn finish(_ruby: &Ruby, rb_self: &Request) -> Result<(), Error> {
302
+ rb_self.0.borrow().responder.finish_stream();
303
+ Ok(())
304
+ }
305
+
306
+ /// Give up on this request: canned 500 if the head wasn't sent, abort
307
+ /// the connection if mid-stream. Used by the worker's rescue path.
308
+ pub fn abort(_ruby: &Ruby, rb_self: &Request) -> Result<(), Error> {
309
+ rb_self.0.borrow().responder.respond_500_if_unsent();
310
+ Ok(())
311
+ }
312
+ }
313
+
314
+ /// Build the response head straight from the Rack headers Hash.
315
+ /// Values are Strings or Arrays of Strings (Rack 3); each Array element
316
+ /// becomes a repeated header. Name/value bytes are borrowed in place from
317
+ /// rooted Ruby strings: safe because the GVL is held and `builder.header`
318
+ /// copies the bytes immediately.
319
+ fn build_head(status: u16, headers: RHash) -> Result<hyper::http::response::Builder, Error> {
320
+ let mut builder = Some(hyper::Response::builder().status(status));
321
+ headers.foreach(|name: RString, value: Value| {
322
+ let take = |b: &mut Option<hyper::http::response::Builder>, v: RString| {
323
+ let next = b
324
+ .take()
325
+ .expect("builder always present")
326
+ .header(unsafe { name.as_slice() }, unsafe { v.as_slice() });
327
+ *b = Some(next);
328
+ };
329
+ if let Some(values) = RArray::from_value(value) {
330
+ for i in 0..values.len() {
331
+ take(&mut builder, values.entry::<RString>(i as isize)?);
332
+ }
333
+ } else {
334
+ take(&mut builder, RString::try_convert(value)?);
335
+ }
336
+ Ok(ForEach::Continue)
337
+ })?;
338
+ Ok(builder.expect("builder always present"))
339
+ }
340
+
341
+ fn split_host_port(host: &str, default_port: u16) -> (String, u16) {
342
+ match host.rsplit_once(':') {
343
+ Some((name, port)) if !name.is_empty() => match port.parse() {
344
+ Ok(p) => (name.to_string(), p),
345
+ Err(_) => (host.to_string(), default_port),
346
+ },
347
+ _ => (host.to_string(), default_port),
348
+ }
349
+ }
350
+
351
+ /// A minimal ctx for pure-Rust tests (no Ruby involved). Dropping it
352
+ /// fires the Drop-500 backstop into a dropped receiver, which is a no-op.
353
+ #[cfg(test)]
354
+ pub fn test_ctx() -> crate::registry::BoxedCtx {
355
+ let (_body_tx, body_rx) = flume::bounded(1);
356
+ let (head_tx, _head_rx) = tokio::sync::oneshot::channel();
357
+ Box::new(RequestCtx {
358
+ method: http::Method::GET,
359
+ uri: "/".parse().expect("static uri"),
360
+ version: http::Version::HTTP_11,
361
+ headers: http::HeaderMap::new(),
362
+ remote_addr: "127.0.0.1:40000".parse().expect("static addr"),
363
+ local_addr: "127.0.0.1:9292".parse().expect("static addr"),
364
+ https: false,
365
+ body_rx,
366
+ leftover: None,
367
+ slot: None,
368
+ responder: Arc::new(Responder::new(head_tx)),
369
+ })
370
+ }
371
+
372
+ #[cfg(test)]
373
+ mod tests {
374
+ use super::*;
375
+
376
+ #[test]
377
+ fn host_port_split() {
378
+ assert_eq!(
379
+ split_host_port("example.com:8080", 80),
380
+ ("example.com".into(), 8080)
381
+ );
382
+ assert_eq!(
383
+ split_host_port("example.com", 80),
384
+ ("example.com".into(), 80)
385
+ );
386
+ }
387
+
388
+ #[test]
389
+ fn host_port_split_edge_cases() {
390
+ // Bracketed IPv6 (the RFC-correct Host form) keeps the brackets.
391
+ assert_eq!(split_host_port("[::1]:8080", 80), ("[::1]".into(), 8080));
392
+ // Out-of-range or non-numeric port: whole input + default port.
393
+ assert_eq!(
394
+ split_host_port("example.com:99999", 80),
395
+ ("example.com:99999".into(), 80)
396
+ );
397
+ assert_eq!(
398
+ split_host_port("example.com:", 80),
399
+ ("example.com:".into(), 80)
400
+ );
401
+ // Empty name (":8080") falls back whole.
402
+ assert_eq!(split_host_port(":8080", 80), (":8080".into(), 80));
403
+ }
404
+
405
+ #[test]
406
+ fn body_possible_reflects_channel_and_leftover_state() {
407
+ // test_ctx drops its sender immediately: disconnected + empty = no
408
+ // byte can ever arrive.
409
+ let ctx = test_ctx();
410
+ assert!(!body_possible(&ctx));
411
+
412
+ // A leftover chunk means there is still body to read.
413
+ let mut ctx = test_ctx();
414
+ ctx.leftover = Some(bytes::Bytes::from_static(b"tail"));
415
+ assert!(body_possible(&ctx));
416
+
417
+ // A live sender means bytes may still arrive.
418
+ let (body_tx, body_rx) = flume::bounded(1);
419
+ let mut ctx = test_ctx();
420
+ ctx.body_rx = body_rx;
421
+ assert!(body_possible(&ctx));
422
+ drop(body_tx);
423
+
424
+ // Disconnected but with a queued chunk: still readable.
425
+ let (body_tx, body_rx) = flume::bounded(1);
426
+ body_tx.send(bytes::Bytes::from_static(b"x")).unwrap();
427
+ drop(body_tx);
428
+ let mut ctx = test_ctx();
429
+ ctx.body_rx = body_rx;
430
+ assert!(body_possible(&ctx));
431
+ }
432
+ }
@@ -0,0 +1,214 @@
1
+ //! The response half of a request's lifecycle. A `Responder` is shared
2
+ //! between the Ruby-held `Request` handle and (via `Weak`) the worker slot,
3
+ //! so exactly one of three parties can answer the client: the app (normal
4
+ //! path), the supervisor (`abort_inflight` after a ractor crash), or the
5
+ //! `RequestCtx` Drop backstop. The `responded` flag makes the race benign.
6
+ //!
7
+ //! Two response shapes: `send_response` (complete, one shot) and
8
+ //! `send_stream_head` + a bounded frame channel (streaming bodies). The
9
+ //! channel being bounded(8) is the client-side backpressure: a slow client
10
+ //! makes `write_chunk` block in Ruby, with the GVL released.
11
+
12
+ use std::io;
13
+ use std::sync::atomic::{AtomicBool, Ordering};
14
+
15
+ use bytes::Bytes;
16
+ use http_body_util::{combinators::BoxBody, BodyExt, Full, StreamBody};
17
+ use parking_lot::Mutex;
18
+
19
+ pub type BodyFrame = hyper::body::Frame<Bytes>;
20
+ pub type FrameResult = Result<BodyFrame, io::Error>;
21
+ pub type ResponseBody = BoxBody<Bytes, io::Error>;
22
+ pub type HyperResponse = hyper::Response<ResponseBody>;
23
+
24
+ const STREAM_BUFFER: usize = 8;
25
+
26
+ pub struct Responder {
27
+ responded: AtomicBool,
28
+ head_tx: Mutex<Option<tokio::sync::oneshot::Sender<HyperResponse>>>,
29
+ body_tx: Mutex<Option<flume::Sender<FrameResult>>>,
30
+ }
31
+
32
+ impl Responder {
33
+ pub fn new(head_tx: tokio::sync::oneshot::Sender<HyperResponse>) -> Self {
34
+ Responder {
35
+ responded: AtomicBool::new(false),
36
+ head_tx: Mutex::new(Some(head_tx)),
37
+ body_tx: Mutex::new(None),
38
+ }
39
+ }
40
+
41
+ /// Claim the right to respond. First caller wins; everyone else gets None.
42
+ fn claim(&self) -> Option<tokio::sync::oneshot::Sender<HyperResponse>> {
43
+ if self.responded.swap(true, Ordering::SeqCst) {
44
+ return None;
45
+ }
46
+ self.head_tx.lock().take()
47
+ }
48
+
49
+ /// Complete response in one shot.
50
+ pub fn send_response(&self, response: HyperResponse) -> bool {
51
+ match self.claim() {
52
+ Some(tx) => tx.send(response).is_ok(),
53
+ None => false,
54
+ }
55
+ }
56
+
57
+ /// Start a streaming response from a prepared head: send it now, return
58
+ /// chunks later through the frame channel. Returns false if someone
59
+ /// already responded.
60
+ pub fn send_stream_head(
61
+ &self,
62
+ builder: hyper::http::response::Builder,
63
+ ) -> Result<bool, http::Error> {
64
+ let Some(tx) = self.claim() else {
65
+ return Ok(false);
66
+ };
67
+ let (body_tx, body_rx) = flume::bounded::<FrameResult>(STREAM_BUFFER);
68
+ let response = builder.body(StreamBody::new(body_rx.into_stream()).boxed())?;
69
+ *self.body_tx.lock() = Some(body_tx);
70
+ let _ = tx.send(response);
71
+ Ok(true)
72
+ }
73
+
74
+ /// Clone of the live frame sender, if a stream is open. The clone lets
75
+ /// `write_chunk` block on a full channel without holding the lock.
76
+ pub fn body_sender(&self) -> Option<flume::Sender<FrameResult>> {
77
+ self.body_tx.lock().clone()
78
+ }
79
+
80
+ /// Clean end of stream: dropping the sender ends the hyper body.
81
+ pub fn finish_stream(&self) {
82
+ self.body_tx.lock().take();
83
+ }
84
+
85
+ /// The "the app will never answer" path. Before the head: canned 500.
86
+ /// Mid-stream: error frame, which makes hyper abort the connection
87
+ /// rather than fake a clean end. Never touches the Ruby API: callable
88
+ /// from Drop, tokio threads, and the supervisor.
89
+ pub fn respond_500_if_unsent(&self) {
90
+ if let Some(tx) = self.claim() {
91
+ let _ = tx.send(plain_response(500, "Internal Server Error\n"));
92
+ return;
93
+ }
94
+ if let Some(body_tx) = self.body_tx.lock().take() {
95
+ let _ = body_tx.send(Err(io::Error::other("Kino: response abandoned mid-stream")));
96
+ }
97
+ }
98
+ }
99
+
100
+ pub fn full_body(bytes: Bytes) -> ResponseBody {
101
+ Full::new(bytes).map_err(|never| match never {}).boxed()
102
+ }
103
+
104
+ /// Canned plain-text response, built entirely on the Rust side (used for
105
+ /// the 500/503/504 paths that never reach Ruby).
106
+ pub fn plain_response(status: u16, message: &'static str) -> HyperResponse {
107
+ hyper::Response::builder()
108
+ .status(status)
109
+ .header("content-type", "text/plain")
110
+ .body(full_body(Bytes::from_static(message.as_bytes())))
111
+ .expect("static response must build")
112
+ }
113
+
114
+ #[cfg(test)]
115
+ mod tests {
116
+ use super::*;
117
+
118
+ fn pair() -> (
119
+ Responder,
120
+ tokio::sync::oneshot::Receiver<HyperResponse>,
121
+ ) {
122
+ let (head_tx, head_rx) = tokio::sync::oneshot::channel();
123
+ (Responder::new(head_tx), head_rx)
124
+ }
125
+
126
+ #[test]
127
+ fn first_claimant_wins() {
128
+ let (responder, mut head_rx) = pair();
129
+
130
+ assert!(responder.send_response(plain_response(200, "first")));
131
+ assert!(!responder.send_response(plain_response(201, "second")));
132
+ assert_eq!(head_rx.try_recv().expect("head sent").status(), 200);
133
+ }
134
+
135
+ #[test]
136
+ fn backstop_sends_500_when_unsent_and_is_idempotent() {
137
+ let (responder, mut head_rx) = pair();
138
+
139
+ responder.respond_500_if_unsent();
140
+ responder.respond_500_if_unsent(); // second call must be a no-op
141
+
142
+ assert_eq!(head_rx.try_recv().expect("head sent").status(), 500);
143
+ assert!(!responder.send_response(plain_response(200, "late")));
144
+ }
145
+
146
+ #[test]
147
+ fn stream_head_claims_and_opens_the_frame_channel() {
148
+ let (responder, mut head_rx) = pair();
149
+
150
+ let started = responder
151
+ .send_stream_head(hyper::Response::builder().status(200))
152
+ .expect("valid head");
153
+ assert!(started);
154
+ assert_eq!(head_rx.try_recv().expect("head sent").status(), 200);
155
+
156
+ // A second stream start loses the claim.
157
+ let again = responder
158
+ .send_stream_head(hyper::Response::builder().status(201))
159
+ .expect("valid head");
160
+ assert!(!again);
161
+
162
+ // The frame channel is open until finish_stream closes it.
163
+ assert!(responder.body_sender().is_some());
164
+ responder.finish_stream();
165
+ assert!(responder.body_sender().is_none());
166
+ }
167
+
168
+ #[test]
169
+ fn backstop_mid_stream_closes_the_frame_channel() {
170
+ let (responder, _head_rx) = pair();
171
+
172
+ responder
173
+ .send_stream_head(hyper::Response::builder().status(200))
174
+ .expect("valid head");
175
+ assert!(responder.body_sender().is_some());
176
+
177
+ // Mid-stream abandonment: the error frame goes to hyper (which
178
+ // aborts the connection) and the channel is closed for the app.
179
+ responder.respond_500_if_unsent();
180
+ assert!(responder.body_sender().is_none());
181
+ }
182
+
183
+ #[test]
184
+ fn concurrent_claimants_yield_exactly_one_winner() {
185
+ let (responder, mut head_rx) = pair();
186
+ let responder = std::sync::Arc::new(responder);
187
+
188
+ let winners: usize = std::thread::scope(|scope| {
189
+ (0..16)
190
+ .map(|i| {
191
+ let responder = responder.clone();
192
+ scope.spawn(move || responder.send_response(plain_response(200 + i, "race")))
193
+ })
194
+ .collect::<Vec<_>>()
195
+ .into_iter()
196
+ .map(|handle| usize::from(handle.join().expect("no panic")))
197
+ .sum()
198
+ });
199
+
200
+ assert_eq!(winners, 1);
201
+ assert!(head_rx.try_recv().is_ok());
202
+ }
203
+
204
+ #[test]
205
+ fn plain_response_sets_status_and_content_type() {
206
+ let response = plain_response(503, "Service Unavailable\n");
207
+
208
+ assert_eq!(response.status(), 503);
209
+ assert_eq!(
210
+ response.headers().get("content-type").unwrap(),
211
+ "text/plain"
212
+ );
213
+ }
214
+ }