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,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "puma_plus/config_file"
5
+ require "puma_plus/version"
6
+
7
+ module PumaPlus
8
+ # The `puma-plus` command.
9
+ #
10
+ # Ruby parses the configuration and then *execs* the Go server. Two decisions
11
+ # worth stating, because both could reasonably have gone the other way.
12
+ #
13
+ # Why Ruby reads the config: it is a Ruby file. Worker counts get computed from
14
+ # Etc.nprocessors, settings branch on the environment, and lifecycle hooks are
15
+ # blocks. Only Ruby can evaluate that, and a config format Go could parse
16
+ # would be a worse config format.
17
+ #
18
+ # Why exec rather than spawn: exec replaces this process, so the Go server ends
19
+ # up with the pid the shell started, inherits the terminal and the process
20
+ # group directly, and receives signals from the user with nothing in between.
21
+ # A supervising Ruby parent would have to forward signals, mirror exit
22
+ # statuses, and would show up as a stray process in every `ps` -- all to
23
+ # supervise a process that supervises Ruby itself. After exec there is no Ruby
24
+ # in the tree except the workers the Go server starts.
25
+ class Launcher
26
+ DEFAULTS = {
27
+ listen: "0.0.0.0:9292",
28
+ control: "127.0.0.1:9293",
29
+ app: "config.ru",
30
+ threads: 5,
31
+ workers: 1,
32
+ environment: "development"
33
+ }.freeze
34
+
35
+ # Searched in order, mirroring puma's config/puma/<env>.rb then config/puma.rb.
36
+ def self.config_candidates(env)
37
+ ["config/puma-plus/#{env}.rb", "config/puma-plus.rb"]
38
+ end
39
+
40
+ def initialize(argv, stdout: $stdout, stderr: $stderr, env: ENV)
41
+ @argv = argv
42
+ @stdout = stdout
43
+ @stderr = stderr
44
+ @env = env
45
+ @options = Options.new(DEFAULTS.dup)
46
+ end
47
+
48
+ attr_reader :options
49
+
50
+ def run
51
+ parse_cli!
52
+
53
+ # RACK_ENV is consulted before the config file is chosen, because which
54
+ # file gets loaded depends on the environment.
55
+ if (e = @env["RACK_ENV"]) && !@options.user.key?(:environment)
56
+ @options.default[:environment] = e
57
+ end
58
+
59
+ dsl = load_config!
60
+ Dir.chdir(@options[:directory]) if @options[:directory]
61
+
62
+ (dsl&.warnings || []).each { |w| @stderr.puts "[puma-plus] config: #{w}" }
63
+ validate!
64
+
65
+ argv = go_argv
66
+ if @dry_run
67
+ print_plan(argv)
68
+ return 0
69
+ end
70
+
71
+ write_pidfile
72
+ exec_go(argv)
73
+ rescue ConfigError, URI::Error, OptionParser::ParseError, ArgumentError => e
74
+ @stderr.puts "[puma-plus] #{e.message}"
75
+ 1
76
+ end
77
+
78
+ private
79
+
80
+ def parse_cli!
81
+ u = @options.user
82
+
83
+ OptionParser.new do |o|
84
+ o.banner = "usage: puma-plus [options] [rackup file]"
85
+
86
+ o.on("-C", "--config PATH", "config file (default: config/puma-plus.rb)") { |v| @config_path = v }
87
+ o.on("--no-config", "skip config file discovery") { @no_config = true }
88
+ o.on("-b", "--bind URL", "tcp://host:port or ssl://host:port") { |v| @bind = v }
89
+ o.on("-p", "--port PORT", Integer, "port to bind (default 9292)") { |v| @port = v }
90
+ o.on("-e", "--environment ENV", "environment (default development)") { |v| u[:environment] = v }
91
+ o.on("-t", "--threads N", "worker connections per process") { |v| u[:threads] = Integer(v.split(":").last) }
92
+ o.on("-w", "--workers N", Integer, "worker processes") { |v| u[:workers] = v }
93
+ o.on("--ractors N", Integer, "serve from N Ractors in one process") { |v| u[:ractors] = v }
94
+ o.on("--autoscale", "scale workers from measured queue time") { u[:autoscale] = true }
95
+ o.on("--min-workers N", Integer) { |v| u[:min_workers] = v }
96
+ o.on("--max-workers N", Integer) { |v| u[:max_workers] = v }
97
+ o.on("--target-queue-p95 D", "queue-time p95 to steer toward, e.g. 25ms") { |v| u[:target_queue_p95] = v }
98
+ o.on("--control-url URL", "--control URL", "where /stats and /metrics listen") do |v|
99
+ u[:control] = Address.host_port(v, 9293, "--control-url")
100
+ end
101
+ o.on("--tls-cert PATH", "PEM certificate chain for the TLS/h3 listener") { |v| u[:tls_cert] = File.expand_path(v) }
102
+ o.on("--tls-key PATH", "PEM private key matching --tls-cert") { |v| u[:tls_key] = File.expand_path(v) }
103
+ o.on("--dir DIR", "chdir here before booting") { |v| u[:directory] = File.expand_path(v) }
104
+ o.on("--pidfile PATH") { |v| u[:pidfile] = v }
105
+ o.on("--dry-run", "print the resolved configuration and exit") { @dry_run = true }
106
+ o.on("-v", "--version") do
107
+ # Both halves, because they are separately installed and a mismatched
108
+ # pair is the failure worth being able to see at a glance.
109
+ @stdout.puts "puma-plus #{PumaPlus::VERSION}"
110
+ begin
111
+ require "puma_plus/core/version"
112
+ @stdout.puts "puma-plus-core #{PumaPlus::Core::VERSION}"
113
+ rescue LoadError
114
+ @stdout.puts "puma-plus-core (not installed)"
115
+ end
116
+ exit 0
117
+ end
118
+ o.on("-h", "--help") { @stdout.puts o; exit 0 }
119
+ end.parse!(@argv)
120
+
121
+ # A bare argument is the rackup file, as `puma config.ru` accepts.
122
+ u[:app] = @argv.shift if @argv.first
123
+
124
+ # -b and -p are resolved after parsing so their interaction is defined
125
+ # rather than order-dependent: an explicit --bind wins, and a lone --port
126
+ # keeps whatever host the bind would have used.
127
+ if @bind
128
+ scheme = @bind[%r{\A([a-zA-Z][a-zA-Z0-9+.-]*)://}, 1] || "tcp"
129
+ case scheme
130
+ when "tcp", "http" then u[:listen] = Address.host_port(@bind, @port || 9292, "--bind")
131
+ when "ssl", "https" then u[:listen_tls] = Address.host_port(@bind, @port || 9443, "--bind")
132
+ else raise ConfigError, "--bind #{@bind.inspect}: unsupported scheme #{scheme.inspect} " \
133
+ "(use tcp:// or ssl://)"
134
+ end
135
+ elsif @port
136
+ u[:listen] = "0.0.0.0:#{@port}"
137
+ end
138
+ end
139
+
140
+ def load_config!
141
+ return nil if @no_config
142
+
143
+ path = @config_path || self.class.config_candidates(@options[:environment]).find { |f| File.exist?(f) }
144
+ return nil unless path
145
+
146
+ unless File.exist?(path)
147
+ raise ConfigError, "config file not found: #{path}"
148
+ end
149
+
150
+ @config_used = path
151
+ ConfigFile.load(path, @options)
152
+ end
153
+
154
+ def validate!
155
+ if @options[:ractors].to_i.positive?
156
+ if @options[:workers].to_i > 1
157
+ raise ConfigError, "ractors and workers cannot both be set: Ractor mode is " \
158
+ "one process. Use one or the other."
159
+ end
160
+ end
161
+
162
+ unless File.exist?(@options[:app].to_s)
163
+ raise ConfigError, "rackup file not found: #{@options[:app]} " \
164
+ "(pass one as an argument, or set `rackup` in the config)"
165
+ end
166
+
167
+ validate_tls!
168
+
169
+ min = @options[:min_workers]
170
+ max = @options[:max_workers]
171
+ if min && max && min > max
172
+ raise ConfigError, "min_workers (#{min}) must be <= max_workers (#{max})"
173
+ end
174
+ end
175
+
176
+ # Checked here rather than left to the server, because an unreadable path is
177
+ # a typo in a config file and should be reported by the thing that read it.
178
+ def validate_tls!
179
+ cert = @options[:tls_cert]
180
+ key = @options[:tls_key]
181
+
182
+ if (cert && !key) || (key && !cert)
183
+ raise ConfigError, "tls_cert and tls_key must be given together " \
184
+ "(got only #{cert ? 'tls_cert' : 'tls_key'})"
185
+ end
186
+ return unless cert
187
+
188
+ { "tls_cert" => cert, "tls_key" => key }.each do |what, path|
189
+ raise ConfigError, "#{what}: no such file: #{path}" unless File.exist?(path)
190
+ raise ConfigError, "#{what}: not readable: #{path}" unless File.readable?(path)
191
+ end
192
+
193
+ if @options[:listen_tls].nil? && @options[:listen_h3].nil?
194
+ @stderr.puts "[puma-plus] config: tls_cert is set but no TLS or HTTP/3 " \
195
+ "listener is configured; it will not be used"
196
+ end
197
+ end
198
+
199
+ # Translate resolved options into the Go server's flags.
200
+ #
201
+ # One-way on purpose: the Go binary's flags stay the machine interface and
202
+ # the DSL stays the human one, so neither constrains the other's naming.
203
+ def go_argv
204
+ o = @options
205
+ args = []
206
+
207
+ args += ["-app", File.expand_path(o[:app])]
208
+ args += ["-control", o[:control]] if o[:control]
209
+ args += ["-threads", o[:threads].to_s] if o[:threads]
210
+
211
+ if o[:ractors].to_i.positive?
212
+ args += ["-ractors", o[:ractors].to_s]
213
+ else
214
+ args += ["-workers", o[:workers].to_s] if o[:workers]
215
+ end
216
+
217
+ # A TLS or HTTP/3 listener replaces the cleartext one unless both were
218
+ # asked for; -listen "" is how the Go side is told not to open it.
219
+ if o[:listen_tls]
220
+ args += ["-listen-tls", o[:listen_tls]]
221
+ args += ["-listen", o.origin(:listen) == :default ? "" : o[:listen]]
222
+ else
223
+ args += ["-listen", o[:listen]]
224
+ end
225
+ args += ["-h2c"] if o[:h2c]
226
+ args += ["-listen-h3", o[:listen_h3]] if o[:listen_h3]
227
+ args += ["-tls-hosts", o[:tls_hosts]] if o[:tls_hosts]
228
+ if o[:tls_cert] && o[:tls_key]
229
+ args += ["-tls-cert", o[:tls_cert], "-tls-key", o[:tls_key]]
230
+ end
231
+
232
+ if o[:autoscale]
233
+ args << "-autoscale"
234
+ args += ["-min-workers", o[:min_workers].to_s] if o[:min_workers]
235
+ args += ["-max-workers", o[:max_workers].to_s] if o[:max_workers]
236
+ args += ["-target-queue-p95", o[:target_queue_p95]] if o[:target_queue_p95]
237
+ end
238
+ args += ["-mem-limit-mb", o[:mem_limit_mb].to_s] if o[:mem_limit_mb]
239
+ args += ["-decision-log", o[:decision_log]] if o[:decision_log]
240
+ args += ["-max-conns", o[:max_conns].to_s] if o[:max_conns]
241
+ args += ["-acquire-timeout", o[:acquire_timeout]] if o[:acquire_timeout]
242
+ args += ["-debug-headers"] if o[:debug_headers]
243
+ args += ["-websockets=false"] if o[:websockets] == false
244
+ args += ["-webtransport=false"] if o[:webtransport] == false
245
+
246
+ args
247
+ end
248
+
249
+ # The worker script this gem ships, in whichever layout we are in.
250
+ def worker_script
251
+ here = File.expand_path("../..", __dir__) # gem/lib/puma_plus -> gem
252
+ %w[exe/puma-plus-worker ../gem/exe/puma-plus-worker].each do |rel|
253
+ cand = File.expand_path(rel, here)
254
+ return cand if File.exist?(cand)
255
+ end
256
+ raise ConfigError, "cannot find puma-plus-worker next to #{__dir__}"
257
+ end
258
+
259
+ # Load paths the worker needs: this gem's lib, plus the GVL extension if it
260
+ # was built in place (a checkout). An installed gem puts the compiled
261
+ # extension on the default load path already, so the extra entry is absent
262
+ # rather than wrong.
263
+ def ruby_load_path
264
+ lib = File.expand_path("../..", __dir__)
265
+ paths = [lib]
266
+ ext = File.expand_path("../ext/puma_plus_gvl", lib)
267
+ paths << ext if File.directory?(ext)
268
+ paths
269
+ end
270
+
271
+ # Locate the compiled server.
272
+ #
273
+ # PUMA_PLUS_BINARY wins, then the puma-plus-core gem if it is installed, then
274
+ # a build sitting in the checkout. The core gem is how a real install finds
275
+ # it: the Go server ships there, precompiled for the platform.
276
+ def go_binary_from_core
277
+ require "puma_plus/core"
278
+ path = PumaPlus::Core.binary_path
279
+ path if path && File.executable?(path)
280
+ rescue LoadError
281
+ nil
282
+ end
283
+
284
+ def go_binary
285
+ env = @env["PUMA_PLUS_BINARY"]
286
+ return env if env && File.executable?(env)
287
+
288
+ if (core = go_binary_from_core)
289
+ return core
290
+ end
291
+
292
+ here = File.expand_path("../../..", __dir__)
293
+ %w[puma-plus bin/puma-plus].each do |rel|
294
+ cand = File.join(here, rel)
295
+ return cand if File.executable?(cand) && !File.directory?(cand)
296
+ end
297
+
298
+ found = ENV["PATH"].to_s.split(File::PATH_SEPARATOR)
299
+ .map { |d| File.join(d, "puma-plus-server") }
300
+ .find { |f| File.executable?(f) }
301
+ return found if found
302
+
303
+ raise ConfigError, "cannot find the puma-plus server binary. Install the " \
304
+ "puma-plus-core gem, build it with " \
305
+ "`go build -o puma-plus ./cmd/puma-plus`, or set PUMA_PLUS_BINARY."
306
+ end
307
+
308
+ def write_pidfile
309
+ return unless @options[:pidfile]
310
+
311
+ # Written before exec, so it holds the pid the Go server will have -- exec
312
+ # keeps this process id. Writing it after would be impossible; writing a
313
+ # child's pid would be wrong.
314
+ File.write(@options[:pidfile], "#{Process.pid}\n")
315
+ end
316
+
317
+ def exec_go(args)
318
+ bin = go_binary
319
+ @stderr.puts "[puma-plus] #{@config_used ? "config #{@config_used}, " : ''}" \
320
+ "env #{@options[:environment]}"
321
+ env = {}
322
+ env["RACK_ENV"] = @options[:environment].to_s
323
+ env["PUMA_PLUS_CONFIG"] = @config_used if @config_used
324
+ # This file lives inside the gem, so it knows where the worker script and
325
+ # the load path are. Telling the server outright is the only thing that
326
+ # works in both layouts: a checkout has gem/exe and gem/lib, an installed
327
+ # gem has exe/ and lib/, and nothing the server could compute is right for
328
+ # both.
329
+ env["PUMA_PLUS_WORKER"] = worker_script
330
+ env["PUMA_PLUS_RUBY_LIB"] = ruby_load_path.join(File::PATH_SEPARATOR)
331
+ Kernel.exec(env, bin, *args)
332
+ end
333
+
334
+ def print_plan(args)
335
+ @stdout.puts "config file : #{@config_used || '(none)'}"
336
+ @stdout.puts "environment : #{@options[:environment]}"
337
+ @stdout.puts "server : #{begin; go_binary; rescue ConfigError; '(not built)'; end}"
338
+ @stdout.puts
339
+ @stdout.puts "resolved options (origin):"
340
+ @options.to_h.keys.sort_by(&:to_s).each do |k|
341
+ @stdout.printf(" %-20s %-28s %s\n", k, @options[k].inspect, @options.origin(k))
342
+ end
343
+ @stdout.puts
344
+ @stdout.puts "exec: #{File.basename(begin; go_binary; rescue ConfigError; 'puma-plus'; end)} #{args.join(' ')}"
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma_plus/wire"
4
+
5
+ module PumaPlus
6
+ # Assembles the Rack env from a REQUEST frame.
7
+ #
8
+ # Go emits the *complete, final* env in CGI form, so this class performs zero
9
+ # string transformation -- no dash/underscore mangling, no Host splitting, no
10
+ # case folding. All of that lives in internal/frontend/env.go. What remains
11
+ # here is a hash dup plus byteslice merges, which is the cheapest per-request
12
+ # env construction available without a C extension.
13
+ #
14
+ # Compare puma, which builds the env in five layers (Binder's @proto_env, the
15
+ # Ragel C parser writing straight into the hash, normalize_env,
16
+ # req_env_post_parse, then handle_request). Moving parsing to Go collapses that
17
+ # to one pass.
18
+ class RackEnv
19
+ # Keys that appear on essentially every request, pre-frozen so the common
20
+ # path never allocates a key string. Same optimization as puma's 35
21
+ # pre-interned header names in ext/puma_http11/puma_http11.c:78-118.
22
+ COMMON_KEYS = %w[
23
+ REQUEST_METHOD PATH_INFO SCRIPT_NAME QUERY_STRING SERVER_PROTOCOL
24
+ SERVER_NAME SERVER_PORT REMOTE_ADDR SERVER_SOFTWARE
25
+ CONTENT_LENGTH CONTENT_TYPE
26
+ rack.url_scheme
27
+ HTTP_HOST HTTP_ACCEPT HTTP_ACCEPT_CHARSET HTTP_ACCEPT_ENCODING
28
+ HTTP_ACCEPT_LANGUAGE HTTP_AUTHORIZATION HTTP_CACHE_CONTROL HTTP_CONNECTION
29
+ HTTP_COOKIE HTTP_IF_MODIFIED_SINCE HTTP_IF_NONE_MATCH HTTP_ORIGIN
30
+ HTTP_PRAGMA HTTP_REFERER HTTP_USER_AGENT HTTP_X_FORWARDED_FOR
31
+ HTTP_X_FORWARDED_PROTO HTTP_X_REQUEST_ID HTTP_X_REQUEST_START
32
+ ].each_with_object({}) { |k, h| h[k.b.freeze] = k.freeze }.freeze
33
+
34
+ # Uncommon keys are deduplicated into this cache on first sight, so a long
35
+ # tail of custom headers costs one allocation per distinct name for the
36
+ # lifetime of the process rather than one per request.
37
+ # multithread and multiprocess describe THIS process, and are computed by the
38
+ # caller from the real configuration rather than assumed.
39
+ #
40
+ # Rack 3 dropped these from the spec, along with rack.version and
41
+ # rack.run_once. They are kept because apps still read them -- sizing a
42
+ # connection pool is the usual reason -- and because puma still sets them
43
+ # (puma/lib/puma/binder.rb:33-35). rack.version is NOT kept: Rack 3 removed
44
+ # it and any value would be a claim about a spec version that no longer
45
+ # numbers itself that way.
46
+ #
47
+ # Hardcoding them, as this used to, meant a single-threaded worker still
48
+ # announced rack.multithread, and an app sizing a pool from it over-allocated
49
+ # by exactly the factor it was trying to compute.
50
+ def initialize(errors: $stderr, multithread: true, multiprocess: false)
51
+ @key_cache = Hash.new { |h, k| h[k] = -k.dup.force_encoding(Encoding::UTF_8) }
52
+ @prototype = {
53
+ "rack.errors" => errors,
54
+ "rack.hijack?" => true,
55
+ "rack.multithread" => multithread,
56
+ "rack.multiprocess" => multiprocess,
57
+ "rack.run_once" => false
58
+ }.freeze
59
+ end
60
+
61
+ # Build an env from a decoded env kv blob.
62
+ #
63
+ # +pairs+ is the array of [key, value] byteslices from Wire.decode_kv. Values
64
+ # are handed through as-is: they are byteslices of the single frame payload,
65
+ # so the whole env costs one payload string plus N slice headers.
66
+ def build(pairs, meta)
67
+ env = @prototype.dup
68
+
69
+ pairs.each do |key, value|
70
+ env[COMMON_KEYS[key] || @key_cache[key]] = value
71
+ end
72
+
73
+ # Timing, in the three shapes callers expect.
74
+ #
75
+ # puma.request_queue_time is ours and authoritative: nanoseconds measured
76
+ # entirely inside Go from a monotonic clock, so no cross-process skew.
77
+ #
78
+ # puma.request_body_wait keeps puma's exact key and millisecond unit
79
+ # (lib/puma/client.rb:746) so middleware written against puma works
80
+ # unchanged.
81
+ env["puma.request_queue_time"] = meta[:queue_ns] / 1_000_000_000.0
82
+ env["puma.request_body_wait"] = meta[:body_wait_ns] / 1_000_000.0
83
+
84
+ # X-Request-Start in the exact `t=<unix micros>` format New Relic, Scout
85
+ # and Skylight already parse, injected only if absent so an upstream load
86
+ # balancer's value always wins. Existing APM gems light up with no changes.
87
+ env["HTTP_X_REQUEST_START"] ||= "t=#{meta[:runnable_wall_us]}"
88
+
89
+ env
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "etc"
5
+ require "puma_plus/wire"
6
+ require "puma_plus/control_channel"
7
+ require "puma_plus/worker_thread"
8
+ require "puma_plus/app_loader"
9
+ require "puma_plus/hooks"
10
+ require "puma_plus/gvl"
11
+
12
+ module PumaPlus
13
+ # A worker process whose unit of capacity is a Ractor rather than a thread.
14
+ #
15
+ # Same contract as Worker: N units of capacity, each dialing the Go server and
16
+ # serving one request at a time. What differs is what a unit *is*. Threads
17
+ # share one lock, so N threads running CPU-bound Ruby is still one core's worth
18
+ # of throughput -- that is the entire reason this project scales by forking
19
+ # processes, and the reason the controller carries GVL instrumentation to
20
+ # detect when adding a thread would be pointless. Ractors each hold their own
21
+ # lock and genuinely run in parallel, so the actuator the controller wants
22
+ # ("add capacity") becomes available without a fork.
23
+ #
24
+ # This architecture is an unusually good fit for Ractors, and the reason is
25
+ # what is absent rather than what is present. The hard part of a Ractor web
26
+ # server is work distribution: Ractors cannot share a mutable queue, so cougar
27
+ # (github.com/jhawthorn/cougar) has every Ractor call accept() on one shared
28
+ # TCPServer and lets the kernel do the balancing. puma-plus has no listener and
29
+ # no queue in Ruby at all -- both are in Go -- so each Ractor dials its own
30
+ # unix socket and shares literally nothing with its siblings. There is no
31
+ # shared object to make shareable, and no accept-balancing to get wrong.
32
+ #
33
+ # Cost model versus forking a worker: a Ractor starts in roughly a millisecond
34
+ # against tens to hundreds for fork-plus-app-boot, and it starts *warm* --
35
+ # same heap, same JIT state, no re-require. Fork dead time is exactly what
36
+ # costs the autoscaler its p99 in the step-mix benchmark, so this is the
37
+ # actuator most likely to close that gap.
38
+ #
39
+ # The remaining constraint is the app, which must be deeply frozen to cross a
40
+ # Ractor boundary. That is a real limitation and it excludes most of the
41
+ # ecosystem today, Rails very much included. See #shareable!.
42
+ #
43
+ # Known gap for the spike: WS/WebTransport publishing (PumaPlus::WS) keeps
44
+ # module-level state in the main Ractor and is not reachable from inside one,
45
+ # so realtime apps must still use the threaded worker.
46
+ class RactorWorker
47
+ def initialize(socket_path:, app_path:, ractors:, worker_id: 0, config_path: nil,
48
+ logger: $stderr)
49
+ @socket_path = socket_path
50
+ @app_path = app_path
51
+ @ractors = ractors
52
+ @worker_id = worker_id
53
+ @logger = logger
54
+ @hooks = Hooks.load(config_path, logger: logger)
55
+ end
56
+
57
+ def run
58
+ run_preloaded(AppLoader.load(@app_path))
59
+ end
60
+
61
+ # Serve using an already-loaded app, mirroring Worker#run_preloaded so a
62
+ # shepherd can preload once and hand the same object to either worker kind.
63
+ def run_preloaded(app)
64
+ # Ruby still prints an experimental-feature warning on first Ractor use.
65
+ # Scoped to this call rather than set globally, so warnings from anything
66
+ # else stay visible.
67
+ warning_was = Warning[:experimental]
68
+ Warning[:experimental] = false
69
+
70
+ app = shareable!(app)
71
+
72
+ # The event hook is VM-wide, so one registration covers every Ractor. What
73
+ # it measures changes meaning here: siblings no longer contend for a shared
74
+ # lock, so a rising GVL fraction now indicates contention *within* a
75
+ # Ractor rather than across the process.
76
+ PumaPlus::GVL.start!
77
+
78
+ # Each argument crosses a Ractor boundary, so each must be shareable.
79
+ # Integers and the frozen path are; `app` was just checked.
80
+ @app = app
81
+ @path = @socket_path.dup.freeze
82
+ @live = {}
83
+ @mutex = Mutex.new
84
+ @exit_port = Ractor::Port.new
85
+ @next_index = 0
86
+ @running = true
87
+
88
+ # In the main Ractor, before any Ractor starts. Hooks cannot run *inside* a
89
+ # Ractor at all -- they are blocks closing over the config file's scope,
90
+ # which is exactly what cannot cross a Ractor boundary -- so there is one
91
+ # boot hook per process here rather than one per unit of capacity. For the
92
+ # usual use, establishing a connection pool, per-process is what you want
93
+ # anyway.
94
+ @hooks.run(:on_worker_boot, @worker_id)
95
+
96
+ reaper = Thread.new { reap_loop }
97
+ @ractors.times { spawn_ractor }
98
+
99
+ @logger.puts "[puma-plus] worker #{@worker_id} pid=#{Process.pid} " \
100
+ "serving with #{@ractors} ractors"
101
+
102
+ # A Ractor cannot be killed from outside, so unlike the threaded worker
103
+ # there is no Thread#kill equivalent to drain with. The graceful path is
104
+ # GOAWAY from Go, which breaks each WorkerThread's read loop from the
105
+ # inside; TERM is the abrupt fallback and does drop in-flight requests.
106
+ trap("TERM") { @running = false }
107
+ trap("INT") { @running = false }
108
+
109
+ # The control connection is what makes autoscaling possible: without it
110
+ # Go can retire Ractors on its own (GOAWAY an idle conn) but has no way to
111
+ # ask for a new one, since only Ruby can call Ractor.new.
112
+ @control = ControlChannel.new(socket_path: @socket_path, logger: @logger).connect!
113
+ @control.run(self)
114
+ reaper.kill
115
+ ensure
116
+ @hooks.run(:on_worker_shutdown, @worker_id, fatal: false)
117
+ @control&.close
118
+ Warning[:experimental] = warning_was unless warning_was.nil?
119
+ end
120
+
121
+ # --- ControlChannel handler protocol ---
122
+
123
+ def running? = @running
124
+
125
+ # SET_SLOTS carries the desired Ractor count.
126
+ def set_slots(target) = adjust_ractors(target)
127
+
128
+ # Nothing to stop replacing: Ractors are never respawned on death here, and
129
+ # Go decides the count.
130
+ def quiesce = @logger.puts("[puma-plus] quiescing")
131
+
132
+ def shutdown(_grace_ms) = @running = false
133
+
134
+ # One process, so one pid. Go reads its RSS from /proc; a per-Ractor share
135
+ # is not a real quantity, because Ractors share a heap.
136
+ def heartbeat_kv
137
+ [["workers", live_count], ["worker_pids", Process.pid.to_s]]
138
+ end
139
+
140
+ private
141
+
142
+ # Start one Ractor serving one connection.
143
+ #
144
+ # thread_index comes from a monotonic counter rather than the current size
145
+ # of the set, because a process that scaled down and back up would otherwise
146
+ # reuse an index still held by a Ractor that had not finished draining --
147
+ # and Go identifies a connection by (worker_id, thread_index).
148
+ def spawn_ractor
149
+ idx = @next_index
150
+ @next_index += 1
151
+
152
+ r = Ractor.new(@path, @app, @worker_id, idx, @exit_port, @ractors > 1,
153
+ name: "puma-plus #{@worker_id}/#{idx}") do |p, a, wid, i, port, mt|
154
+ # multithread because sibling Ractors call the app concurrently in this
155
+ # process; not multiprocess, since Ractor mode is a single process.
156
+ WorkerThread.new(socket_path: p, app: a, worker_id: wid, thread_index: i,
157
+ multithread: mt, multiprocess: false).run
158
+ ensure
159
+ # Reported from inside because a Ractor's exit cannot be polled from
160
+ # outside: there is no #alive?, and #join blocks. An ensure block also
161
+ # covers the Ractor dying of an exception, which a success-only signal
162
+ # would miss -- leaving the count permanently too high and the process
163
+ # unable to scale back up.
164
+ port.send(i)
165
+ end
166
+
167
+ @mutex.synchronize { @live[idx] = r }
168
+ r
169
+ end
170
+
171
+ # Remove Ractors as they finish.
172
+ #
173
+ # A Ractor ends when Go sends GOAWAY to its connection and WorkerThread's
174
+ # read loop breaks, so scale-down is initiated entirely from Go and arrives
175
+ # here only as bookkeeping. Runs in its own thread because Port#receive
176
+ # blocks; it is an ordinary thread in the main Ractor, so it can share
177
+ # @live under a mutex like any other.
178
+ def reap_loop
179
+ while @running
180
+ idx = @exit_port.receive
181
+ @mutex.synchronize { @live.delete(idx) }
182
+ end
183
+ rescue Ractor::ClosedError, Ractor::Error
184
+ nil
185
+ end
186
+
187
+ def live_count = @mutex.synchronize { @live.size }
188
+
189
+ # SET_SLOTS carries the desired Ractor count.
190
+ #
191
+ # Only ever grows. Shrinking is Go's job and it has already happened by the
192
+ # time this arrives: Go drains an idle connection with GOAWAY and then sends
193
+ # the lower target, so acting on a shrink here would at best be redundant
194
+ # and at worst would race the drain. This is the one place the Ractor
195
+ # actuator is genuinely simpler than the forking one -- the shepherd has to
196
+ # pick a pid and TERM it, and has to distinguish that from a crash.
197
+ def adjust_ractors(target)
198
+ target = 1 if target.nil? || target < 1
199
+
200
+ spawned = 0
201
+ while live_count < target
202
+ spawn_ractor
203
+ spawned += 1
204
+ end
205
+ return if spawned.zero?
206
+
207
+ @logger.puts "[puma-plus] spawned #{spawned} ractor(s), now #{live_count}"
208
+ end
209
+
210
+ # An app crosses into a Ractor only if it is deeply frozen. Modules and
211
+ # classes already qualify -- an app that is `MyApp` with `def self.call`
212
+ # needs nothing. A Rack::Builder middleware chain is a graph of ordinary
213
+ # objects and has to be deep-frozen, which is where most real apps refuse:
214
+ # anything memoizing into an ivar on first request, or holding a connection
215
+ # pool, or lazily building a route table, will raise here or (worse) at the
216
+ # first request. That is a genuine limitation of Ractor mode, not a bug.
217
+ def shareable!(app)
218
+ return app if Ractor.shareable?(app)
219
+
220
+ Ractor.make_shareable(app)
221
+ rescue StandardError => e
222
+ raise "#{@app_path} is not Ractor-shareable (#{e.class}: #{e.message}). " \
223
+ "Ractor mode requires an app that can be deep-frozen; run with " \
224
+ "--threads instead."
225
+ end
226
+ end
227
+ end