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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaPlus
4
+ VERSION = "0.1.0"
5
+ end
@@ -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