shuttlebay 0.1.4-x86_64-darwin
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.bundle +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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2e46afdb7413a7578647d830b058aa63416a47d7416a5c28cee19d29dfc42f87
|
|
4
|
+
data.tar.gz: 55f4ad4083e81b9ddec34cb1c5e5f4645f5a393149379c1a84f3f47ebb645f54
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4a6712492cb1ac949632432984916fd1d9946335b358e583ee3925aaf1ddf66563b903be864cec9ded9f19444c81066f51d25bf989131061aefc8ee0c3a48734
|
|
7
|
+
data.tar.gz: 948e95b09b886c7a1c5c8e2f155dbb544b2fa0ee5cc4470f6066bfbb70f1057b9217ffa944cc51b31d3e3b9f6198c9d74907fbe5ae079e799f06f532c174dd36
|
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
|