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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6b8d9532060d00b57f7f72413ef6de64db9640d472ca1ef092b1d2d22892916b
4
+ data.tar.gz: 8172c64b5085e89dce75e35ba51c00efbf515bed106aead640300399fcfe82dc
5
+ SHA512:
6
+ metadata.gz: b0a08dc827de5a782b85ed5e3ba85766c695eccd78ef9fbb0a7025514664e827515fbb6633b9f786022e02aee01a0737ab0a9dff52ab6f829d035bc4505768b7
7
+ data.tar.gz: 5186f12cc57b56654cfaebee69baaa833eced3096cdb7e0db8d08791a42e6b33c5960bce0bdf8c161798e993a81bf86dea789c9488968191db8ed487b08412fb
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdelkader Boudih
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # Shuttlebay
2
+
3
+ Run a Rack app as a [Mothership](https://github.com/seuros/mothership) `[[bays.http]]` bay.
4
+
5
+ Mothership is the HTTP server. Shuttlebay is the Ruby side: it preloads your
6
+ app, forks workers, and runs the app on threads that receive requests over
7
+ Unix-socket docking links. A native engine (Rust, via magnus) decodes the
8
+ docking protocol and builds the Rack env, so Ruby never parses HTTP.
9
+
10
+ ```mermaid
11
+ flowchart TD
12
+ client([client]) -- HTTP --> mothership["mothership<br/>static, gzip, routing, limits, request ids…"]
13
+ mothership -- "docking protocol v3<br/>over a Unix socket" --> master["shuttlebay master<br/>config.ru preloaded: Rails, Sinatra, Roda, Padrino, …"]
14
+ master --> w0["worker 0<br/>threads, one docking link each"]
15
+ master --> w1["worker 1<br/>threads"]
16
+ ```
17
+
18
+ ## Install
19
+
20
+ ```ruby
21
+ # Gemfile
22
+ gem "shuttlebay"
23
+ gem "guardship" # the mothership binary (or: cargo install mothership)
24
+ ```
25
+
26
+ ```toml
27
+ # ship-manifest.toml
28
+ [mothership.bind]
29
+ http = "0.0.0.0:3000"
30
+
31
+ [[mothership.static_dirs]] # public/ served by mothership, not Rails
32
+ path = "./public"
33
+ prefix = "/"
34
+
35
+ [[bays.http]]
36
+ name = "web"
37
+ command = "bundle"
38
+ args = ["exec", "shuttlebay", "config.ru"]
39
+ workers = "auto" # default: sized from CPUs and memory; 0 = single process
40
+ threads = 5
41
+ routes = [{ bind = "http", pattern = "/.*" }]
42
+ ```
43
+
44
+ ```bash
45
+ bundle exec mothership
46
+ ```
47
+
48
+ ## What Mothership does instead of Rack middleware
49
+
50
+ | Concern | Before (app server + middleware) | With Shuttlebay |
51
+ |---|---|---|
52
+ | HTTP parsing, keep-alive, slow clients | App server reactor | Mothership |
53
+ | Static files | `ActionDispatch::Static` / nginx | `[[mothership.static_dirs]]` |
54
+ | `send_file` / `Rack::Sendfile` | X-Sendfile + nginx | `to_path` bodies streamed from disk by Mothership |
55
+ | Compression | `Rack::Deflater` / nginx | `compression = true` |
56
+ | Request id, queue time | `ActionDispatch::RequestId`, LB | `X-Request-Id`, `X-Request-Start: t=<µs>` |
57
+ | Real client IP | `RemoteIp` + proxy config | `REMOTE_ADDR` resolved (Forwarded / PROXY protocol) |
58
+ | Load balancing to workers | kernel accept / `wait_for_less_busy_worker` | idle-thread checkout; queueing in Mothership |
59
+ | Request body buffering | App server | Mothership reads the whole body before claiming a thread |
60
+
61
+ When Rails is loaded, the bundled Railtie removes `Rack::Sendfile`, since the
62
+ engine offloads `to_path` bodies itself.
63
+
64
+ ## Hooks
65
+
66
+ `config.ru` loads in the master before forking, so register hooks there:
67
+
68
+ ```ruby
69
+ Shuttlebay.before_fork { ActiveRecord::Base.connection_pool.disconnect! }
70
+ Shuttlebay.on_worker_boot { |index| Rails.logger.info("worker #{index} up") }
71
+ Shuttlebay.on_worker_shutdown { |index| Rails.logger.info("worker #{index} drained") }
72
+ Shuttlebay.out_of_band { GC.start } # runs when a worker has no request in flight
73
+ run Rails.application
74
+ ```
75
+
76
+ ## Fibers
77
+
78
+ With a fiber scheduler, each worker runs its `threads` docking links as fibers
79
+ on one reactor thread instead of as threads. A request waiting on I/O parks
80
+ its fiber, and the reactor serves the other links meanwhile.
81
+
82
+ ```ruby
83
+ # config.ru (Gemfile: gem "async")
84
+ require "async"
85
+ Shuttlebay.fiber_scheduler { Async::Scheduler.new }
86
+ run Rails.application
87
+ ```
88
+
89
+ The block runs once per worker, after fork, on the reactor thread; any
90
+ `Fiber::Scheduler` works. `threads` in the manifest still counts docking links
91
+ per worker, so Mothership needs no change, but a parked fiber costs no thread,
92
+ so it can go much higher (64, 256).
93
+
94
+ - Fibers are not preempted: a request burning CPU stalls every other request
95
+ on its reactor. Size `workers` for CPU and `threads` for I/O wait.
96
+ `Shuttlebay.fiber_scheduler(reactors: 4) { … }` splits a worker's links
97
+ across 4 reactor threads, which Ruby does preempt, so one busy request
98
+ stalls a quarter of the links instead of all of them.
99
+ - Only I/O through Ruby's scheduler yields: Ruby sockets (`Net::HTTP`, Redis
100
+ clients without hiredis), `pg`, `sleep`. C extensions doing their own
101
+ blocking I/O (`mysql2`, libcurl gems) stall the reactor.
102
+ - Rails: set `config.active_support.isolation_level = :fiber` and size the
103
+ Active Record pool to at least `threads`.
104
+ - Hijacked connections (WebSockets) still relay on their own threads.
105
+ - Async's I/O hooks use `IO::Buffer`, so Ruby prints a one-time experimental
106
+ warning to stderr; `Warning[:experimental] = false` silences it.
107
+
108
+ ## Development server and system tests
109
+
110
+ ```bash
111
+ bin/rails server -u shuttlebay # or: rackup -s shuttlebay
112
+ ```
113
+
114
+ ```ruby
115
+ # test/application_system_test_case.rb
116
+ require "shuttlebay/capybara"
117
+ Capybara.server = :shuttlebay
118
+ ```
119
+
120
+ Both run the app in their own process and start a Mothership that attaches to
121
+ it, so development and tests go through the same HTTP stack as production. The
122
+ binary comes from `MOTHERSHIP_BIN`, the `guardship` gem, or `PATH`. The
123
+ `Threads` option (`rackup -O Threads=5`) or `MS_BAY_THREADS` sets the thread
124
+ count; Mothership sizes its link pool from what the app reports when it docks.
125
+
126
+ For HTTPS, run Mothership with a `tls` bind and the app as a docking upstream
127
+ (see the Mothership README) instead of the attached server.
128
+
129
+ ## Behaviour
130
+
131
+ - **Responses:** HEAD, 1xx, 204 and 304 send no body. `to_ary` bodies go out
132
+ in one write. `each` bodies flush per chunk (SSE, `ActionController::Live`).
133
+ Rack 3 streaming bodies (`call`) are supported. A `to_path` body with status
134
+ 200 is served by Mothership from disk.
135
+ - **Thread handoff:** after the response's last byte, the thread closes the
136
+ body and runs `rack.response_finished` callbacks, then sends `Ready`.
137
+ Mothership routes the next request to that link only after `Ready`, so a
138
+ request never waits behind Rails' after-response work.
139
+ - **Errors:** an exception before the head goes out becomes a 500. After the
140
+ head, the link is closed and the response aborts.
141
+ `rack.response_finished` callbacks run after every response.
142
+ - **WebSockets:** run ActionCable on [orbitcast](https://github.com/seuros/orbitcast),
143
+ which holds the sockets in Rust and speaks ActionCable's protocol, so no
144
+ Ruby thread sits on a connection. Every other WebSocket (Faye,
145
+ `websocket-driver`, custom protocols, or ActionCable's built-in server in
146
+ development and system tests) goes through `rack.hijack`: the app answers on
147
+ the hijacked IO, Mothership relays the upgraded connection, and the thread
148
+ goes back to serving while the relay runs on threads of its own.
149
+ - **Early hints:** not offered (`rack.early_hints` is absent). Mothership's
150
+ HTTP stack cannot send 1xx responses yet; Rails falls back to `Link`
151
+ headers on the final response.
152
+ - **Large uploads:** bodies over 1 MiB are spooled to an unlinked temp file
153
+ (`rack.input` is a `Tempfile`). Memory stays bounded however large uploads
154
+ are allowed to be.
155
+ - **Signals:** on `TERM`, workers stop accepting, drop idle links, and finish
156
+ in-flight requests within `MS_BAY_DRAIN_TIMEOUT` (Mothership passes the
157
+ bay's `request_timeout`). A dead worker is re-forked with backoff.
158
+ - **Deploys:** `kill -USR1` the mothership process to roll the bay to new code
159
+ with no dropped requests (phased restart).
160
+ - **TLS:** terminated by Mothership on a `tls` bind; the app sees
161
+ `rack.url_scheme = "https"`.
162
+ - **Logs:** JSON lines on stdout, which Mothership passes through.
163
+
164
+ ## Environment (set by Mothership)
165
+
166
+ `MS_SOCKET_PATH`, `MS_SHIP`, `MS_BAY_WORKERS`, `MS_BAY_THREADS`, `MS_BAY_DRAIN_TIMEOUT`.
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ bundle install
172
+ bundle exec rake compile # builds lib/shuttlebay/shuttlebay.{bundle,so}
173
+ bundle exec rake test
174
+ ```
175
+
176
+ The engine crate (`ext/shuttlebay`) shares the wire format with Mothership
177
+ through `mothership-docking-protocol` and is never published to crates.io.
178
+ Platform gems: `bundle exec rake platforms:build` (rb-sys-dock, Docker).
179
+
180
+ Releases: release-please tags the version, CI builds the platform and source
181
+ gems and attaches them to the GitHub release. Push them to rubygems.org from a
182
+ workstation:
183
+
184
+ ```bash
185
+ bundle exec rake 'gems:fetch[v0.1.0]' # download the release's gems into pkg/
186
+ bundle exec rake 'gems:push[v0.1.0]' # fetch, then gem push each one
187
+ ```
188
+
189
+ ## License
190
+
191
+ MIT
data/exe/shuttlebay ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/shuttlebay/logger'
5
+
6
+ begin
7
+ require_relative '../lib/shuttlebay/runtime'
8
+ Shuttlebay::Server.from_env(ARGV).run
9
+ rescue StandardError, ScriptError => e
10
+ Shuttlebay::Logger.new.error('shuttlebay stopped', **Shuttlebay::Logger.error_fields(e))
11
+ exit 1
12
+ end
@@ -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