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.
- checksums.yaml +7 -0
- data/Cargo.lock +416 -0
- data/Cargo.toml +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +191 -0
- data/exe/shuttlebay +12 -0
- data/ext/shuttlebay/Cargo.toml +31 -0
- data/ext/shuttlebay/extconf.rb +22 -0
- data/ext/shuttlebay/src/codec.rs +477 -0
- data/ext/shuttlebay/src/headers.rs +34 -0
- data/ext/shuttlebay/src/lib.rs +94 -0
- data/ext/shuttlebay/src/link.rs +78 -0
- data/lib/rackup/handler/shuttlebay.rb +26 -0
- data/lib/shuttlebay/attached.rb +139 -0
- data/lib/shuttlebay/capybara.rb +8 -0
- data/lib/shuttlebay/engine.rb +23 -0
- data/lib/shuttlebay/handler.rb +152 -0
- data/lib/shuttlebay/logger.rb +28 -0
- data/lib/shuttlebay/process_control.rb +33 -0
- data/lib/shuttlebay/railtie.rb +11 -0
- data/lib/shuttlebay/runtime.rb +83 -0
- data/lib/shuttlebay/server.rb +199 -0
- data/lib/shuttlebay/stream.rb +56 -0
- data/lib/shuttlebay/tunnel.rb +95 -0
- data/lib/shuttlebay/version.rb +5 -0
- data/lib/shuttlebay/worker.rb +166 -0
- data/lib/shuttlebay.rb +4 -0
- metadata +187 -0
|
@@ -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'
|