shuttlebay 0.1.4-aarch64-linux
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/LICENSE.txt +21 -0
- data/README.md +191 -0
- data/exe/shuttlebay +12 -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/shuttlebay.so +0 -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 +171 -0
|
@@ -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'
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
|
|
5
|
+
module Shuttlebay
|
|
6
|
+
class Server
|
|
7
|
+
include ProcessControl
|
|
8
|
+
|
|
9
|
+
RESPAWN_MIN = 0.1
|
|
10
|
+
RESPAWN_MAX = 5.0
|
|
11
|
+
STABLE_AFTER = 30.0
|
|
12
|
+
SIGNALS = { 'TERM' => 'T', 'INT' => 'I', 'QUIT' => 'Q', 'CHLD' => 'C' }.freeze
|
|
13
|
+
|
|
14
|
+
Slot = Struct.new(:index, :pid, :started_at, :backoff, :respawn_at)
|
|
15
|
+
|
|
16
|
+
attr_reader :app_path, :socket_path, :workers, :threads, :ship
|
|
17
|
+
|
|
18
|
+
def self.from_env(argv, env = ENV)
|
|
19
|
+
socket_path = env.fetch('MS_SOCKET_PATH') do
|
|
20
|
+
raise ArgumentError, 'MS_SOCKET_PATH is not set: shuttlebay runs as a mothership [[bays.http]] bay'
|
|
21
|
+
end
|
|
22
|
+
new(
|
|
23
|
+
app_path: argv.first || 'config.ru',
|
|
24
|
+
socket_path: socket_path,
|
|
25
|
+
workers: Integer(env.fetch('MS_BAY_WORKERS', '0'), 10),
|
|
26
|
+
threads: Integer(env.fetch('MS_BAY_THREADS', '3'), 10),
|
|
27
|
+
ship: env.fetch('MS_SHIP', 'rack')
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(app_path:, socket_path:, workers:, threads:, ship:, logger: Shuttlebay.logger)
|
|
32
|
+
raise ArgumentError, "workers must be >= 0 (got #{workers})" if workers.negative?
|
|
33
|
+
raise ArgumentError, "threads must be >= 1 (got #{threads})" if threads < 1
|
|
34
|
+
|
|
35
|
+
@app_path = app_path
|
|
36
|
+
@socket_path = socket_path
|
|
37
|
+
@workers = workers
|
|
38
|
+
@threads = threads
|
|
39
|
+
@ship = ship
|
|
40
|
+
@logger = logger
|
|
41
|
+
@master_pid = Process.pid
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run
|
|
45
|
+
Shuttlebay.running = true
|
|
46
|
+
app = load_app
|
|
47
|
+
@listener = bind
|
|
48
|
+
@logger.info('app loaded, socket bound', app: app_path, socket: socket_path,
|
|
49
|
+
workers: workers, threads: threads)
|
|
50
|
+
|
|
51
|
+
if workers.zero?
|
|
52
|
+
worker(app, index: nil).run
|
|
53
|
+
else
|
|
54
|
+
supervise(app)
|
|
55
|
+
end
|
|
56
|
+
ensure
|
|
57
|
+
cleanup if Process.pid == @master_pid
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def load_app
|
|
63
|
+
require 'rack'
|
|
64
|
+
require 'rack/builder'
|
|
65
|
+
path = File.expand_path(app_path)
|
|
66
|
+
raise ArgumentError, "Rack app not found: #{path}" unless File.file?(path)
|
|
67
|
+
|
|
68
|
+
Rack::Builder.parse_file(path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def bind
|
|
72
|
+
if File.exist?(socket_path)
|
|
73
|
+
raise ArgumentError, "#{socket_path} exists and is not a socket" unless File.socket?(socket_path)
|
|
74
|
+
|
|
75
|
+
File.unlink(socket_path)
|
|
76
|
+
end
|
|
77
|
+
UNIXServer.new(socket_path).tap { |server| server.listen(1024) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def worker(app, index:)
|
|
81
|
+
Worker.new(app: app, listener: @listener, threads: threads, workers: workers, ship: ship,
|
|
82
|
+
index: index, master_pid: index && @master_pid, logger: @logger)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def supervise(app)
|
|
86
|
+
wake_r, wake_w = IO.pipe
|
|
87
|
+
previous = trap_signals(SIGNALS, wake_w)
|
|
88
|
+
@app = app
|
|
89
|
+
Shuttlebay.run_hooks(:before_fork)
|
|
90
|
+
@slots = Array.new(workers) { |index| Slot.new(index, nil, nil, RESPAWN_MIN, monotonic) }
|
|
91
|
+
@stopping = false
|
|
92
|
+
|
|
93
|
+
until @stopping && @slots.none?(&:pid)
|
|
94
|
+
spawn_due unless @stopping
|
|
95
|
+
reap
|
|
96
|
+
wait_for_signal(wake_r)
|
|
97
|
+
end
|
|
98
|
+
@logger.info('all workers stopped')
|
|
99
|
+
ensure
|
|
100
|
+
close_self_pipe(previous, wake_r, wake_w)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def wait_for_signal(wake_r)
|
|
104
|
+
ready, = IO.select([wake_r], nil, nil, next_timeout)
|
|
105
|
+
codes = ready ? wake_r.read_nonblock(1024, exception: false) : nil
|
|
106
|
+
stop_code = codes.delete('C')[0] if codes.is_a?(String)
|
|
107
|
+
begin_stop(SIGNALS.key(stop_code)) if stop_code
|
|
108
|
+
stop_overdue if @stopping
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def next_timeout
|
|
112
|
+
return 0.2 if @stopping
|
|
113
|
+
|
|
114
|
+
due = @slots.reject(&:pid).map(&:respawn_at).min
|
|
115
|
+
due ? [[due - monotonic, 0.0].max, 1.0].min : 1.0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def spawn_due
|
|
119
|
+
now = monotonic
|
|
120
|
+
@slots.each do |slot|
|
|
121
|
+
next if slot.pid || slot.respawn_at > now
|
|
122
|
+
|
|
123
|
+
slot.pid = spawn_worker(slot.index)
|
|
124
|
+
slot.started_at = now
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def spawn_worker(index)
|
|
129
|
+
app = @app
|
|
130
|
+
fork { run_worker_process(index, SIGNALS) { worker(app, index: index) } }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def reap
|
|
134
|
+
loop do
|
|
135
|
+
pid, status = Process.wait2(-1, Process::WNOHANG)
|
|
136
|
+
break unless pid
|
|
137
|
+
|
|
138
|
+
exited(pid, status)
|
|
139
|
+
end
|
|
140
|
+
rescue Errno::ECHILD
|
|
141
|
+
@slots.each { |slot| slot.pid = nil }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def exited(pid, status)
|
|
145
|
+
slot = @slots.find { |candidate| candidate.pid == pid }
|
|
146
|
+
return unless slot
|
|
147
|
+
|
|
148
|
+
uptime = monotonic - slot.started_at
|
|
149
|
+
slot.pid = nil
|
|
150
|
+
slot.backoff = RESPAWN_MIN if uptime >= STABLE_AFTER
|
|
151
|
+
delay = slot.backoff
|
|
152
|
+
slot.backoff = [slot.backoff * 2, RESPAWN_MAX].min
|
|
153
|
+
slot.respawn_at = monotonic + delay
|
|
154
|
+
return if @stopping
|
|
155
|
+
|
|
156
|
+
@logger.warn('worker exited, respawning', index: slot.index, pid: pid,
|
|
157
|
+
status: status && (status.exitstatus || status.termsig),
|
|
158
|
+
uptime: uptime.round(3), respawn_in: delay)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def begin_stop(signal)
|
|
162
|
+
return if @stopping
|
|
163
|
+
|
|
164
|
+
@stopping = true
|
|
165
|
+
@stop_deadline = monotonic + Worker.stop_grace + 0.5
|
|
166
|
+
@logger.info('stopping workers', signal: signal)
|
|
167
|
+
signal_workers('TERM')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def stop_overdue
|
|
171
|
+
return unless monotonic >= @stop_deadline && running_pids.any?
|
|
172
|
+
|
|
173
|
+
@logger.warn('workers did not stop in time, killing', pids: running_pids)
|
|
174
|
+
signal_workers('KILL')
|
|
175
|
+
@stop_deadline = Float::INFINITY
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def running_pids
|
|
179
|
+
@slots.filter_map(&:pid)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def signal_workers(signal)
|
|
183
|
+
running_pids.each { |pid| send_signal(pid, signal) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def send_signal(pid, signal)
|
|
187
|
+
Process.kill(signal, pid)
|
|
188
|
+
rescue Errno::ESRCH
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def cleanup
|
|
193
|
+
@listener&.close
|
|
194
|
+
File.unlink(socket_path) if socket_path && File.socket?(socket_path)
|
|
195
|
+
rescue SystemCallError => e
|
|
196
|
+
@logger.warn('socket cleanup failed', socket: socket_path, error: e.message)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shuttlebay
|
|
4
|
+
class Stream
|
|
5
|
+
def initialize(io, codec)
|
|
6
|
+
@io = io
|
|
7
|
+
@codec = codec
|
|
8
|
+
@write_closed = false
|
|
9
|
+
@read_closed = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def write(data)
|
|
13
|
+
raise IOError, 'closed stream' if @write_closed
|
|
14
|
+
|
|
15
|
+
data = String(data)
|
|
16
|
+
@codec.write(@io, data)
|
|
17
|
+
@codec.flush(@io)
|
|
18
|
+
data.bytesize
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def <<(data)
|
|
22
|
+
write(data)
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def flush
|
|
27
|
+
@codec.flush(@io) unless @write_closed
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read(_length = nil, _buffer = nil)
|
|
32
|
+
raise IOError, 'closed stream' if @read_closed
|
|
33
|
+
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def close_read
|
|
38
|
+
@read_closed = true
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def close_write
|
|
43
|
+
@write_closed = true
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def close
|
|
48
|
+
close_read
|
|
49
|
+
close_write
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def closed?
|
|
53
|
+
@read_closed && @write_closed
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
|
|
5
|
+
module Shuttlebay
|
|
6
|
+
class Tunnel
|
|
7
|
+
MAX_HEAD = 64 * 1024
|
|
8
|
+
HEAD_TIMEOUT = 10
|
|
9
|
+
READ_CHUNK = 16 * 1024
|
|
10
|
+
HEAD_END = /\r?\n\r?\n/
|
|
11
|
+
|
|
12
|
+
class InvalidHead < StandardError; end
|
|
13
|
+
|
|
14
|
+
attr_reader :app_io
|
|
15
|
+
|
|
16
|
+
def initialize(logger: Shuttlebay.logger)
|
|
17
|
+
@app_io, @engine_io = UNIXSocket.pair
|
|
18
|
+
@logger = logger
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def discard
|
|
22
|
+
[@app_io, @engine_io].each(&:close)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start(io, codec)
|
|
26
|
+
status, headers, rest = read_head
|
|
27
|
+
codec.reply(io, status, headers, :tunnel)
|
|
28
|
+
codec.flush(io)
|
|
29
|
+
codec.tunnel_write(io, rest) unless rest.empty?
|
|
30
|
+
relay(io, codec)
|
|
31
|
+
rescue InvalidHead, *Handler::LINK_LOST, Engine::ProtocolError => e
|
|
32
|
+
@logger.warn('hijacked connection ended before its tunnel opened', **Logger.error_fields(e))
|
|
33
|
+
close(io)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def relay(io, codec)
|
|
39
|
+
to_client = Thread.new do
|
|
40
|
+
begin
|
|
41
|
+
loop { codec.tunnel_write(io, @engine_io.readpartial(READ_CHUNK)) }
|
|
42
|
+
rescue EOFError
|
|
43
|
+
codec.tunnel_close(io)
|
|
44
|
+
end
|
|
45
|
+
rescue *Handler::LINK_LOST, Engine::ProtocolError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
Thread.new do
|
|
49
|
+
while (data = codec.tunnel_read(io))
|
|
50
|
+
@engine_io.write(data)
|
|
51
|
+
end
|
|
52
|
+
@engine_io.close_write
|
|
53
|
+
rescue *Handler::LINK_LOST, Engine::ProtocolError
|
|
54
|
+
nil
|
|
55
|
+
ensure
|
|
56
|
+
to_client.join(1)
|
|
57
|
+
close(io)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read_head
|
|
62
|
+
buffer = +''.b
|
|
63
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HEAD_TIMEOUT
|
|
64
|
+
until (split = buffer =~ HEAD_END)
|
|
65
|
+
raise InvalidHead, "no response head within #{MAX_HEAD} bytes" if buffer.bytesize > MAX_HEAD
|
|
66
|
+
|
|
67
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
68
|
+
raise InvalidHead, "app wrote no response head within #{HEAD_TIMEOUT}s" unless left.positive? &&
|
|
69
|
+
@engine_io.wait_readable(left)
|
|
70
|
+
|
|
71
|
+
buffer << @engine_io.readpartial(READ_CHUNK)
|
|
72
|
+
end
|
|
73
|
+
[*parse_head(buffer.byteslice(0, split)), buffer.byteslice((split + Regexp.last_match(0).bytesize)..)]
|
|
74
|
+
rescue EOFError
|
|
75
|
+
raise InvalidHead, 'app closed the hijacked connection without a response head'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_head(head)
|
|
79
|
+
status_line, *lines = head.split(/\r?\n/)
|
|
80
|
+
status = status_line[%r{\AHTTP/1\.[01] (\d{3})}, 1] or raise InvalidHead, "bad status line #{status_line.inspect}"
|
|
81
|
+
headers = Hash.new { |hash, key| hash[key] = [] }
|
|
82
|
+
lines.each do |line|
|
|
83
|
+
name, value = line.split(':', 2)
|
|
84
|
+
raise InvalidHead, "bad header line #{line.inspect}" unless value
|
|
85
|
+
|
|
86
|
+
headers[name.strip.downcase] << value.strip
|
|
87
|
+
end
|
|
88
|
+
[Integer(status, 10), headers.to_h]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def close(io)
|
|
92
|
+
[@engine_io, io].each(&:close)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shuttlebay
|
|
4
|
+
class Worker
|
|
5
|
+
include ProcessControl
|
|
6
|
+
|
|
7
|
+
DEFAULT_STOP_GRACE = 4.0
|
|
8
|
+
SIGNALS = { 'TERM' => '.', 'INT' => '.', 'QUIT' => '.' }.freeze
|
|
9
|
+
|
|
10
|
+
def self.stop_grace(env = ENV)
|
|
11
|
+
Float(env.fetch('MS_BAY_DRAIN_TIMEOUT', DEFAULT_STOP_GRACE))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(app:, listener:, threads:, workers:, ship:, index: nil, master_pid: nil,
|
|
15
|
+
logger: Shuttlebay.logger)
|
|
16
|
+
@app = app
|
|
17
|
+
@listener = listener
|
|
18
|
+
@threads = threads
|
|
19
|
+
@workers = workers
|
|
20
|
+
@ship = ship
|
|
21
|
+
@index = index
|
|
22
|
+
@master_pid = master_pid
|
|
23
|
+
@logger = logger
|
|
24
|
+
@handler = Handler.new(app, template: Worker.template(threads: threads, workers: workers), logger: logger)
|
|
25
|
+
@links = {}
|
|
26
|
+
@links_lock = Mutex.new
|
|
27
|
+
@stopping = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.template(threads:, workers:)
|
|
31
|
+
{
|
|
32
|
+
'SCRIPT_NAME' => '',
|
|
33
|
+
'rack.errors' => $stderr,
|
|
34
|
+
'rack.multithread' => threads > 1,
|
|
35
|
+
'rack.multiprocess' => workers > 1,
|
|
36
|
+
'rack.run_once' => false,
|
|
37
|
+
'rack.hijack?' => false
|
|
38
|
+
}.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run(trap_signals: true)
|
|
42
|
+
@wake_r, @wake_w = IO.pipe
|
|
43
|
+
previous = trap_signals(SIGNALS, @wake_w) if trap_signals
|
|
44
|
+
Shuttlebay.run_hooks(:worker_boot, @index) if forked?
|
|
45
|
+
pool = spawn_pool
|
|
46
|
+
@logger.info('worker serving', index: @index, threads: @threads, reactors: @reactors)
|
|
47
|
+
|
|
48
|
+
wait_for_stop
|
|
49
|
+
stop(pool)
|
|
50
|
+
raise @reactor_error if @reactor_error
|
|
51
|
+
ensure
|
|
52
|
+
close_self_pipe(previous, @wake_r, @wake_w)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stop!
|
|
56
|
+
@wake_w&.write_nonblock('.', exception: false)
|
|
57
|
+
rescue IOError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def forked?
|
|
64
|
+
!@master_pid.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def spawn_pool
|
|
68
|
+
factory = Shuttlebay.fiber_scheduler_factory
|
|
69
|
+
return Array.new(@threads) { |slot| Thread.new { accept_loop(slot) } } unless factory
|
|
70
|
+
|
|
71
|
+
reactors = (0...@threads).group_by { |slot| slot % Shuttlebay.fiber_reactors }.values
|
|
72
|
+
@reactors = reactors.size
|
|
73
|
+
reactors.map { |slots| Thread.new { run_fibers(factory, slots) } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def run_fibers(factory, slots)
|
|
77
|
+
Fiber.set_scheduler(factory.call)
|
|
78
|
+
slots.each { |slot| Fiber.schedule { accept_loop(slot) } }
|
|
79
|
+
Fiber.set_scheduler(nil)
|
|
80
|
+
rescue StandardError, ScriptError => e
|
|
81
|
+
@reactor_error = e
|
|
82
|
+
stop!
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def accept_loop(slot)
|
|
86
|
+
until @stopping
|
|
87
|
+
io = @listener.accept
|
|
88
|
+
serve_link(io)
|
|
89
|
+
end
|
|
90
|
+
rescue IOError, Errno::EBADF, Errno::EINVAL
|
|
91
|
+
nil
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
@logger.error('accept loop crashed', slot: slot, **Logger.error_fields(e))
|
|
94
|
+
return if @stopping
|
|
95
|
+
|
|
96
|
+
sleep 0.1
|
|
97
|
+
retry
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
LINK_BUFFER = 512 * 1024
|
|
101
|
+
|
|
102
|
+
def serve_link(io)
|
|
103
|
+
widen_buffers(io)
|
|
104
|
+
codec = Engine::Codec.new
|
|
105
|
+
@links_lock.synchronize { @links[io] = codec }
|
|
106
|
+
config = codec.dock(io, @ship, @threads)
|
|
107
|
+
Shuttlebay.config ||= config
|
|
108
|
+
outcome = @handler.serve(io, codec, stopping: -> { @stopping }, after_request: -> { out_of_band(codec) })
|
|
109
|
+
rescue Engine::ProtocolError => e
|
|
110
|
+
@logger.warn('docking link protocol error', **Logger.error_fields(e))
|
|
111
|
+
rescue *Handler::LINK_LOST
|
|
112
|
+
nil
|
|
113
|
+
ensure
|
|
114
|
+
@links_lock.synchronize { @links.delete(io) }
|
|
115
|
+
io.close unless outcome == :detached
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def widen_buffers(io)
|
|
119
|
+
io.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, LINK_BUFFER)
|
|
120
|
+
io.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, LINK_BUFFER)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def wait_for_stop
|
|
124
|
+
loop do
|
|
125
|
+
return if @wake_r.wait_readable(1.0)
|
|
126
|
+
return @logger.warn('master is gone, stopping worker', index: @index) if orphaned?
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def orphaned?
|
|
131
|
+
forked? && Process.ppid != @master_pid
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def stop(pool)
|
|
135
|
+
@stopping = true
|
|
136
|
+
@listener.close
|
|
137
|
+
close_idle_links
|
|
138
|
+
deadline = monotonic + Worker.stop_grace
|
|
139
|
+
pool.each do |thread|
|
|
140
|
+
thread.join([deadline - monotonic, 0].max)
|
|
141
|
+
end
|
|
142
|
+
busy = @links_lock.synchronize { @links.count { |_io, codec| codec.request_id } }
|
|
143
|
+
@logger.warn('stopping with requests still running', busy: busy) if busy.positive?
|
|
144
|
+
run_shutdown_hooks
|
|
145
|
+
@logger.info('worker stopped', index: @index)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def out_of_band(codec)
|
|
149
|
+
others_busy = @links_lock.synchronize do
|
|
150
|
+
@links.any? { |_io, other| !other.equal?(codec) && !other.request_id.nil? }
|
|
151
|
+
end
|
|
152
|
+
Shuttlebay.run_hooks(:out_of_band) unless others_busy
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def close_idle_links
|
|
156
|
+
idle = @links_lock.synchronize { @links.select { |_io, codec| codec.request_id.nil? }.keys }
|
|
157
|
+
idle.each(&:close)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def run_shutdown_hooks
|
|
161
|
+
Shuttlebay.run_hooks(:worker_shutdown, @index)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
@logger.error('on_worker_shutdown hook failed', **Logger.error_fields(e))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/shuttlebay.rb
ADDED