shuttlebay 0.1.4

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,78 @@
1
+ use state_machines::state_machine;
2
+
3
+ #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
4
+ pub struct RequestId(u32);
5
+
6
+ state_machine! {
7
+ name: Link,
8
+ dynamic: true,
9
+ initial: Idle,
10
+ states: [
11
+ Idle,
12
+ superstate Exchange(RequestId) {
13
+ state Requested,
14
+ state Streaming,
15
+ state Replied,
16
+ state Tunnelling,
17
+ state Finished,
18
+ initial: Requested,
19
+ },
20
+ ],
21
+ events {
22
+ hail {
23
+ transition: { from: Idle, to: Exchange }
24
+ }
25
+ reply_stream {
26
+ transition: { from: Requested, to: Streaming }
27
+ }
28
+ reply_whole {
29
+ transition: { from: Requested, to: Replied }
30
+ }
31
+ reply_tunnel {
32
+ transition: { from: Requested, to: Tunnelling }
33
+ }
34
+ finish {
35
+ transition: { from: [Streaming, Replied], to: Finished }
36
+ }
37
+ ready {
38
+ transition: { from: Finished, to: Idle }
39
+ }
40
+ }
41
+ }
42
+
43
+ pub type Machine = DynamicLink<()>;
44
+
45
+ pub fn new() -> Machine {
46
+ DynamicLink::new(())
47
+ }
48
+
49
+ pub fn request_id(link: &Machine) -> Option<u32> {
50
+ link.exchange_data().map(|id| id.0)
51
+ }
52
+
53
+ pub fn begin(link: &mut Machine, request_id: u32) -> Result<(), &'static str> {
54
+ link.handle(LinkEvent::Hail).map_err(|_| "read_request called before the previous ready")?;
55
+ link.set_exchange_data(RequestId(request_id)).map_err(|_| "request id could not be recorded")
56
+ }
57
+
58
+ pub fn request_in(
59
+ link: &Machine,
60
+ wanted: LinkState,
61
+ without_request: &'static str,
62
+ elsewhere: &'static str,
63
+ ) -> Result<u32, &'static str> {
64
+ let request_id = request_id(link).ok_or(without_request)?;
65
+ if link.current_state() == wanted { Ok(request_id) } else { Err(elsewhere) }
66
+ }
67
+
68
+ pub fn advance(
69
+ link: &mut Machine,
70
+ event: LinkEvent,
71
+ refused: impl FnOnce(LinkState) -> &'static str,
72
+ ) -> Result<(u32, LinkState), &'static str> {
73
+ let from = link.current_state();
74
+ let refusal = refused(from);
75
+ let request_id = request_id(link).ok_or(refusal)?;
76
+ link.handle(event).map_err(|_| refusal)?;
77
+ Ok((request_id, from))
78
+ }
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rackup/handler'
4
+ require 'shuttlebay/attached'
5
+
6
+ module Rackup
7
+ module Handler
8
+ module Shuttlebay
9
+ def self.run(app, **)
10
+ server = ::Shuttlebay::Attached.new(app, **)
11
+ yield server if block_given?
12
+ server.run
13
+ end
14
+
15
+ def self.valid_options
16
+ {
17
+ 'Host=HOST' => 'Address Mothership listens on (default: 127.0.0.1)',
18
+ 'Port=PORT' => 'Port Mothership listens on (default: 9292)',
19
+ 'Threads=N' => 'Threads running the app (default: MS_BAY_THREADS or 3)'
20
+ }
21
+ end
22
+ end
23
+
24
+ register :shuttlebay, Shuttlebay
25
+ end
26
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'socket'
5
+ require 'tmpdir'
6
+ require_relative 'runtime'
7
+
8
+ module Shuttlebay
9
+ class Attached
10
+ # sun_path holds 104 bytes on macOS (108 on Linux), terminator included
11
+ SOCKET_PATH_MAX = 103
12
+
13
+ attr_reader :host, :port, :threads
14
+
15
+ def initialize(app, trap_signals: true, mothership: Attached.mothership_binary, logger: nil, **options)
16
+ @app = app
17
+ @host = options.fetch(:Host, '127.0.0.1').to_s
18
+ @port = Integer(options.fetch(:Port, 9292))
19
+ @threads = Attached.threads(options[:Threads])
20
+ @silent = options.fetch(:Silent, false)
21
+ @trap_signals = trap_signals
22
+ @mothership = mothership
23
+ @logger = logger || (@silent ? Logger.new(File.open(File::NULL, 'w')) : Shuttlebay.logger)
24
+ end
25
+
26
+ def run
27
+ @dir, socket = Attached.socket_dir
28
+ listener = UNIXServer.new(socket)
29
+ manifest = File.join(@dir, 'ship-manifest.toml')
30
+ File.write(manifest, manifest_toml(socket))
31
+
32
+ @worker = Worker.new(app: @app, listener: listener, threads: threads, workers: 0, ship: 'app',
33
+ logger: @logger)
34
+ Shuttlebay.running = true
35
+ @mothership_pid = spawn_mothership(manifest)
36
+ watch_mothership
37
+ at_exit { stop_mothership } unless @trap_signals
38
+ @logger.info('serving through mothership', url: url, threads: threads)
39
+ @worker.run(trap_signals: @trap_signals)
40
+ raise Error, "mothership exited (#{@mothership_exit}); see its output above" if @mothership_exit
41
+ ensure
42
+ stop_mothership
43
+ FileUtils.rm_rf(@dir) if @dir
44
+ end
45
+
46
+ def stop
47
+ @worker&.stop!
48
+ end
49
+
50
+ def self.mothership_binary(env = ENV)
51
+ env.fetch('MOTHERSHIP_BIN') do
52
+ Gem.bin_path('guardship', 'mothership')
53
+ rescue Gem::Exception
54
+ 'mothership'
55
+ end
56
+ end
57
+
58
+ # A private directory for the app socket: under $TMPDIR (Dir.tmpdir),
59
+ # unless that makes the socket path too long for a Unix socket.
60
+ def self.socket_dir(roots = [Dir.tmpdir, '/tmp'])
61
+ roots.uniq.each do |root|
62
+ dir = Dir.mktmpdir('sb', root)
63
+ socket = File.join(dir, 'app.sock')
64
+ return [dir, socket] if socket.bytesize <= SOCKET_PATH_MAX
65
+
66
+ FileUtils.rm_rf(dir)
67
+ rescue SystemCallError
68
+ next
69
+ end
70
+ raise Error, "no temp directory gives a Unix socket path under #{SOCKET_PATH_MAX} bytes; " \
71
+ 'set TMPDIR to a shorter one'
72
+ end
73
+
74
+ def self.threads(option, env = ENV)
75
+ value = option || env.fetch('MS_BAY_THREADS', '3')
76
+ count = Integer(value.to_s, 10)
77
+ raise ArgumentError, "Threads must be at least 1 (got #{value.inspect})" if count < 1
78
+
79
+ count
80
+ end
81
+
82
+ private
83
+
84
+ def url
85
+ "http://#{address}"
86
+ end
87
+
88
+ def address
89
+ "#{host.include?(':') ? "[#{host}]" : host}:#{port}"
90
+ end
91
+
92
+ def manifest_toml(socket)
93
+ <<~TOML
94
+ [mothership.bind]
95
+ http = #{address.dump}
96
+
97
+ [[mothership.upstreams]]
98
+ name = "app"
99
+ bind = #{"unix://#{socket}".dump}
100
+ protocol = "docking"
101
+ routes = [{ bind = "http", pattern = "/.*" }]
102
+ TOML
103
+ end
104
+
105
+ def spawn_mothership(manifest)
106
+ output = @silent ? File::NULL : $stdout
107
+ Process.spawn(@mothership, 'run', '-c', manifest, out: output, err: @silent ? File::NULL : $stderr)
108
+ rescue SystemCallError => e
109
+ raise Error, "cannot start mothership (#{@mothership}): #{e.message}. " \
110
+ 'Install the guardship gem or set MOTHERSHIP_BIN.'
111
+ end
112
+
113
+ def watch_mothership
114
+ pid = @mothership_pid
115
+ @watcher = Thread.new do
116
+ _, status = Process.wait2(pid)
117
+ next unless @mothership_pid
118
+
119
+ @mothership_pid = nil
120
+ @mothership_exit = status.exitstatus ? "exit #{status.exitstatus}" : "signal #{status.termsig}"
121
+ @logger.error('mothership exited', status: @mothership_exit)
122
+ stop
123
+ rescue Errno::ECHILD
124
+ nil
125
+ end
126
+ end
127
+
128
+ def stop_mothership
129
+ pid = @mothership_pid
130
+ @mothership_pid = nil
131
+ return unless pid
132
+
133
+ Process.kill('TERM', pid)
134
+ @watcher&.join(30) || Process.wait(pid)
135
+ rescue Errno::ESRCH, Errno::ECHILD
136
+ nil
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara'
4
+ require_relative 'attached'
5
+
6
+ Capybara.register_server(:shuttlebay) do |app, port, host, **options|
7
+ Shuttlebay::Attached.new(app, Host: host, Port: port, Silent: true, trap_signals: false, **options).run
8
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ ruby_abi = RUBY_VERSION[/\A\d+\.\d+/]
5
+ require_relative "#{ruby_abi}/shuttlebay"
6
+ rescue LoadError
7
+ begin
8
+ require_relative 'shuttlebay'
9
+ rescue LoadError => e
10
+ raise LoadError, "shuttlebay native engine is missing (#{e.message}). " \
11
+ 'Install a platform shuttlebay gem, or run `bundle exec rake compile` in a checkout.'
12
+ end
13
+ end
14
+
15
+ require 'tempfile'
16
+
17
+ module Shuttlebay
18
+ module Engine
19
+ def self.spool_file
20
+ Tempfile.new('shuttlebay-body', binmode: true).tap(&:unlink)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shuttlebay
4
+ class Handler
5
+ BODYLESS_STATUS = ->(status) { status < 200 || status == 204 || status == 304 }
6
+
7
+ LINK_LOST = [IOError, Errno::EPIPE, Errno::ECONNRESET, Errno::ENOTCONN, Errno::EBADF].freeze
8
+
9
+ INTERNAL_ERROR_BODY = 'Internal Server Error'
10
+
11
+ def initialize(app, template:, logger: Shuttlebay.logger)
12
+ @app = app
13
+ @template = template
14
+ @logger = logger
15
+ end
16
+
17
+ def serve(io, codec, stopping: -> { false }, after_request: -> {})
18
+ until stopping.call
19
+ env = codec.read_request(io, @template)
20
+ break unless env
21
+
22
+ outcome = handle(io, codec, env)
23
+ return :detached if outcome == :detached
24
+ break unless outcome
25
+
26
+ run_after_request(after_request)
27
+ codec.ready(io)
28
+ end
29
+ end
30
+
31
+ def handle(io, codec, env)
32
+ env['rack.response_finished'] = finished = []
33
+ status = headers = body = error = nil
34
+ tunnel = offer_hijack(env)
35
+
36
+ begin
37
+ status, headers, body = @app.call(env)
38
+ return detach(io, codec, tunnel.tap { tunnel = nil }) if tunnel && env['rack.hijack_io']
39
+
40
+ status = Integer(status)
41
+ rescue StandardError, ScriptError => e
42
+ error = e
43
+ @logger.error('request failed', path: env['PATH_INFO'], **Logger.error_fields(e))
44
+ return internal_error(io, codec)
45
+ end
46
+
47
+ kept, error = respond(io, codec, env, status, headers, body)
48
+ kept
49
+ ensure
50
+ tunnel&.discard
51
+ close_body(body)
52
+ run_finished(finished, env, status, headers, error)
53
+ close_input(env)
54
+ end
55
+
56
+ private
57
+
58
+ def offer_hijack(env)
59
+ return unless env['HTTP_UPGRADE']
60
+
61
+ tunnel = Tunnel.new(logger: @logger)
62
+ env['rack.hijack?'] = true
63
+ env['rack.hijack'] = lambda do
64
+ env['rack.hijack_io'] = tunnel.app_io
65
+ end
66
+ tunnel
67
+ end
68
+
69
+ def detach(io, codec, tunnel)
70
+ Thread.new { tunnel.start(io, codec) }
71
+ :detached
72
+ end
73
+
74
+ def respond(io, codec, env, status, headers, body)
75
+ head_sent = false
76
+ kind, path = reply_kind(env, status, body)
77
+ chunks = body.to_ary if kind == :stream && body.respond_to?(:to_ary)
78
+
79
+ codec.reply(io, status, headers, kind, path)
80
+ head_sent = true
81
+ write_body(io, codec, body, chunks) if kind == :stream
82
+ codec.finish(io)
83
+ [true, nil]
84
+ rescue *LINK_LOST => e
85
+ @logger.debug('link lost mid-response', **Logger.error_fields(e))
86
+ [false, e]
87
+ rescue StandardError, ScriptError => e
88
+ @logger.error('response failed', path: env['PATH_INFO'], head_sent: head_sent, **Logger.error_fields(e))
89
+ [head_sent ? false : internal_error(io, codec), e]
90
+ end
91
+
92
+ def reply_kind(env, status, body)
93
+ return [:empty, nil] if env['REQUEST_METHOD'] == 'HEAD' || BODYLESS_STATUS.call(status)
94
+
95
+ if status == 200 && body.respond_to?(:to_path)
96
+ path = File.expand_path(body.to_path)
97
+ return [:file, path] if File.file?(path) && File.readable?(path)
98
+ end
99
+
100
+ [:stream, nil]
101
+ end
102
+
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
+ body.each do |chunk|
108
+ codec.write(io, chunk)
109
+ codec.flush(io)
110
+ end
111
+ else
112
+ body.call(Stream.new(io, codec))
113
+ end
114
+ end
115
+
116
+ def internal_error(io, codec)
117
+ codec.reply(io, 500, { 'content-type' => 'text/plain' }, :stream)
118
+ codec.write(io, INTERNAL_ERROR_BODY)
119
+ codec.finish(io)
120
+ true
121
+ rescue *LINK_LOST, Engine::ProtocolError
122
+ false
123
+ end
124
+
125
+ def close_input(env)
126
+ input = env['rack.input']
127
+ input.close if input.respond_to?(:close)
128
+ rescue StandardError => e
129
+ @logger.error('rack.input close failed', **Logger.error_fields(e))
130
+ end
131
+
132
+ def close_body(body)
133
+ body.close if body.respond_to?(:close)
134
+ rescue StandardError => e
135
+ @logger.error('body close failed', **Logger.error_fields(e))
136
+ end
137
+
138
+ def run_after_request(hook)
139
+ hook.call
140
+ rescue StandardError => e
141
+ @logger.error('after-request hook failed', **Logger.error_fields(e))
142
+ end
143
+
144
+ def run_finished(callbacks, env, status, headers, error)
145
+ callbacks.reverse_each do |callback|
146
+ callback.call(env, status, headers, error)
147
+ rescue StandardError => e
148
+ @logger.error('rack.response_finished callback failed', **Logger.error_fields(e))
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Shuttlebay
6
+ class Logger
7
+ LEVELS = %i[debug info warn error].freeze
8
+
9
+ def initialize(io = $stdout, ship: ENV.fetch('MS_SHIP', 'rack'))
10
+ @io = io
11
+ @io.sync = true
12
+ @ship = ship
13
+ end
14
+
15
+ LEVELS.each do |level|
16
+ define_method(level) { |msg, **fields| log(level, msg, **fields) }
17
+ end
18
+
19
+ def log(level, msg, **fields)
20
+ line = JSON.generate({ level: level, msg: msg, ship: @ship, pid: Process.pid, **fields })
21
+ @io.write("#{line}\n")
22
+ end
23
+
24
+ def self.error_fields(error)
25
+ { error: error.class.name, message: error.message, backtrace: error.backtrace.first(10) }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shuttlebay
4
+ module ProcessControl
5
+ private
6
+
7
+ def trap_signals(signals, wake_w)
8
+ signals.to_h do |signal, code|
9
+ [signal, trap(signal) { wake_w.write_nonblock(code, exception: false) }]
10
+ end
11
+ end
12
+
13
+ def close_self_pipe(previous, wake_r, wake_w)
14
+ previous&.each { |signal, handler| trap(signal, handler || 'DEFAULT') }
15
+ [wake_r, wake_w].compact.each(&:close)
16
+ end
17
+
18
+ def run_worker_process(index, signals)
19
+ signals.each_key { |signal| trap(signal, 'DEFAULT') }
20
+ yield.run
21
+ $stdout.flush
22
+ exit!(0)
23
+ rescue StandardError, ScriptError => e
24
+ @logger.error('worker crashed', index: index, **Logger.error_fields(e))
25
+ $stdout.flush
26
+ exit!(1)
27
+ end
28
+
29
+ def monotonic
30
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/sendfile'
4
+
5
+ module Shuttlebay
6
+ class Railtie < ::Rails::Railtie
7
+ initializer 'shuttlebay.mothership_serves_files' do |app|
8
+ app.config.middleware.delete(::Rack::Sendfile) if Shuttlebay.running?
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require_relative 'version'
5
+ require_relative 'engine'
6
+ require_relative 'logger'
7
+
8
+ module Shuttlebay
9
+ class Error < StandardError; end
10
+
11
+ HOOKS = %i[before_fork worker_boot worker_shutdown out_of_band].freeze
12
+
13
+ class << self
14
+ attr_accessor :config
15
+
16
+ attr_writer :running, :logger
17
+
18
+ def running?
19
+ @running == true
20
+ end
21
+
22
+ def logger
23
+ @logger ||= Logger.new
24
+ end
25
+
26
+ def before_fork(&block)
27
+ register_hook(:before_fork, block)
28
+ end
29
+
30
+ def on_worker_boot(&block)
31
+ register_hook(:worker_boot, block)
32
+ end
33
+
34
+ def on_worker_shutdown(&block)
35
+ register_hook(:worker_shutdown, block)
36
+ end
37
+
38
+ def out_of_band(&block)
39
+ register_hook(:out_of_band, block)
40
+ end
41
+
42
+ attr_reader :fiber_scheduler_factory, :fiber_reactors
43
+
44
+ def fiber_scheduler(reactors: 1, &factory)
45
+ raise ArgumentError, 'Shuttlebay.fiber_scheduler needs a block that builds a Fiber scheduler' unless factory
46
+
47
+ count = Integer(reactors)
48
+ raise ArgumentError, "Shuttlebay.fiber_scheduler needs reactors >= 1 (got #{reactors.inspect})" if count < 1
49
+
50
+ @fiber_reactors = count
51
+ @fiber_scheduler_factory = factory
52
+ end
53
+
54
+ def run_hooks(name, *)
55
+ hooks.fetch(name).each { |hook| hook.call(*) }
56
+ end
57
+
58
+ def clear_hooks!
59
+ HOOKS.each { |name| hooks[name].clear }
60
+ @fiber_scheduler_factory = @fiber_reactors = nil
61
+ end
62
+
63
+ private
64
+
65
+ def hooks
66
+ @hooks ||= HOOKS.to_h { |name| [name, []] }
67
+ end
68
+
69
+ def register_hook(name, block)
70
+ raise ArgumentError, "Shuttlebay.#{name} needs a block" unless block
71
+
72
+ hooks.fetch(name) << block
73
+ block
74
+ end
75
+ end
76
+ end
77
+
78
+ require_relative 'stream'
79
+ require_relative 'tunnel'
80
+ require_relative 'handler'
81
+ require_relative 'process_control'
82
+ require_relative 'worker'
83
+ require_relative 'server'