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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shuttlebay
4
+ VERSION = '0.1.4'
5
+ 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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shuttlebay/runtime'
4
+ require_relative 'shuttlebay/railtie' if defined?(Rails::Railtie)