puma-plus 0.1.0
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/exe/puma-plus +12 -0
- data/exe/puma-plus-worker +79 -0
- data/ext/puma_plus_gvl/extconf.rb +16 -0
- data/ext/puma_plus_gvl/puma_plus_gvl.c +160 -0
- data/lib/puma_plus/address.rb +33 -0
- data/lib/puma_plus/app_loader.rb +66 -0
- data/lib/puma_plus/config_file.rb +267 -0
- data/lib/puma_plus/control_channel.rb +138 -0
- data/lib/puma_plus/gvl.rb +76 -0
- data/lib/puma_plus/hooks.rb +88 -0
- data/lib/puma_plus/input.rb +216 -0
- data/lib/puma_plus/launcher.rb +347 -0
- data/lib/puma_plus/rack_env.rb +92 -0
- data/lib/puma_plus/ractor_worker.rb +227 -0
- data/lib/puma_plus/shepherd.rb +234 -0
- data/lib/puma_plus/version.rb +5 -0
- data/lib/puma_plus/wire.rb +236 -0
- data/lib/puma_plus/worker.rb +90 -0
- data/lib/puma_plus/worker_thread.rb +331 -0
- data/lib/puma_plus/ws.rb +233 -0
- data/lib/puma_plus/ws_event.rb +78 -0
- metadata +85 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "puma_plus/wire"
|
|
5
|
+
require "puma_plus/worker"
|
|
6
|
+
require "puma_plus/control_channel"
|
|
7
|
+
require "puma_plus/hooks"
|
|
8
|
+
|
|
9
|
+
module PumaPlus
|
|
10
|
+
# The shepherd process: loads the Rack app once, forks worker processes, and
|
|
11
|
+
# holds the control connection to the Go server.
|
|
12
|
+
#
|
|
13
|
+
# Why a shepherd rather than letting Go fork Ruby directly: fork must happen
|
|
14
|
+
# from a process that has already loaded the application, so children inherit a
|
|
15
|
+
# warm heap through copy-on-write. Go cannot do that. This mirrors puma's
|
|
16
|
+
# cluster master (puma/lib/puma/cluster.rb) but is far smaller, because Go owns
|
|
17
|
+
# the listener, the queue, and every scaling decision -- the shepherd only
|
|
18
|
+
# executes commands.
|
|
19
|
+
class Shepherd
|
|
20
|
+
RESPAWN_BACKOFF = 1.0
|
|
21
|
+
|
|
22
|
+
def initialize(socket_path:, app_path:, workers:, threads:, config_path: nil, logger: $stderr)
|
|
23
|
+
@socket_path = socket_path
|
|
24
|
+
@app_path = app_path
|
|
25
|
+
@config_path = config_path
|
|
26
|
+
@initial_workers = workers
|
|
27
|
+
@threads = threads
|
|
28
|
+
@logger = logger
|
|
29
|
+
|
|
30
|
+
@children = {} # pid => worker_id
|
|
31
|
+
# Pids we killed on purpose. Without this, reap_children cannot tell a
|
|
32
|
+
# crash from a retirement and respawns the worker it was just told to
|
|
33
|
+
# retire -- which produced a kill/respawn loop that burned 25 forks in a
|
|
34
|
+
# scenario needing 3, and failed every request in flight on each dying
|
|
35
|
+
# worker.
|
|
36
|
+
@retiring = {}
|
|
37
|
+
@next_worker_id = 0
|
|
38
|
+
@running = true
|
|
39
|
+
@completed = 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
# Preload before the first fork. This is the whole point of the shepherd:
|
|
44
|
+
# every child inherits an already-parsed application, so forks are cheap
|
|
45
|
+
# and copy-on-write keeps them cheap in memory too.
|
|
46
|
+
@app = Worker.new(
|
|
47
|
+
socket_path: @socket_path, app_path: @app_path,
|
|
48
|
+
threads: @threads, logger: @logger
|
|
49
|
+
).send(:load_app)
|
|
50
|
+
log "app preloaded from #{@app_path}"
|
|
51
|
+
|
|
52
|
+
@hooks = Hooks.load(@config_path, logger: @logger)
|
|
53
|
+
log "hooks from #{@hooks.path}: #{@hooks.names.join(', ')}" if @hooks.names.any?
|
|
54
|
+
|
|
55
|
+
# Once, before ANY worker is forked -- puma's semantics
|
|
56
|
+
# (puma/lib/puma/cluster.rb:438). The documented use is closing connections
|
|
57
|
+
# opened during preload, so that each child dials its own rather than
|
|
58
|
+
# inheriting a socket that several processes then share.
|
|
59
|
+
@hooks.run(:before_fork)
|
|
60
|
+
|
|
61
|
+
@control = ControlChannel.new(socket_path: @socket_path, logger: @logger).connect!
|
|
62
|
+
install_signal_handlers
|
|
63
|
+
|
|
64
|
+
@initial_workers.times { spawn_worker }
|
|
65
|
+
@respawn_on_death = true
|
|
66
|
+
|
|
67
|
+
@control.run(self)
|
|
68
|
+
ensure
|
|
69
|
+
shutdown_children
|
|
70
|
+
@control&.close
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- ControlChannel handler protocol ---
|
|
74
|
+
|
|
75
|
+
def running? = @running
|
|
76
|
+
|
|
77
|
+
# SET_SLOTS carries the desired *worker process* count.
|
|
78
|
+
def set_slots(target) = adjust_workers(target)
|
|
79
|
+
|
|
80
|
+
def quiesce
|
|
81
|
+
@respawn_on_death = false
|
|
82
|
+
log "quiescing: will not respawn"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def shutdown(grace_ms)
|
|
86
|
+
log "shutdown requested, grace #{grace_ms}ms"
|
|
87
|
+
@running = false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def heartbeat_kv
|
|
91
|
+
[["workers", active_count], ["worker_pids", @children.keys.join(",")]]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Reaping cannot wait on the socket: a child can die at any moment, and the
|
|
95
|
+
# CHLD handler only sets a flag because a trap context cannot safely take
|
|
96
|
+
# locks or allocate and Process.wait can block.
|
|
97
|
+
def on_idle
|
|
98
|
+
return unless @child_died
|
|
99
|
+
|
|
100
|
+
@child_died = false
|
|
101
|
+
reap_children
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def log(msg)
|
|
107
|
+
@logger.puts "[puma-plus shepherd] #{msg}"
|
|
108
|
+
@logger.flush if @logger.respond_to?(:flush)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def install_signal_handlers
|
|
112
|
+
# Reap in the main loop rather than in the handler: a trap context cannot
|
|
113
|
+
# safely take locks or allocate, and Process.wait can block.
|
|
114
|
+
trap("CHLD") { @child_died = true }
|
|
115
|
+
trap("TERM") { @running = false }
|
|
116
|
+
trap("INT") { @running = false }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Fork one worker. The child never returns from here.
|
|
120
|
+
def spawn_worker
|
|
121
|
+
worker_id = (@next_worker_id += 1)
|
|
122
|
+
|
|
123
|
+
pid = fork do
|
|
124
|
+
# Reset inherited handlers so the child does not try to shepherd.
|
|
125
|
+
trap("CHLD", "DEFAULT")
|
|
126
|
+
trap("TERM") { exit 0 }
|
|
127
|
+
trap("INT") { exit 0 }
|
|
128
|
+
@control.close
|
|
129
|
+
|
|
130
|
+
Worker.new(
|
|
131
|
+
socket_path: @socket_path, app_path: @app_path,
|
|
132
|
+
threads: @threads, worker_id: worker_id, logger: @logger, hooks: @hooks,
|
|
133
|
+
multiprocess: true
|
|
134
|
+
).run_preloaded(@app)
|
|
135
|
+
exit 0
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@children[pid] = worker_id
|
|
139
|
+
log "forked worker #{worker_id} pid=#{pid} (#{@children.size} workers)"
|
|
140
|
+
worker_id
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Retire the newest worker. Go chooses when; the shepherd chooses which pid,
|
|
144
|
+
# because only it knows the mapping. Go's own GOAWAY handles draining the
|
|
145
|
+
# individual connections, so by the time this runs the worker is idle.
|
|
146
|
+
def retire_worker
|
|
147
|
+
# Skip anything already on its way out, or we would TERM the same pid
|
|
148
|
+
# repeatedly while waiting for it to die and never make progress.
|
|
149
|
+
candidates = @children.reject { |pid, _| @retiring.key?(pid) }
|
|
150
|
+
return if candidates.empty?
|
|
151
|
+
|
|
152
|
+
pid, worker_id = candidates.max_by { |_, id| id }
|
|
153
|
+
@retiring[pid] = worker_id
|
|
154
|
+
log "retiring worker #{worker_id} pid=#{pid}"
|
|
155
|
+
begin
|
|
156
|
+
Process.kill("TERM", pid)
|
|
157
|
+
rescue Errno::ESRCH
|
|
158
|
+
# Already gone; reaping will notice.
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def reap_children
|
|
163
|
+
loop do
|
|
164
|
+
pid, status = Process.wait2(-1, Process::WNOHANG)
|
|
165
|
+
break unless pid
|
|
166
|
+
|
|
167
|
+
worker_id = @children.delete(pid)
|
|
168
|
+
next unless worker_id
|
|
169
|
+
|
|
170
|
+
# A worker we retired is supposed to be gone. Only an unexpected death
|
|
171
|
+
# earns a respawn.
|
|
172
|
+
if @retiring.delete(pid)
|
|
173
|
+
log "worker #{worker_id} pid=#{pid} retired"
|
|
174
|
+
elsif @running && @respawn_on_death
|
|
175
|
+
log "worker #{worker_id} pid=#{pid} died (#{status.exitstatus || status.termsig}); respawning"
|
|
176
|
+
sleep RESPAWN_BACKOFF
|
|
177
|
+
spawn_worker
|
|
178
|
+
else
|
|
179
|
+
log "worker #{worker_id} pid=#{pid} exited"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
rescue Errno::ECHILD
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Workers that exist and are not on their way out.
|
|
187
|
+
def active_count
|
|
188
|
+
@children.count { |pid, _| !@retiring.key?(pid) }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# SET_SLOTS carries the desired *worker process* count.
|
|
192
|
+
#
|
|
193
|
+
# Counts use active_count, which excludes workers already being retired.
|
|
194
|
+
# Counting them would make the loop TERM a fresh worker on every pass while
|
|
195
|
+
# waiting for the previous one to actually exit.
|
|
196
|
+
def adjust_workers(target)
|
|
197
|
+
target = 1 if target < 1
|
|
198
|
+
|
|
199
|
+
spawn_worker while active_count < target
|
|
200
|
+
|
|
201
|
+
while active_count > target
|
|
202
|
+
retire_worker
|
|
203
|
+
# Let the reaper run so the next iteration sees reality. Retirement is
|
|
204
|
+
# asynchronous: the worker finishes what it is doing before exiting.
|
|
205
|
+
sleep 0.05
|
|
206
|
+
reap_children
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def shutdown_children
|
|
211
|
+
@respawn_on_death = false
|
|
212
|
+
@children.each_key do |pid|
|
|
213
|
+
Process.kill("TERM", pid)
|
|
214
|
+
rescue Errno::ESRCH
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
deadline = Time.now + 5
|
|
219
|
+
until @children.empty? || Time.now > deadline
|
|
220
|
+
reap_children
|
|
221
|
+
sleep 0.05
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
@children.each_key do |pid|
|
|
225
|
+
Process.kill("KILL", pid)
|
|
226
|
+
rescue Errno::ESRCH
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
log "all workers stopped"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
require "etc"
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PumaPlus
|
|
4
|
+
# The puma-plus frame protocol, Ruby side.
|
|
5
|
+
#
|
|
6
|
+
# Mirror of internal/wire/wire.go. See docs/PROTOCOL.md for the normative spec;
|
|
7
|
+
# internal/wire/golden_test.go enforces that the two agree byte for byte.
|
|
8
|
+
#
|
|
9
|
+
# Deliberately pure Ruby with no dependencies: String#pack/unpack and byteslice
|
|
10
|
+
# are enough for a length-prefixed framing over a flat string->string map, and
|
|
11
|
+
# every dependency avoided is one the gem does not have to install.
|
|
12
|
+
module Wire
|
|
13
|
+
# Frame types, data connections.
|
|
14
|
+
HELLO = 0x01
|
|
15
|
+
REQUEST = 0x02
|
|
16
|
+
BODY_CHUNK = 0x03
|
|
17
|
+
BODY_END = 0x04
|
|
18
|
+
RESPONSE = 0x05
|
|
19
|
+
RESP_CHUNK = 0x06
|
|
20
|
+
RESP_END = 0x07
|
|
21
|
+
HIJACK = 0x08
|
|
22
|
+
GOAWAY = 0x09
|
|
23
|
+
|
|
24
|
+
# Frame types, WebSocket control connections (role "ws"). Outbound commands
|
|
25
|
+
# from Ruby, issuable from any thread at any time rather than only while a
|
|
26
|
+
# message is being handled.
|
|
27
|
+
WS_SEND = 0x50
|
|
28
|
+
WS_PUBLISH = 0x51
|
|
29
|
+
WS_SUBSCRIBE = 0x52
|
|
30
|
+
WS_UNSUBSCRIBE = 0x53
|
|
31
|
+
WS_CLOSE = 0x54
|
|
32
|
+
WS_QUERY = 0x55
|
|
33
|
+
WS_QUERY_REPLY = 0x56
|
|
34
|
+
|
|
35
|
+
# WebTransport-only. Separate frames rather than a flag: best-effort
|
|
36
|
+
# delivery is a different contract from reliable delivery.
|
|
37
|
+
WS_DATAGRAM = 0x57
|
|
38
|
+
WS_PUBLISH_DATAGRAM = 0x58
|
|
39
|
+
WS_OPEN_STREAM = 0x59
|
|
40
|
+
|
|
41
|
+
# Frame types, control connections.
|
|
42
|
+
WORKER_STATUS = 0x40
|
|
43
|
+
SET_SLOTS = 0x41
|
|
44
|
+
QUIESCE = 0x42
|
|
45
|
+
SHUTDOWN = 0x43
|
|
46
|
+
PING = 0x44
|
|
47
|
+
PONG = 0x45
|
|
48
|
+
|
|
49
|
+
# body_mode values in the REQUEST fixed meta.
|
|
50
|
+
BODY_NONE = 0
|
|
51
|
+
BODY_INLINE = 1
|
|
52
|
+
BODY_STREAM = 2
|
|
53
|
+
|
|
54
|
+
# http_version values in the REQUEST fixed meta.
|
|
55
|
+
HTTP_10 = 10
|
|
56
|
+
HTTP_11 = 11
|
|
57
|
+
HTTP_2 = 20
|
|
58
|
+
HTTP_3 = 30
|
|
59
|
+
|
|
60
|
+
VERSION = "1"
|
|
61
|
+
HEADER_SIZE = 8
|
|
62
|
+
MAX_PAYLOAD = 16 << 20
|
|
63
|
+
CHUNK_TARGET = 64 << 10
|
|
64
|
+
INLINE_BODY_MAX = 64 << 10
|
|
65
|
+
REQUEST_META_SIZE = 26
|
|
66
|
+
RESP_END_META_SIZE = 32
|
|
67
|
+
|
|
68
|
+
TYPE_NAMES = {
|
|
69
|
+
HELLO => "HELLO", REQUEST => "REQUEST", BODY_CHUNK => "BODY_CHUNK",
|
|
70
|
+
BODY_END => "BODY_END", RESPONSE => "RESPONSE", RESP_CHUNK => "RESP_CHUNK",
|
|
71
|
+
RESP_END => "RESP_END", HIJACK => "HIJACK", GOAWAY => "GOAWAY",
|
|
72
|
+
WORKER_STATUS => "WORKER_STATUS", SET_SLOTS => "SET_SLOTS",
|
|
73
|
+
QUIESCE => "QUIESCE", SHUTDOWN => "SHUTDOWN", PING => "PING", PONG => "PONG",
|
|
74
|
+
WS_SEND => "WS_SEND", WS_PUBLISH => "WS_PUBLISH", WS_SUBSCRIBE => "WS_SUBSCRIBE",
|
|
75
|
+
WS_UNSUBSCRIBE => "WS_UNSUBSCRIBE", WS_CLOSE => "WS_CLOSE",
|
|
76
|
+
WS_QUERY => "WS_QUERY", WS_QUERY_REPLY => "WS_QUERY_REPLY",
|
|
77
|
+
WS_DATAGRAM => "WS_DATAGRAM", WS_PUBLISH_DATAGRAM => "WS_PUBLISH_DATAGRAM",
|
|
78
|
+
WS_OPEN_STREAM => "WS_OPEN_STREAM"
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
BINARY = Encoding::BINARY
|
|
82
|
+
|
|
83
|
+
# Raised when a peer announces a frame larger than MAX_PAYLOAD. The only
|
|
84
|
+
# defense against a corrupt or hostile peer causing an OOM.
|
|
85
|
+
class PayloadTooLarge < StandardError; end
|
|
86
|
+
|
|
87
|
+
# Raised when a payload ends in the middle of a field.
|
|
88
|
+
class Truncated < StandardError; end
|
|
89
|
+
|
|
90
|
+
# Raised when the peer closes cleanly between frames. Not an error at the
|
|
91
|
+
# call site that owns the connection lifecycle -- it means "we're done".
|
|
92
|
+
class Closed < StandardError; end
|
|
93
|
+
|
|
94
|
+
module_function
|
|
95
|
+
|
|
96
|
+
def type_name(type)
|
|
97
|
+
TYPE_NAMES[type] || format("UNKNOWN(0x%02x)", type)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Encode a frame: u8 type, u24 reserved (zero), u32 length, payload.
|
|
101
|
+
# Returns a single string so the caller emits one write per frame; frames
|
|
102
|
+
# must never appear interleaved on the wire.
|
|
103
|
+
def frame(type, payload = "")
|
|
104
|
+
[type, 0, 0, 0, payload.bytesize].pack("C4N") << payload.b
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Encode a kv blob: u32 count, then count x (u16 klen, k, u32 vlen, v).
|
|
108
|
+
#
|
|
109
|
+
# +pairs+ is an array of [key, value] rather than a Hash because duplicate
|
|
110
|
+
# keys are meaningful in header blobs, where they encode Rack 3 array-valued
|
|
111
|
+
# headers in wire order.
|
|
112
|
+
def encode_kv(pairs)
|
|
113
|
+
out = [pairs.size].pack("N")
|
|
114
|
+
pairs.each do |k, v|
|
|
115
|
+
k = k.to_s.b
|
|
116
|
+
v = v.to_s.b
|
|
117
|
+
out << [k.bytesize].pack("n") << k << [v.bytesize].pack("N") << v
|
|
118
|
+
end
|
|
119
|
+
out
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Decode a kv blob from the front of +buf+ starting at +offset+.
|
|
123
|
+
# Returns [pairs, next_offset]. Values are byteslices of buf, not copies --
|
|
124
|
+
# on the request hot path that aliasing is the point, since the whole env
|
|
125
|
+
# becomes byteslices of a single payload string.
|
|
126
|
+
def decode_kv(buf, offset = 0)
|
|
127
|
+
raise Truncated, "kv count" if buf.bytesize - offset < 4
|
|
128
|
+
|
|
129
|
+
count = buf.unpack1("N", offset: offset)
|
|
130
|
+
offset += 4
|
|
131
|
+
|
|
132
|
+
# A count larger than the remaining bytes can never be satisfied (the
|
|
133
|
+
# smallest possible pair is 6 bytes). Checking up front stops a corrupt
|
|
134
|
+
# length from driving a huge allocation.
|
|
135
|
+
raise Truncated, "kv count #{count} exceeds remaining bytes" if count > buf.bytesize - offset
|
|
136
|
+
|
|
137
|
+
pairs = Array.new(count)
|
|
138
|
+
count.times do |i|
|
|
139
|
+
raise Truncated, "kv key length" if buf.bytesize - offset < 2
|
|
140
|
+
kl = buf.unpack1("n", offset: offset)
|
|
141
|
+
offset += 2
|
|
142
|
+
raise Truncated, "kv key" if buf.bytesize - offset < kl
|
|
143
|
+
key = buf.byteslice(offset, kl)
|
|
144
|
+
offset += kl
|
|
145
|
+
|
|
146
|
+
raise Truncated, "kv value length" if buf.bytesize - offset < 4
|
|
147
|
+
vl = buf.unpack1("N", offset: offset)
|
|
148
|
+
offset += 4
|
|
149
|
+
raise Truncated, "kv value" if buf.bytesize - offset < vl
|
|
150
|
+
val = buf.byteslice(offset, vl)
|
|
151
|
+
offset += vl
|
|
152
|
+
|
|
153
|
+
pairs[i] = [key, val]
|
|
154
|
+
end
|
|
155
|
+
[pairs, offset]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Encode the fixed 26-byte REQUEST meta.
|
|
159
|
+
def encode_request_meta(queue_ns:, runnable_wall_us:, body_wait_ns:,
|
|
160
|
+
http_version:, body_mode:)
|
|
161
|
+
# Ruby has no native u64 pack directive that is endian-explicit in older
|
|
162
|
+
# syntax, so split each into two u32s. Q> exists and is used here.
|
|
163
|
+
[queue_ns, runnable_wall_us, body_wait_ns].pack("Q>3") <<
|
|
164
|
+
[http_version, body_mode].pack("C2")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Decode the fixed 26-byte REQUEST meta from +buf+ at +offset+.
|
|
168
|
+
# Returns [meta_hash, next_offset].
|
|
169
|
+
def decode_request_meta(buf, offset = 0)
|
|
170
|
+
raise Truncated, "request meta" if buf.bytesize - offset < REQUEST_META_SIZE
|
|
171
|
+
|
|
172
|
+
queue_ns, runnable_wall_us, body_wait_ns = buf.unpack("Q>3", offset: offset)
|
|
173
|
+
http_version = buf.getbyte(offset + 24)
|
|
174
|
+
body_mode = buf.getbyte(offset + 25)
|
|
175
|
+
|
|
176
|
+
[{
|
|
177
|
+
queue_ns: queue_ns,
|
|
178
|
+
runnable_wall_us: runnable_wall_us,
|
|
179
|
+
body_wait_ns: body_wait_ns,
|
|
180
|
+
http_version: http_version,
|
|
181
|
+
body_mode: body_mode
|
|
182
|
+
}, offset + REQUEST_META_SIZE]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Encode the fixed 24-byte RESP_END meta. Piggybacking per-request timing on
|
|
186
|
+
# the response costs no extra frame and no extra syscall.
|
|
187
|
+
def encode_resp_end_meta(service_ns:, cpu_ns:, body_read_ns:, gvl_wait_ns: 0)
|
|
188
|
+
[service_ns, cpu_ns, body_read_ns, gvl_wait_ns].pack("Q>4")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Decode the fixed 24-byte RESP_END meta. Returns [meta_hash, next_offset].
|
|
192
|
+
def decode_resp_end_meta(buf, offset = 0)
|
|
193
|
+
raise Truncated, "resp_end meta" if buf.bytesize - offset < RESP_END_META_SIZE
|
|
194
|
+
|
|
195
|
+
service_ns, cpu_ns, body_read_ns, gvl_wait_ns = buf.unpack("Q>4", offset: offset)
|
|
196
|
+
[{ service_ns: service_ns, cpu_ns: cpu_ns, body_read_ns: body_read_ns,
|
|
197
|
+
gvl_wait_ns: gvl_wait_ns },
|
|
198
|
+
offset + RESP_END_META_SIZE]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Read one frame from +io+. Returns [type, payload].
|
|
202
|
+
#
|
|
203
|
+
# Raises Closed on a clean EOF between frames, Truncated on EOF mid-frame --
|
|
204
|
+
# the distinction matters because the first is normal shutdown and the second
|
|
205
|
+
# means the peer died holding our request.
|
|
206
|
+
def read_frame(io)
|
|
207
|
+
hdr = read_exactly(io, HEADER_SIZE)
|
|
208
|
+
raise Closed if hdr.nil?
|
|
209
|
+
|
|
210
|
+
type = hdr.getbyte(0)
|
|
211
|
+
# hdr[1..3] is the reserved u24. Per spec we ignore its value rather than
|
|
212
|
+
# rejecting non-zero, so it can later become a stream id.
|
|
213
|
+
len = hdr.unpack1("N", offset: 4)
|
|
214
|
+
|
|
215
|
+
if len > MAX_PAYLOAD
|
|
216
|
+
raise PayloadTooLarge, "#{type_name(type)} announced #{len} bytes"
|
|
217
|
+
end
|
|
218
|
+
return [type, ""] if len.zero?
|
|
219
|
+
|
|
220
|
+
payload = read_exactly(io, len)
|
|
221
|
+
raise Truncated, "#{type_name(type)} body" if payload.nil?
|
|
222
|
+
|
|
223
|
+
[type, payload]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Read exactly +n+ bytes. Returns nil on clean EOF before any byte was read,
|
|
227
|
+
# which read_frame maps to Closed; a partial read raises Truncated.
|
|
228
|
+
def read_exactly(io, n)
|
|
229
|
+
buf = io.read(n)
|
|
230
|
+
return nil if buf.nil?
|
|
231
|
+
raise Truncated, "wanted #{n} bytes, got #{buf.bytesize}" if buf.bytesize < n
|
|
232
|
+
|
|
233
|
+
buf
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puma_plus/worker_thread"
|
|
4
|
+
require "puma_plus/app_loader"
|
|
5
|
+
require "puma_plus/hooks"
|
|
6
|
+
require "puma_plus/ws"
|
|
7
|
+
require "puma_plus/gvl"
|
|
8
|
+
|
|
9
|
+
module PumaPlus
|
|
10
|
+
# A Ruby worker process: loads the Rack app once, then runs N threads that each
|
|
11
|
+
# dial the Go server and serve requests.
|
|
12
|
+
#
|
|
13
|
+
# Phase 1 runs a single worker process directly. Phase 4 puts a shepherd above
|
|
14
|
+
# this that forks several of these and handles SET_SLOTS/QUIESCE.
|
|
15
|
+
class Worker
|
|
16
|
+
def initialize(socket_path:, app_path:, threads:, worker_id: 0, config_path: nil,
|
|
17
|
+
hooks: nil, multiprocess: false, logger: $stderr)
|
|
18
|
+
@socket_path = socket_path
|
|
19
|
+
@app_path = app_path
|
|
20
|
+
@threads = threads
|
|
21
|
+
@worker_id = worker_id
|
|
22
|
+
@logger = logger
|
|
23
|
+
# The shepherd passes hooks it already loaded, so a forked child does not
|
|
24
|
+
# re-read and re-evaluate the config file once per worker.
|
|
25
|
+
@hooks = hooks || Hooks.load(config_path, logger: logger)
|
|
26
|
+
# Matches puma: multithread when more than one thread can call the app in
|
|
27
|
+
# this process, multiprocess when a shepherd is managing worker processes
|
|
28
|
+
# (puma/lib/puma/binder.rb:33-34).
|
|
29
|
+
@multithread = threads > 1
|
|
30
|
+
@multiprocess = multiprocess
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
# Load before any thread dials in. A connection appearing on the Go side is
|
|
35
|
+
# the readiness signal, so it must not appear until the app can actually
|
|
36
|
+
# serve -- that is the whole reason puma-plus needs no dial-polling.
|
|
37
|
+
run_preloaded(load_app)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Serve using an already-loaded app. Used by the shepherd, which loads once
|
|
41
|
+
# before forking so children inherit a warm heap through copy-on-write.
|
|
42
|
+
def run_preloaded(app)
|
|
43
|
+
# Start GVL instrumentation before any thread serves, so the very first
|
|
44
|
+
# request is measured. A no-op without the native extension.
|
|
45
|
+
PumaPlus::GVL.start!
|
|
46
|
+
|
|
47
|
+
# One outbound WebSocket control channel per worker process, opened before
|
|
48
|
+
# any thread serves. The app can then address any connection at any time,
|
|
49
|
+
# from any thread, rather than only while handling a message.
|
|
50
|
+
PumaPlus::WS.connect!(@socket_path, logger: @logger)
|
|
51
|
+
|
|
52
|
+
# Before any thread dials in, which is what makes this ordering meaningful:
|
|
53
|
+
# a connection appearing on the Go side is the readiness signal, so hooks
|
|
54
|
+
# finishing first means Go cannot dispatch a request to a worker whose
|
|
55
|
+
# setup has not completed.
|
|
56
|
+
@hooks.run(:on_worker_boot, @worker_id)
|
|
57
|
+
|
|
58
|
+
threads = @threads.times.map do |i|
|
|
59
|
+
Thread.new do
|
|
60
|
+
Thread.current.name = "puma-plus #{@worker_id}/#{i}"
|
|
61
|
+
WorkerThread.new(
|
|
62
|
+
socket_path: @socket_path,
|
|
63
|
+
app: app,
|
|
64
|
+
worker_id: @worker_id,
|
|
65
|
+
thread_index: i,
|
|
66
|
+
multithread: @multithread,
|
|
67
|
+
multiprocess: @multiprocess,
|
|
68
|
+
logger: @logger
|
|
69
|
+
).run
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@logger.puts "[puma-plus] worker #{@worker_id} pid=#{Process.pid} " \
|
|
74
|
+
"serving with #{@threads} threads"
|
|
75
|
+
|
|
76
|
+
trap("TERM") { threads.each(&:kill) }
|
|
77
|
+
trap("INT") { threads.each(&:kill) }
|
|
78
|
+
|
|
79
|
+
threads.each(&:join)
|
|
80
|
+
ensure
|
|
81
|
+
# Not fatal: the process is leaving either way, and refusing to exit
|
|
82
|
+
# because a cleanup hook raised helps nobody.
|
|
83
|
+
@hooks.run(:on_worker_shutdown, @worker_id, fatal: false)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def load_app = AppLoader.load(@app_path)
|
|
89
|
+
end
|
|
90
|
+
end
|