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.
- checksums.yaml +7 -0
- data/.yardopts +14 -0
- data/CHANGELOG.md +54 -0
- data/Cargo.lock +993 -0
- data/Cargo.toml +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +384 -0
- data/doc/README.md +6 -0
- data/doc/architecture.md +161 -0
- data/doc/benchmarks.md +321 -0
- data/doc/rails-on-ractors.md +50 -0
- data/doc/why-kino.md +91 -0
- data/exe/kino +26 -0
- data/ext/kino/Cargo.toml +49 -0
- data/ext/kino/build.rs +5 -0
- data/ext/kino/extconf.rb +6 -0
- data/ext/kino/src/env_strings.rs +318 -0
- data/ext/kino/src/gvl.rs +103 -0
- data/ext/kino/src/lib.rs +90 -0
- data/ext/kino/src/logsink.rs +155 -0
- data/ext/kino/src/queue.rs +207 -0
- data/ext/kino/src/registry.rs +268 -0
- data/ext/kino/src/request.rs +432 -0
- data/ext/kino/src/response.rs +214 -0
- data/ext/kino/src/server.rs +621 -0
- data/ext/kino/src/style.rs +87 -0
- data/ext/kino/src/test_support.rs +82 -0
- data/ext/kino/src/timer.rs +57 -0
- data/ext/kino/src/tls.rs +96 -0
- data/lib/kino/check.rb +199 -0
- data/lib/kino/cli.rb +254 -0
- data/lib/kino/configuration.rb +190 -0
- data/lib/kino/errors_stream.rb +25 -0
- data/lib/kino/input.rb +77 -0
- data/lib/kino/logger.rb +56 -0
- data/lib/kino/null_input.rb +37 -0
- data/lib/kino/ractor_supervisor.rb +103 -0
- data/lib/kino/server.rb +271 -0
- data/lib/kino/stream.rb +61 -0
- data/lib/kino/templates/kino.rb.tt +141 -0
- data/lib/kino/version.rb +6 -0
- data/lib/kino/worker.rb +124 -0
- data/lib/kino.rb +53 -0
- data/sig/kino.rbs +178 -0
- metadata +219 -0
|
@@ -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
|
+
}
|