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.
@@ -1,16 +1,18 @@
1
1
  use std::{cell::RefCell, fmt::Display};
2
2
 
3
- use magnus::{Error, RHash, RString, Ruby, Symbol, Value, prelude::*, scan_args::scan_args};
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, encode_ready,
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 name = std::str::from_utf8(name)
105
- .map_or_else(|_| ruby.str_from_slice(name), |name| ruby.str_new(name));
106
- env.aset(name, ruby.str_from_slice(value))?;
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
- env.aset("rack.url_scheme", if secure { "https" } else { "http" })?;
110
- env.aset("rack.input", input)?;
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
- let (request_id, _) = link::advance(&mut state.link, event, |from| {
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
- if expected > 0 {
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
+ }
@@ -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
@@ -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
@@ -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
- chunks = body.to_ary if kind == :stream && body.respond_to?(:to_ary)
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, chunks) if kind == :stream
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, chunks)
104
- if chunks
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)
@@ -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")
@@ -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
- app.config.middleware.delete(::Rack::Sendfile) if Shuttlebay.running?
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
@@ -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
- def run_hooks(name, *)
55
- hooks.fetch(name).each { |hook| hook.call(*) }
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!