shuttlebay 0.1.4 → 0.2.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 +4 -4
- data/Cargo.lock +24 -24
- data/README.md +77 -6
- data/docs/guide.md +282 -0
- data/ext/shuttlebay/Cargo.toml +4 -4
- data/ext/shuttlebay/src/codec.rs +140 -29
- data/ext/shuttlebay/src/interned.rs +93 -0
- data/ext/shuttlebay/src/lib.rs +16 -0
- data/lib/shuttlebay/cache.rb +61 -0
- data/lib/shuttlebay/engine.rb +25 -0
- data/lib/shuttlebay/handler.rb +8 -6
- data/lib/shuttlebay/logger.rb +5 -0
- data/lib/shuttlebay/railtie.rb +42 -1
- data/lib/shuttlebay/runtime.rb +45 -3
- data/lib/shuttlebay/server.rb +80 -29
- data/lib/shuttlebay/version.rb +1 -1
- data/lib/shuttlebay/worker.rb +2 -2
- data/lib/shuttlebay.rb +1 -0
- metadata +21 -4
data/ext/shuttlebay/src/codec.rs
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
use std::{cell::RefCell, fmt::Display};
|
|
2
2
|
|
|
3
|
-
use magnus::{
|
|
3
|
+
use magnus::{
|
|
4
|
+
Error, RArray, RHash, RString, Ruby, Symbol, Value, prelude::*, scan_args::scan_args,
|
|
5
|
+
};
|
|
4
6
|
use mothership_docking_protocol::{
|
|
5
|
-
Dock, HAUL_CHUNK_LEN, Hail, MAX_FRAME_PAYLOAD, MessageType, Moored, ProtocolError, Reply,
|
|
6
|
-
ReplyBody, VERSION, decode_hail, decode_haul, decode_header, encode_dock,
|
|
7
|
-
encode_reply, push_haul,
|
|
7
|
+
Dock, HAUL_CHUNK_LEN, Hail, MAX_FRAME_PAYLOAD, MessageType, Moored, Pair, ProtocolError, Reply,
|
|
8
|
+
ReplyBody, VERSION, decode_hail, decode_haul, decode_header, encode_dock, encode_haul_header,
|
|
9
|
+
encode_ready, encode_reply, push_haul,
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
use crate::{
|
|
11
|
-
OrProtocolError, headers,
|
|
13
|
+
OrProtocolError, headers, interned,
|
|
12
14
|
link::{self, LinkEvent, LinkState},
|
|
13
|
-
protocol_error, spool_file, string_io,
|
|
15
|
+
null_input, protocol_error, spool_file, string_io,
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
const READ_CHUNK: usize = 64 * 1024;
|
|
@@ -19,6 +21,17 @@ pub const SPOOL_THRESHOLD: u64 = 1024 * 1024;
|
|
|
19
21
|
|
|
20
22
|
const MAX_BODY: u64 = 4 * 1024 * 1024 * 1024;
|
|
21
23
|
|
|
24
|
+
/// CGI params whose values come from a small set and that apps replace
|
|
25
|
+
/// rather than mutate: each value is one shared frozen String.
|
|
26
|
+
const SHARED_VALUES: [&[u8]; 6] = [
|
|
27
|
+
b"REQUEST_METHOD",
|
|
28
|
+
b"SCRIPT_NAME",
|
|
29
|
+
b"SERVER_PORT",
|
|
30
|
+
b"SERVER_PROTOCOL",
|
|
31
|
+
b"SERVER_SOFTWARE",
|
|
32
|
+
b"HTTPS",
|
|
33
|
+
];
|
|
34
|
+
|
|
22
35
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
23
36
|
enum Kind {
|
|
24
37
|
Empty,
|
|
@@ -101,13 +114,17 @@ impl Codec {
|
|
|
101
114
|
|
|
102
115
|
let env: RHash = template.funcall("dup", ())?;
|
|
103
116
|
for (name, value) in &hail.params {
|
|
104
|
-
let
|
|
105
|
-
|
|
106
|
-
|
|
117
|
+
let value = if shares_value(name, value) {
|
|
118
|
+
interned::value(ruby, value)?
|
|
119
|
+
} else {
|
|
120
|
+
ruby.str_from_slice(value)
|
|
121
|
+
};
|
|
122
|
+
env.aset(interned::key(ruby, name)?, value)?;
|
|
107
123
|
}
|
|
108
124
|
let secure = hail.params.iter().any(|(name, value)| name == b"HTTPS" && value == b"on");
|
|
109
|
-
|
|
110
|
-
env.aset("rack.
|
|
125
|
+
let scheme: &[u8] = if secure { b"https" } else { b"http" };
|
|
126
|
+
env.aset(interned::key(ruby, b"rack.url_scheme")?, interned::value(ruby, scheme)?)?;
|
|
127
|
+
env.aset(interned::key(ruby, b"rack.input")?, input)?;
|
|
111
128
|
|
|
112
129
|
rb_self.try_with_state(ruby, |state| link::begin(&mut state.link, hail.request_id))?;
|
|
113
130
|
Ok(Some(env))
|
|
@@ -122,10 +139,7 @@ impl Codec {
|
|
|
122
139
|
let (path,) = args.optional;
|
|
123
140
|
let path = path.flatten();
|
|
124
141
|
|
|
125
|
-
let status =
|
|
126
|
-
u16::try_from(status).ok().filter(|status| (100..=599).contains(status)).ok_or_else(
|
|
127
|
-
|| Error::new(ruby.exception_arg_error(), format!("invalid HTTP status: {status}")),
|
|
128
|
-
)?;
|
|
142
|
+
let status = http_status(ruby, status)?;
|
|
129
143
|
let kind = match kind.name()?.as_ref() {
|
|
130
144
|
"empty" => Kind::Empty,
|
|
131
145
|
"stream" => Kind::Stream,
|
|
@@ -161,20 +175,66 @@ impl Codec {
|
|
|
161
175
|
Kind::Tunnel => LinkEvent::ReplyTunnel,
|
|
162
176
|
};
|
|
163
177
|
rb_self.try_with_state(ruby, |state| -> Result<(), &str> {
|
|
164
|
-
|
|
165
|
-
if from == LinkState::Idle {
|
|
166
|
-
"reply called without a request"
|
|
167
|
-
} else {
|
|
168
|
-
"reply already sent for this request"
|
|
169
|
-
}
|
|
170
|
-
})?;
|
|
171
|
-
let frame = encode_reply(&Reply { request_id, status, headers, body })
|
|
172
|
-
.map_err(|_| "reply head exceeds the frame size limit")?;
|
|
173
|
-
state.outbox.extend_from_slice(&frame);
|
|
174
|
-
Ok(())
|
|
178
|
+
push_reply(state, event, status, headers, body).map(drop)
|
|
175
179
|
})
|
|
176
180
|
}
|
|
177
181
|
|
|
182
|
+
/// Send a whole response at once: Reply head, body and FIN leave in one
|
|
183
|
+
/// write, so Mothership always lands it as a complete body. A single chunk
|
|
184
|
+
/// (a rendered page) is handed to the socket as is, never copied into a
|
|
185
|
+
/// frame buffer; several are framed together.
|
|
186
|
+
pub fn reply_whole(
|
|
187
|
+
ruby: &Ruby,
|
|
188
|
+
rb_self: &Self,
|
|
189
|
+
io: Value,
|
|
190
|
+
status: i64,
|
|
191
|
+
headers: RHash,
|
|
192
|
+
chunks: RArray,
|
|
193
|
+
) -> Result<(), Error> {
|
|
194
|
+
let status = http_status(ruby, status)?;
|
|
195
|
+
let headers = headers::flatten(ruby, headers)?;
|
|
196
|
+
// Checked before the link moves, so a bad body still gets a 500.
|
|
197
|
+
for index in 0..chunks.len() {
|
|
198
|
+
body_chunk(ruby, chunks, index)?;
|
|
199
|
+
}
|
|
200
|
+
let only = (chunks.len() == 1)
|
|
201
|
+
.then(|| body_chunk(ruby, chunks, 0))
|
|
202
|
+
.transpose()?
|
|
203
|
+
.filter(|chunk| chunk.len() <= HAUL_CHUNK_LEN);
|
|
204
|
+
|
|
205
|
+
let request_id = rb_self.try_with_state(ruby, |state| {
|
|
206
|
+
push_reply(state, LinkEvent::ReplyStream, status, headers, ReplyBody::Stream)
|
|
207
|
+
})?;
|
|
208
|
+
|
|
209
|
+
if let Some(chunk) = only {
|
|
210
|
+
rb_self.try_with_state(ruby, |state| -> Result<(), &str> {
|
|
211
|
+
let haul = encode_haul_header(request_id, true, chunk.len())
|
|
212
|
+
.map_err(|_| "body chunk exceeds the frame size limit")?;
|
|
213
|
+
state.outbox.extend_from_slice(&haul);
|
|
214
|
+
finish_stream(state)
|
|
215
|
+
})?;
|
|
216
|
+
let head = rb_self.with_state(ruby, |state| std::mem::take(&mut state.outbox))?;
|
|
217
|
+
// A tuple keeps both Strings on the stack, visible to the GC,
|
|
218
|
+
// for the whole write.
|
|
219
|
+
let _: Value = io.funcall("write", (ruby.str_from_slice(&head), chunk))?;
|
|
220
|
+
return Ok(());
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for index in 0..chunks.len() {
|
|
224
|
+
let bytes = body_chunk(ruby, chunks, index)?.to_bytes();
|
|
225
|
+
rb_self.try_with_state(ruby, |state| {
|
|
226
|
+
push_body(&mut state.outbox, request_id, &bytes, false)
|
|
227
|
+
.map_err(|_| "body chunk exceeds the frame size limit")
|
|
228
|
+
})?;
|
|
229
|
+
}
|
|
230
|
+
rb_self.try_with_state(ruby, |state| -> Result<(), &str> {
|
|
231
|
+
push_haul(&mut state.outbox, request_id, true, &[])
|
|
232
|
+
.map_err(|_| "FIN frame could not be encoded")?;
|
|
233
|
+
finish_stream(state)
|
|
234
|
+
})?;
|
|
235
|
+
rb_self.flush_to(ruby, io)
|
|
236
|
+
}
|
|
237
|
+
|
|
178
238
|
pub fn write(ruby: &Ruby, rb_self: &Self, io: Value, chunk: RString) -> Result<(), Error> {
|
|
179
239
|
let chunk = chunk.to_bytes();
|
|
180
240
|
let pending = rb_self.try_with_state(ruby, |state| -> Result<_, &str> {
|
|
@@ -326,6 +386,9 @@ impl Codec {
|
|
|
326
386
|
"request body of {} bytes exceeds the engine limit",
|
|
327
387
|
hail.content_length
|
|
328
388
|
);
|
|
389
|
+
if hail.content_length == 0 {
|
|
390
|
+
return null_input(ruby);
|
|
391
|
+
}
|
|
329
392
|
let expected = usize::try_from(hail.content_length)
|
|
330
393
|
.map_err(|_| protocol_error(ruby, "request body does not fit in memory"))?;
|
|
331
394
|
let mut sink = if hail.content_length > SPOOL_THRESHOLD {
|
|
@@ -333,9 +396,7 @@ impl Codec {
|
|
|
333
396
|
} else {
|
|
334
397
|
BodySink::Memory(Vec::with_capacity(expected))
|
|
335
398
|
};
|
|
336
|
-
|
|
337
|
-
self.receive_body(ruby, io, hail, expected, &mut sink)?;
|
|
338
|
-
}
|
|
399
|
+
self.receive_body(ruby, io, hail, expected, &mut sink)?;
|
|
339
400
|
sink.into_input(ruby)
|
|
340
401
|
}
|
|
341
402
|
|
|
@@ -447,6 +508,56 @@ impl BodySink {
|
|
|
447
508
|
}
|
|
448
509
|
}
|
|
449
510
|
|
|
511
|
+
fn http_status(ruby: &Ruby, status: i64) -> Result<u16, Error> {
|
|
512
|
+
u16::try_from(status).ok().filter(|status| (100..=599).contains(status)).ok_or_else(|| {
|
|
513
|
+
Error::new(ruby.exception_arg_error(), format!("invalid HTTP status: {status}"))
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
fn shares_value(name: &[u8], value: &[u8]) -> bool {
|
|
518
|
+
SHARED_VALUES.contains(&name) || (name == b"QUERY_STRING" && value.is_empty())
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/// Body chunk `index`: Rack bodies yield Strings, nothing else.
|
|
522
|
+
fn body_chunk(ruby: &Ruby, chunks: RArray, index: usize) -> Result<RString, Error> {
|
|
523
|
+
let offset = isize::try_from(index)
|
|
524
|
+
.map_err(|_| Error::new(ruby.exception_range_error(), "body array too long"))?;
|
|
525
|
+
let chunk: Value = chunks.entry(offset)?;
|
|
526
|
+
RString::from_value(chunk).ok_or_else(|| {
|
|
527
|
+
Error::new(
|
|
528
|
+
ruby.exception_type_error(),
|
|
529
|
+
format!("body chunk must be a String, got {}", chunk.class().inspect()),
|
|
530
|
+
)
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/// Move the link past its Reply and queue the Reply head; the request id is
|
|
535
|
+
/// what the body frames must carry.
|
|
536
|
+
fn push_reply(
|
|
537
|
+
state: &mut State,
|
|
538
|
+
event: LinkEvent,
|
|
539
|
+
status: u16,
|
|
540
|
+
headers: Vec<Pair>,
|
|
541
|
+
body: ReplyBody,
|
|
542
|
+
) -> Result<u32, &'static str> {
|
|
543
|
+
let (request_id, _) = link::advance(&mut state.link, event, |from| {
|
|
544
|
+
if from == LinkState::Idle {
|
|
545
|
+
"reply called without a request"
|
|
546
|
+
} else {
|
|
547
|
+
"reply already sent for this request"
|
|
548
|
+
}
|
|
549
|
+
})?;
|
|
550
|
+
let frame = encode_reply(&Reply { request_id, status, headers, body })
|
|
551
|
+
.map_err(|_| "reply head exceeds the frame size limit")?;
|
|
552
|
+
state.outbox.extend_from_slice(&frame);
|
|
553
|
+
Ok(request_id)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/// A streamed body is complete: the link waits for `ready`.
|
|
557
|
+
fn finish_stream(state: &mut State) -> Result<(), &'static str> {
|
|
558
|
+
link::advance(&mut state.link, LinkEvent::Finish, |_| "finish called twice").map(drop)
|
|
559
|
+
}
|
|
560
|
+
|
|
450
561
|
fn push_body(
|
|
451
562
|
outbox: &mut Vec<u8>,
|
|
452
563
|
request_id: u32,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//! Frozen, interned Strings for env keys and a few fixed CGI values, so each
|
|
2
|
+
//! request's env reuses them instead of allocating one String per param.
|
|
3
|
+
//!
|
|
4
|
+
//! The table only grows, and only up to [`CAPACITY`] entries per kind: header
|
|
5
|
+
//! names reach it from clients, and past the cap a name gets a fresh String,
|
|
6
|
+
//! as without the table. Every interned String is kept alive by one rooted
|
|
7
|
+
//! Array; the table holds no lock while it calls into Ruby, since a Ruby call
|
|
8
|
+
//! can switch threads and another thread's lookup would then deadlock.
|
|
9
|
+
|
|
10
|
+
use std::{
|
|
11
|
+
collections::HashMap,
|
|
12
|
+
sync::{LazyLock, Mutex, OnceLock, PoisonError},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
use magnus::{Error, RArray, RString, Ruby, value::Opaque};
|
|
16
|
+
|
|
17
|
+
use crate::OrProtocolError;
|
|
18
|
+
|
|
19
|
+
const CAPACITY: usize = 1024;
|
|
20
|
+
|
|
21
|
+
#[derive(Default)]
|
|
22
|
+
struct Table {
|
|
23
|
+
/// Env keys: UTF-8 when the name is, binary otherwise
|
|
24
|
+
keys: HashMap<Box<[u8]>, Opaque<RString>>,
|
|
25
|
+
/// Env values: binary, like every other param value
|
|
26
|
+
values: HashMap<Box<[u8]>, Opaque<RString>>,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[derive(Clone, Copy)]
|
|
30
|
+
enum Kind {
|
|
31
|
+
Key,
|
|
32
|
+
Value,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl Table {
|
|
36
|
+
const fn strings(&mut self, kind: Kind) -> &mut HashMap<Box<[u8]>, Opaque<RString>> {
|
|
37
|
+
match kind {
|
|
38
|
+
Kind::Key => &mut self.keys,
|
|
39
|
+
Kind::Value => &mut self.values,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static TABLE: LazyLock<Mutex<Table>> = LazyLock::new(Mutex::default);
|
|
45
|
+
static ROOT: OnceLock<Opaque<RArray>> = OnceLock::new();
|
|
46
|
+
|
|
47
|
+
/// Create the Array that keeps interned Strings alive. Called once, at load.
|
|
48
|
+
pub fn init(ruby: &Ruby) {
|
|
49
|
+
let root = ruby.ary_new();
|
|
50
|
+
ruby.gc_register_mark_object(root);
|
|
51
|
+
crate::cache(&ROOT, root);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// The env key for a param name.
|
|
55
|
+
pub fn key(ruby: &Ruby, name: &[u8]) -> Result<RString, Error> {
|
|
56
|
+
intern(ruby, Kind::Key, name)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// A shared, frozen param value.
|
|
60
|
+
pub fn value(ruby: &Ruby, value: &[u8]) -> Result<RString, Error> {
|
|
61
|
+
intern(ruby, Kind::Value, value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn intern(ruby: &Ruby, kind: Kind, bytes: &[u8]) -> Result<RString, Error> {
|
|
65
|
+
if let Some(found) = lock().strings(kind).get(bytes) {
|
|
66
|
+
return Ok(ruby.get_inner(*found));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let fresh = match kind {
|
|
70
|
+
Kind::Key => std::str::from_utf8(bytes)
|
|
71
|
+
.map_or_else(|_| ruby.str_from_slice(bytes), |name| ruby.str_new(name)),
|
|
72
|
+
Kind::Value => ruby.str_from_slice(bytes),
|
|
73
|
+
};
|
|
74
|
+
let interned = fresh.to_interned_str();
|
|
75
|
+
|
|
76
|
+
let mut table = lock();
|
|
77
|
+
let strings = table.strings(kind);
|
|
78
|
+
if strings.len() >= CAPACITY || strings.contains_key(bytes) {
|
|
79
|
+
return Ok(interned);
|
|
80
|
+
}
|
|
81
|
+
strings.insert(bytes.into(), Opaque::from(interned));
|
|
82
|
+
drop(table);
|
|
83
|
+
|
|
84
|
+
let root = ROOT.get().ok_or("interned strings were not initialised").or_protocol_error(ruby)?;
|
|
85
|
+
ruby.get_inner(*root).push(interned)?;
|
|
86
|
+
Ok(interned)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// The table holds plain maps that stay consistent even if a holder
|
|
90
|
+
/// panicked, so a poisoned lock is still safe to use.
|
|
91
|
+
fn lock() -> std::sync::MutexGuard<'static, Table> {
|
|
92
|
+
TABLE.lock().unwrap_or_else(PoisonError::into_inner)
|
|
93
|
+
}
|
data/ext/shuttlebay/src/lib.rs
CHANGED
|
@@ -15,6 +15,7 @@ macro_rules! ensure {
|
|
|
15
15
|
|
|
16
16
|
mod codec;
|
|
17
17
|
mod headers;
|
|
18
|
+
mod interned;
|
|
18
19
|
mod link;
|
|
19
20
|
|
|
20
21
|
use codec::Codec;
|
|
@@ -22,6 +23,7 @@ use codec::Codec;
|
|
|
22
23
|
static PROTOCOL_ERROR: OnceLock<Opaque<ExceptionClass>> = OnceLock::new();
|
|
23
24
|
static STRING_IO: OnceLock<Opaque<RClass>> = OnceLock::new();
|
|
24
25
|
static ENGINE: OnceLock<Opaque<RModule>> = OnceLock::new();
|
|
26
|
+
static NULL_INPUT: OnceLock<Opaque<Value>> = OnceLock::new();
|
|
25
27
|
|
|
26
28
|
pub(crate) fn protocol_error(ruby: &Ruby, message: impl Into<String>) -> Error {
|
|
27
29
|
let class = cached(ruby, &PROTOCOL_ERROR).unwrap_or_else(|| ruby.exception_runtime_error());
|
|
@@ -42,6 +44,18 @@ pub(crate) fn spool_file(ruby: &Ruby) -> Result<Value, Error> {
|
|
|
42
44
|
initialised(ruby, &ENGINE, "Shuttlebay::Engine was not initialised")?.funcall("spool_file", ())
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/// `Shuttlebay::Engine::NULL_INPUT`, the `rack.input` of every bodiless
|
|
48
|
+
/// request. Defined in Ruby after this library loads, so resolved on first use.
|
|
49
|
+
pub(crate) fn null_input(ruby: &Ruby) -> Result<Value, Error> {
|
|
50
|
+
if let Some(input) = cached(ruby, &NULL_INPUT) {
|
|
51
|
+
return Ok(input);
|
|
52
|
+
}
|
|
53
|
+
let engine = initialised(ruby, &ENGINE, "Shuttlebay::Engine was not initialised")?;
|
|
54
|
+
let input: Value = engine.const_get("NULL_INPUT")?;
|
|
55
|
+
cache(&NULL_INPUT, input);
|
|
56
|
+
Ok(input)
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
pub(crate) fn string_io(ruby: &Ruby) -> Result<RClass, Error> {
|
|
46
60
|
initialised(ruby, &STRING_IO, "StringIO was not resolved at load time")
|
|
47
61
|
}
|
|
@@ -65,6 +79,7 @@ fn initialised<T: ReprValue>(
|
|
|
65
79
|
#[magnus::init(name = "shuttlebay")]
|
|
66
80
|
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
67
81
|
ruby.require("stringio")?;
|
|
82
|
+
interned::init(ruby);
|
|
68
83
|
cache(&STRING_IO, ruby.class_object().const_get::<_, RClass>("StringIO")?);
|
|
69
84
|
|
|
70
85
|
let shuttlebay = ruby.define_module("Shuttlebay")?;
|
|
@@ -82,6 +97,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
82
97
|
codec.define_method("dock", method!(Codec::dock, 3))?;
|
|
83
98
|
codec.define_method("read_request", method!(Codec::read_request, 2))?;
|
|
84
99
|
codec.define_method("reply", method!(Codec::reply, -1))?;
|
|
100
|
+
codec.define_method("reply_whole", method!(Codec::reply_whole, 4))?;
|
|
85
101
|
codec.define_method("write", method!(Codec::write, 2))?;
|
|
86
102
|
codec.define_method("flush", method!(Codec::flush, 1))?;
|
|
87
103
|
codec.define_method("finish", method!(Codec::finish, 1))?;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Shuttlebay
|
|
8
|
+
# Mothership's edge cache, from the app: tag responses with the data they
|
|
9
|
+
# show, and purge the tags when that data changes, so entries live until
|
|
10
|
+
# purged instead of until a guessed TTL runs out.
|
|
11
|
+
#
|
|
12
|
+
# # in a controller
|
|
13
|
+
# Shuttlebay::Cache.tag(response.headers, "book-#{@book.id}", 'books')
|
|
14
|
+
# # in a model
|
|
15
|
+
# after_commit { Shuttlebay::Cache.purge(tags: ["book-#{id}", 'books']) }
|
|
16
|
+
#
|
|
17
|
+
# Purging works from any process Mothership runs (web workers, job
|
|
18
|
+
# workers): it posts to the admin endpoint in MS_ADMIN_URL, which
|
|
19
|
+
# Mothership sets when it has a metrics port.
|
|
20
|
+
module Cache
|
|
21
|
+
HEADER = 'cache-tag'
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Add `tags` to the response's Cache-Tag (Mothership keeps it from clients).
|
|
26
|
+
def tag(headers, *tags)
|
|
27
|
+
tags = tags.flatten.map { |tag| tag.to_s.strip }.reject(&:empty?)
|
|
28
|
+
return headers if tags.empty?
|
|
29
|
+
|
|
30
|
+
headers[HEADER] = [headers[HEADER], *tags].compact.reject(&:empty?).join(', ')
|
|
31
|
+
headers
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Purge the entries tagged with any of `tags`, for any of `urls` (path and
|
|
35
|
+
# query), under any of `prefixes`, or `all`. `soft: true` marks them stale
|
|
36
|
+
# instead: still served while one request refreshes them. Returns how many
|
|
37
|
+
# were purged; nil outside Mothership (no MS_ADMIN_URL), where there is no
|
|
38
|
+
# cache to purge. A purge Mothership refuses raises Shuttlebay::Error.
|
|
39
|
+
def purge(tags: [], urls: [], prefixes: [], all: false, soft: false, admin_url: ENV.fetch('MS_ADMIN_URL', nil),
|
|
40
|
+
timeout: 2)
|
|
41
|
+
params = Array(tags).map { |tag| ['tag', tag.to_s] } + Array(urls).map { |url| ['url', url.to_s] } +
|
|
42
|
+
Array(prefixes).map { |prefix| ['prefix', prefix.to_s] }
|
|
43
|
+
params << %w[all true] if all
|
|
44
|
+
raise ArgumentError, 'purge needs tags, urls, prefixes or all: true' if params.empty?
|
|
45
|
+
return nil if admin_url.nil? || admin_url.empty?
|
|
46
|
+
|
|
47
|
+
params << %w[soft true] if soft
|
|
48
|
+
post_purge(URI.join(admin_url, '/cache/purge').tap { |uri| uri.query = URI.encode_www_form(params) }, timeout)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def post_purge(uri, timeout)
|
|
52
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: timeout, read_timeout: timeout) do |http|
|
|
53
|
+
http.post(uri.request_uri, '')
|
|
54
|
+
end
|
|
55
|
+
raise Error, "cache purge failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
56
|
+
|
|
57
|
+
JSON.parse(response.body).fetch('purged')
|
|
58
|
+
end
|
|
59
|
+
private_class_method :post_purge
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/shuttlebay/engine.rb
CHANGED
|
@@ -19,5 +19,30 @@ module Shuttlebay
|
|
|
19
19
|
def self.spool_file
|
|
20
20
|
Tempfile.new('shuttlebay-body', binmode: true).tap(&:unlink)
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# The rack.input of a request without a body, like Puma's NullIO: always
|
|
24
|
+
# at EOF and stateless, so one frozen instance serves every request.
|
|
25
|
+
class NullInput
|
|
26
|
+
def gets = nil
|
|
27
|
+
def each = nil
|
|
28
|
+
def rewind = 0
|
|
29
|
+
def close = nil
|
|
30
|
+
def closed? = false
|
|
31
|
+
def eof? = true
|
|
32
|
+
def size = 0
|
|
33
|
+
def string = ''
|
|
34
|
+
|
|
35
|
+
def read(length = nil, buffer = nil)
|
|
36
|
+
raise ArgumentError, "negative length #{length} given" if length&.negative?
|
|
37
|
+
|
|
38
|
+
buffer = buffer.nil? ? +''.b : String.try_convert(buffer)
|
|
39
|
+
raise TypeError, 'buffer must be a String' unless buffer
|
|
40
|
+
|
|
41
|
+
buffer.clear
|
|
42
|
+
length&.positive? ? nil : buffer
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
NULL_INPUT = NullInput.new.freeze
|
|
22
47
|
end
|
|
23
48
|
end
|
data/lib/shuttlebay/handler.rb
CHANGED
|
@@ -74,11 +74,15 @@ module Shuttlebay
|
|
|
74
74
|
def respond(io, codec, env, status, headers, body)
|
|
75
75
|
head_sent = false
|
|
76
76
|
kind, path = reply_kind(env, status, body)
|
|
77
|
-
|
|
77
|
+
if kind == :stream && body.respond_to?(:to_ary)
|
|
78
|
+
# The whole body is in hand: head, body and FIN leave in one write.
|
|
79
|
+
codec.reply_whole(io, status, headers, body.to_ary)
|
|
80
|
+
return [true, nil]
|
|
81
|
+
end
|
|
78
82
|
|
|
79
83
|
codec.reply(io, status, headers, kind, path)
|
|
80
84
|
head_sent = true
|
|
81
|
-
write_body(io, codec, body
|
|
85
|
+
write_body(io, codec, body) if kind == :stream
|
|
82
86
|
codec.finish(io)
|
|
83
87
|
[true, nil]
|
|
84
88
|
rescue *LINK_LOST => e
|
|
@@ -100,10 +104,8 @@ module Shuttlebay
|
|
|
100
104
|
[:stream, nil]
|
|
101
105
|
end
|
|
102
106
|
|
|
103
|
-
def write_body(io, codec, body
|
|
104
|
-
if
|
|
105
|
-
chunks.each { |chunk| codec.write(io, chunk) }
|
|
106
|
-
elsif body.respond_to?(:each)
|
|
107
|
+
def write_body(io, codec, body)
|
|
108
|
+
if body.respond_to?(:each)
|
|
107
109
|
body.each do |chunk|
|
|
108
110
|
codec.write(io, chunk)
|
|
109
111
|
codec.flush(io)
|
data/lib/shuttlebay/logger.rb
CHANGED
|
@@ -16,6 +16,11 @@ module Shuttlebay
|
|
|
16
16
|
define_method(level) { |msg, **fields| log(level, msg, **fields) }
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# This logger as `ship`'s: same stream, another ship name.
|
|
20
|
+
def for_ship(ship)
|
|
21
|
+
ship == @ship ? self : self.class.new(@io, ship: ship)
|
|
22
|
+
end
|
|
23
|
+
|
|
19
24
|
def log(level, msg, **fields)
|
|
20
25
|
line = JSON.generate({ level: level, msg: msg, ship: @ship, pid: Process.pid, **fields })
|
|
21
26
|
@io.write("#{line}\n")
|
data/lib/shuttlebay/railtie.rb
CHANGED
|
@@ -1,11 +1,52 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require 'rack/sendfile'
|
|
4
5
|
|
|
5
6
|
module Shuttlebay
|
|
7
|
+
# Under Mothership, some of a Rails app's middleware repeats work Mothership
|
|
8
|
+
# already does for its HTTP bays: the app's copy is removed. Other servers
|
|
9
|
+
# (rails server, Puma) keep the full stack.
|
|
10
|
+
#
|
|
11
|
+
# - Rack::Sendfile: Mothership serves `to_path` bodies itself.
|
|
12
|
+
# - ActionDispatch::Static: when Mothership serves the app's public/ at `/`
|
|
13
|
+
# for everything routed to the bay (it lists those in MS_STATIC_ROOTS).
|
|
14
|
+
# The app's `public_file_server.headers` Mothership does not send (per
|
|
15
|
+
# MS_STATIC_HEADERS) are logged: they belong in the manifest now.
|
|
16
|
+
# - Rack::ETag, Rack::ConditionalGet: when Mothership tags responses and
|
|
17
|
+
# answers revalidations (MS_CONDITIONAL_GET=1), off the app's GVL.
|
|
6
18
|
class Railtie < ::Rails::Railtie
|
|
7
19
|
initializer 'shuttlebay.mothership_serves_files' do |app|
|
|
8
|
-
|
|
20
|
+
next unless Shuttlebay.running?
|
|
21
|
+
|
|
22
|
+
middleware = app.config.middleware
|
|
23
|
+
middleware.delete(::Rack::Sendfile) if Shuttlebay.offloaded?(:sendfile)
|
|
24
|
+
public = app.paths['public'].first
|
|
25
|
+
if Shuttlebay.offloaded?(:static, public)
|
|
26
|
+
middleware.delete(::ActionDispatch::Static)
|
|
27
|
+
Shuttlebay::Railtie.warn_unsent_headers(public, app.config.public_file_server.headers)
|
|
28
|
+
end
|
|
29
|
+
if Shuttlebay.offloaded?(:conditional_get)
|
|
30
|
+
middleware.delete(::Rack::ETag)
|
|
31
|
+
middleware.delete(::Rack::ConditionalGet)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The app's `headers` for public/ files that Mothership, serving them
|
|
36
|
+
# now, does not send as given; logged once, at boot.
|
|
37
|
+
def self.warn_unsent_headers(public, headers, announced = ENV.fetch('MS_STATIC_HEADERS', '{}'))
|
|
38
|
+
unsent = unsent_headers(public, headers, announced)
|
|
39
|
+
return if unsent.empty?
|
|
40
|
+
|
|
41
|
+
Shuttlebay.logger.warn('public_file_server.headers are not sent by Mothership, which serves public/; ' \
|
|
42
|
+
'add them to the [[mothership.static_dirs]] headers', missing: unsent)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.unsent_headers(public, headers, announced)
|
|
46
|
+
sent = JSON.parse(announced).fetch(File.realpath(public), {})
|
|
47
|
+
(headers || {}).to_h { |name, value| [name.to_s.downcase, value.to_s] }.reject { |name, value| sent[name] == value }
|
|
48
|
+
rescue JSON::ParserError, SystemCallError
|
|
49
|
+
(headers || {}).to_h { |name, value| [name.to_s.downcase, value.to_s] }
|
|
9
50
|
end
|
|
10
51
|
end
|
|
11
52
|
end
|
data/lib/shuttlebay/runtime.rb
CHANGED
|
@@ -13,12 +13,16 @@ module Shuttlebay
|
|
|
13
13
|
class << self
|
|
14
14
|
attr_accessor :config
|
|
15
15
|
|
|
16
|
-
attr_writer :running, :logger
|
|
16
|
+
attr_writer :running, :logger, :warmup
|
|
17
17
|
|
|
18
18
|
def running?
|
|
19
19
|
@running == true
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def warmup?
|
|
23
|
+
@warmup != false
|
|
24
|
+
end
|
|
25
|
+
|
|
22
26
|
def logger
|
|
23
27
|
@logger ||= Logger.new
|
|
24
28
|
end
|
|
@@ -51,8 +55,46 @@ module Shuttlebay
|
|
|
51
55
|
@fiber_scheduler_factory = factory
|
|
52
56
|
end
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
# Run `name`'s hooks with `args`, each given only as many as it takes:
|
|
59
|
+
# `on_worker_boot { |index| }` and `{ |index, ship| }` both work, lambdas
|
|
60
|
+
# and methods included.
|
|
61
|
+
def run_hooks(name, *args)
|
|
62
|
+
hooks.fetch(name).each do |hook|
|
|
63
|
+
hook.call(*(hook.arity.negative? ? args : args.first(hook.arity)))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Whether Mothership does `concern` for this app, so its Rack middleware
|
|
68
|
+
# for it can go (the Railtie does this for Rails; elsewhere, in
|
|
69
|
+
# config.ru):
|
|
70
|
+
#
|
|
71
|
+
# use Rack::ETag unless Shuttlebay.offloaded?(:conditional_get)
|
|
72
|
+
# use Rack::Static, urls: [""], root: "public" unless Shuttlebay.offloaded?(:static, "public")
|
|
73
|
+
#
|
|
74
|
+
# - :sendfile: `to_path` bodies are streamed by Mothership (Rack::Sendfile)
|
|
75
|
+
# - :conditional_get: ETags and 304s (Rack::ETag, Rack::ConditionalGet)
|
|
76
|
+
# - :static, dir: `dir` is served at `/`, pages by name included
|
|
77
|
+
# (Rack::Static, ActionDispatch::Static)
|
|
78
|
+
#
|
|
79
|
+
# Always false outside Mothership.
|
|
80
|
+
def offloaded?(concern, dir = nil)
|
|
81
|
+
return false unless running?
|
|
82
|
+
|
|
83
|
+
case concern
|
|
84
|
+
when :sendfile then true
|
|
85
|
+
when :conditional_get then ENV['MS_CONDITIONAL_GET'] == '1'
|
|
86
|
+
when :static then static_root?(dir)
|
|
87
|
+
else raise ArgumentError, "unknown concern #{concern.inspect} (:sendfile, :conditional_get, :static)"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Whether Mothership serves `dir` at `/` (it lists those in MS_STATIC_ROOTS).
|
|
92
|
+
def static_root?(dir, roots = ENV.fetch('MS_STATIC_ROOTS', ''))
|
|
93
|
+
raise ArgumentError, 'Shuttlebay.offloaded?(:static, dir) needs the directory' if dir.nil?
|
|
94
|
+
|
|
95
|
+
roots.split("\n").include?(File.realpath(dir))
|
|
96
|
+
rescue SystemCallError
|
|
97
|
+
false
|
|
56
98
|
end
|
|
57
99
|
|
|
58
100
|
def clear_hooks!
|