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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: aa68ce5c2a9260719885681158aaef4faa2b8f70187232bb3b78d7d5fb0d7b9a
4
+ data.tar.gz: 14505256a7bae7411c669edfce2428a319cd2e7f5b4dd8b321e8909cb126dabe
5
+ SHA512:
6
+ metadata.gz: b012201b85d423d11882ab801193bacc46b7a0a6fc0a69680a6bc4a4ebec4b615fdb9b7d1e13a9a7187f950dba7a95296c99aa62774814cedae084878872349b
7
+ data.tar.gz: 5439a0c1e43df45dba7525509b54eeefbdf76def6d7e1fa30d765b82658dda6a59201647fe360fa35f4b70229b26d2612d9fb042425b65850cfe50ef599d8384
data/exe/puma-plus ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The puma-plus command.
5
+ #
6
+ # Reads configuration the way puma does -- a Ruby file, CLI flags overriding it
7
+ # -- and then execs the Go server. See PumaPlus::Launcher for why Ruby parses
8
+ # the config and why this execs rather than supervises.
9
+
10
+ require "puma_plus/launcher"
11
+
12
+ exit(PumaPlus::Launcher.new(ARGV).run || 0)
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Entry point for a puma-plus Ruby worker process.
5
+ #
6
+ # Normally spawned by the Go server, which passes the worker socket path. Can be
7
+ # run by hand against `puma-plus -spawn=false` for debugging.
8
+
9
+ require "optparse"
10
+ require "puma_plus/worker"
11
+ require "puma_plus/ractor_worker"
12
+ require "puma_plus/shepherd"
13
+
14
+ # PUMA_PLUS_CONFIG is set by the `puma-plus` launcher before it execs the Go
15
+ # server, and inherited from there. Lifecycle hooks are blocks, so they cannot
16
+ # arrive as flags -- this process has to read the config file itself.
17
+ options = { threads: 5, worker_id: 0, workers: 0, ractors: 0,
18
+ config: ENV["PUMA_PLUS_CONFIG"] }
19
+
20
+ OptionParser.new do |opts|
21
+ opts.banner = "usage: puma-plus-worker --socket PATH --app PATH [--threads N]"
22
+
23
+ opts.on("--socket PATH", "unix socket to dial") { |v| options[:socket] = v }
24
+ opts.on("--app PATH", "Rack app (config.ru or .rb)") { |v| options[:app] = v }
25
+ opts.on("--threads N", Integer, "threads to run (default 5)") { |v| options[:threads] = v }
26
+ opts.on("--ractors N", Integer,
27
+ "serve from N Ractors instead of N threads (real parallelism; needs a "\
28
+ "deep-freezable app)") do |v|
29
+ options[:ractors] = v
30
+ end
31
+ opts.on("--config PATH", "config file to read lifecycle hooks from") { |v| options[:config] = v }
32
+ opts.on("--worker-id N", Integer, "worker index (default 0)") { |v| options[:worker_id] = v }
33
+ opts.on("--workers N", Integer,
34
+ "run a shepherd managing N worker processes (0 = single process, no shepherd)") do |v|
35
+ options[:workers] = v
36
+ end
37
+ opts.on("-h", "--help") { puts opts; exit }
38
+ end.parse!
39
+
40
+ abort "--socket is required" unless options[:socket]
41
+ abort "--app is required" unless options[:app]
42
+ abort "app not found: #{options[:app]}" unless File.exist?(options[:app])
43
+
44
+ # With --workers 0 this process serves directly, which keeps the Phase 1 debug
45
+ # path working. With --workers N it becomes a shepherd: it preloads the app,
46
+ # forks N children so they inherit a warm heap, and takes scaling commands from
47
+ # the Go server over the control connection.
48
+ if options[:ractors].positive? && options[:workers].positive?
49
+ abort "--ractors and --workers cannot be combined: the shepherd forks processes "\
50
+ "that run threads, and Ractor mode is one process. Use one or the other."
51
+ end
52
+
53
+ if options[:ractors].positive?
54
+ # Ractors are units of real parallelism, so N ractors in one process is the
55
+ # in-process analogue of N forked workers -- without the fork dead time.
56
+ PumaPlus::RactorWorker.new(
57
+ socket_path: options[:socket],
58
+ app_path: options[:app],
59
+ ractors: options[:ractors],
60
+ worker_id: options[:worker_id],
61
+ config_path: options[:config]
62
+ ).run
63
+ elsif options[:workers].positive?
64
+ PumaPlus::Shepherd.new(
65
+ socket_path: options[:socket],
66
+ app_path: options[:app],
67
+ workers: options[:workers],
68
+ threads: options[:threads],
69
+ config_path: options[:config]
70
+ ).run
71
+ else
72
+ PumaPlus::Worker.new(
73
+ socket_path: options[:socket],
74
+ app_path: options[:app],
75
+ threads: options[:threads],
76
+ worker_id: options[:worker_id],
77
+ config_path: options[:config]
78
+ ).run
79
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+
5
+ # The whole extension is one API. Without it there is nothing to build, so bail
6
+ # out gracefully rather than emitting a Makefile that produces a useless .so --
7
+ # the Ruby wrapper falls back to the cheaper heuristics.
8
+ unless have_func("rb_internal_thread_add_event_hook", "ruby/thread.h")
9
+ warn "puma-plus: rb_internal_thread_add_event_hook is unavailable " \
10
+ "(needs Ruby >= 3.0); GVL instrumentation will be disabled"
11
+ File.write("Makefile", "install:\n\t@true\nclean:\n\t@true\nall:\n\t@true\n")
12
+ exit 0
13
+ end
14
+
15
+ have_func("clock_gettime")
16
+ create_makefile("puma_plus_gvl")
@@ -0,0 +1,160 @@
1
+ /*
2
+ * GVL wait instrumentation for puma-plus.
3
+ *
4
+ * Measures how long each thread spends RUNNABLE BUT UNABLE TO RUN because
5
+ * another thread in the same process holds the GVL. Ruby exposes no other way
6
+ * to see this, and without it the scaling controller cannot tell two very
7
+ * different situations apart:
8
+ *
9
+ * - a thread blocked on a database socket -> more threads WILL help
10
+ * - a thread queued behind the GVL -> more threads will NOT help,
11
+ * and may make latency worse
12
+ *
13
+ * Both look identical to every cheaper heuristic, because a thread waiting for
14
+ * the GVL accrues neither CPU time nor IO wait: it is not on CPU, and it is not
15
+ * blocked on a file descriptor. It is simply queued. That is the blind spot
16
+ * this closes.
17
+ *
18
+ * Implementation constraint: the callback fires BETWEEN GVL states, so it must
19
+ * not touch the Ruby VM at all -- no allocation, no VALUEs, no calling back into
20
+ * Ruby. It does exactly two things: read a monotonic clock and add to a counter.
21
+ */
22
+
23
+ #include <ruby.h>
24
+ #include <ruby/thread.h>
25
+ #include <stdatomic.h>
26
+ #include <time.h>
27
+
28
+ /*
29
+ * Ruby 3.2's rb_internal_thread_event_data_t is `void` -- it carries no thread
30
+ * identity yet. That is fine, because the callback runs ON the thread the event
31
+ * concerns, so a thread-local is exactly the right place to stash the timestamp.
32
+ */
33
+ static _Thread_local uint64_t tl_ready_at = 0;
34
+ static _Thread_local uint64_t tl_wait_ns = 0;
35
+ static _Thread_local uint64_t tl_waits = 0;
36
+
37
+ /* Process-wide totals, for the per-second heartbeat. */
38
+ static _Atomic uint64_t g_wait_ns = 0;
39
+ static _Atomic uint64_t g_waits = 0;
40
+
41
+ static rb_internal_thread_event_hook_t *g_hook = NULL;
42
+
43
+ static inline uint64_t monotonic_ns(void) {
44
+ struct timespec ts;
45
+ clock_gettime(CLOCK_MONOTONIC, &ts);
46
+ return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec;
47
+ }
48
+
49
+ static void on_thread_event(rb_event_flag_t event,
50
+ const rb_internal_thread_event_data_t *data,
51
+ void *user_data) {
52
+ (void)data;
53
+ (void)user_data;
54
+
55
+ switch (event) {
56
+ case RUBY_INTERNAL_THREAD_EVENT_READY:
57
+ /* This thread now wants the GVL. Start the clock. */
58
+ tl_ready_at = monotonic_ns();
59
+ break;
60
+
61
+ case RUBY_INTERNAL_THREAD_EVENT_RESUMED: {
62
+ /* It got the GVL. The gap is time it was runnable but blocked. */
63
+ if (tl_ready_at != 0) {
64
+ uint64_t waited = monotonic_ns() - tl_ready_at;
65
+ tl_ready_at = 0;
66
+ tl_wait_ns += waited;
67
+ tl_waits += 1;
68
+ atomic_fetch_add_explicit(&g_wait_ns, waited, memory_order_relaxed);
69
+ atomic_fetch_add_explicit(&g_waits, 1, memory_order_relaxed);
70
+ }
71
+ break;
72
+ }
73
+
74
+ case RUBY_INTERNAL_THREAD_EVENT_EXITED:
75
+ tl_ready_at = 0;
76
+ break;
77
+
78
+ default:
79
+ break;
80
+ }
81
+ }
82
+
83
+ static VALUE gvl_start(VALUE self) {
84
+ if (g_hook != NULL) return Qfalse;
85
+
86
+ g_hook = rb_internal_thread_add_event_hook(
87
+ on_thread_event,
88
+ RUBY_INTERNAL_THREAD_EVENT_READY |
89
+ RUBY_INTERNAL_THREAD_EVENT_RESUMED |
90
+ RUBY_INTERNAL_THREAD_EVENT_EXITED,
91
+ NULL);
92
+ return g_hook != NULL ? Qtrue : Qfalse;
93
+ }
94
+
95
+ static VALUE gvl_stop(VALUE self) {
96
+ if (g_hook == NULL) return Qfalse;
97
+ bool removed = rb_internal_thread_remove_event_hook(g_hook);
98
+ g_hook = NULL;
99
+ return removed ? Qtrue : Qfalse;
100
+ }
101
+
102
+ static VALUE gvl_running_p(VALUE self) {
103
+ return g_hook != NULL ? Qtrue : Qfalse;
104
+ }
105
+
106
+ /* Nanoseconds this process has spent waiting for the GVL, all threads. */
107
+ static VALUE gvl_wait_ns(VALUE self) {
108
+ return ULL2NUM(atomic_load_explicit(&g_wait_ns, memory_order_relaxed));
109
+ }
110
+
111
+ static VALUE gvl_waits(VALUE self) {
112
+ return ULL2NUM(atomic_load_explicit(&g_waits, memory_order_relaxed));
113
+ }
114
+
115
+ /*
116
+ * Nanoseconds THIS thread has spent waiting for the GVL.
117
+ *
118
+ * Read around app.call, the delta is directly comparable to that request's
119
+ * service time: gvl_wait/service is the fraction of the request spent queued
120
+ * behind other Ruby threads rather than doing work.
121
+ */
122
+ static VALUE gvl_thread_wait_ns(VALUE self) {
123
+ return ULL2NUM(tl_wait_ns);
124
+ }
125
+
126
+ static VALUE gvl_thread_waits(VALUE self) {
127
+ return ULL2NUM(tl_waits);
128
+ }
129
+
130
+ void Init_puma_plus_gvl(void) {
131
+ /* Callable from a Ractor. Ruby assumes a C extension is Ractor-unsafe until
132
+ * told otherwise, so without this every method here raises
133
+ * Ractor::UnsafeError from a Ractor worker -- and RactorWorker reads
134
+ * thread_wait_ns on the request path.
135
+ *
136
+ * The claim is true by construction rather than by inspection: every piece
137
+ * of state this extension owns is either _Thread_local (tl_ready_at,
138
+ * tl_wait_ns, tl_waits) or _Atomic (g_wait_ns, g_waits, g_running). It
139
+ * allocates nothing, touches no VALUE, and calls into no VM function from
140
+ * the hook -- which it already had to guarantee, since the hook runs on
141
+ * threads that do not hold the GVL.
142
+ *
143
+ * What the numbers MEAN does change under Ractors, though the code does
144
+ * not. Ractors hold separate locks, so sibling Ractors no longer contend;
145
+ * a high fraction here now indicates contention between threads within one
146
+ * Ractor rather than across the process. */
147
+ rb_ext_ractor_safe(true);
148
+
149
+ VALUE mPumaPlus = rb_define_module("PumaPlus");
150
+ VALUE mGVL = rb_define_module_under(mPumaPlus, "GVL");
151
+
152
+ rb_define_singleton_method(mGVL, "native_start!", gvl_start, 0);
153
+ rb_define_singleton_method(mGVL, "native_stop!", gvl_stop, 0);
154
+ rb_define_singleton_method(mGVL, "running?", gvl_running_p, 0);
155
+ rb_define_singleton_method(mGVL, "wait_ns", gvl_wait_ns, 0);
156
+ rb_define_singleton_method(mGVL, "waits", gvl_waits, 0);
157
+ rb_define_singleton_method(mGVL, "thread_wait_ns", gvl_thread_wait_ns, 0);
158
+ rb_define_singleton_method(mGVL, "thread_waits", gvl_thread_waits, 0);
159
+ rb_define_const(mGVL, "NATIVE", Qtrue);
160
+ }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaPlus
4
+ # Address parsing shared by the config DSL and the command line.
5
+ #
6
+ # Its own file because both the launcher and the worker need it, and the
7
+ # worker loads the DSL without the launcher -- it reads the config only for
8
+ # lifecycle hooks. Having the DSL reach back into Launcher made that
9
+ # combination raise NameError on any config using activate_control_app, which
10
+ # no test caught because every launcher test loads the launcher.
11
+ module Address
12
+ module_function
13
+
14
+ # Normalize whatever someone typed for an address into "host:port".
15
+ #
16
+ # Accepts "tcp://127.0.0.1:9293", "127.0.0.1:9293", ":9293" and "9293",
17
+ # because all four are things people type and three of them are not URLs.
18
+ # URI.parse alone rejects the bare forms with a message about RFC 3986 that
19
+ # tells you nothing about what to do instead.
20
+ def host_port(value, default_port, what)
21
+ s = value.to_s.strip.sub(%r{\A[a-zA-Z][a-zA-Z0-9+.-]*://}, "")
22
+ case s
23
+ when /\A\[([^\]]+)\]:(\d+)\z/ then "[#{Regexp.last_match(1)}]:#{Regexp.last_match(2)}"
24
+ when /\A(.*):(\d+)\z/
25
+ host = Regexp.last_match(1)
26
+ "#{host.empty? ? '0.0.0.0' : host}:#{Regexp.last_match(2)}"
27
+ when /\A\d+\z/ then "0.0.0.0:#{s}"
28
+ when "" then raise(ConfigError, "#{what}: expected an address like 127.0.0.1:#{default_port}")
29
+ else "#{s}:#{default_port}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaPlus
4
+ # Loading a Rack app from a path.
5
+ #
6
+ # Extracted from Worker so the thread-backed and Ractor-backed workers load
7
+ # apps identically -- if they diverged, a benchmark comparing them would be
8
+ # comparing app loading as much as concurrency.
9
+ module AppLoader
10
+ module_function
11
+
12
+ # Load a Rack app from a .ru or a plain .rb.
13
+ #
14
+ # The .ru path uses Rack::Builder when the rack gem is available, and falls
15
+ # back to a minimal builder otherwise. The spike must be runnable before
16
+ # `gem install rack`, and a Rack app is only an object responding to #call.
17
+ def load(app_path)
18
+ source = File.read(app_path)
19
+
20
+ if app_path.end_with?(".ru")
21
+ begin
22
+ require "rack"
23
+ return Rack::Builder.new_from_string(source, app_path)
24
+ rescue LoadError
25
+ return MiniBuilder.build(source, app_path)
26
+ end
27
+ end
28
+
29
+ require File.expand_path(app_path)
30
+ unless defined?(::App) && ::App.respond_to?(:call)
31
+ raise "#{app_path} must define an App constant responding to #call"
32
+ end
33
+
34
+ ::App
35
+ end
36
+
37
+ # A stand-in for Rack::Builder supporting just `run` and `use`, so an example
38
+ # config.ru works with no gems installed. Not a Rack::Builder replacement --
39
+ # real apps should install rack.
40
+ module MiniBuilder
41
+ def self.build(source, path)
42
+ builder = new_builder
43
+ builder.instance_eval(source, path, 1)
44
+ builder.to_app
45
+ end
46
+
47
+ def self.new_builder
48
+ Object.new.tap do |o|
49
+ o.instance_variable_set(:@run, nil)
50
+ o.instance_variable_set(:@use, [])
51
+ def o.run(app) = @run = app
52
+ def o.use(middleware, *args, &blk) = @use << [middleware, args, blk]
53
+ def o.map(*) = raise(NotImplementedError, "map requires the rack gem")
54
+
55
+ def o.to_app
56
+ raise "config.ru never called run()" unless @run
57
+
58
+ @use.reverse.inject(@run) do |app, (mw, args, blk)|
59
+ mw.new(app, *args, &blk)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma_plus/address"
4
+
5
+ module PumaPlus
6
+ # Three-layer option lookup: command line beats config file beats default.
7
+ #
8
+ # Same precedence as puma's UserFileDefaultOptions, and for the same reason --
9
+ # a config file is checked in and shared, a command line is what you type to
10
+ # override it right now. Kept as three separate hashes rather than one merged
11
+ # one so `puma-plus --port 3000` still wins over a `port 9292` in the file no
12
+ # matter which was read first.
13
+ class Options
14
+ def initialize(defaults = {})
15
+ @default = defaults
16
+ @file = {}
17
+ @user = {}
18
+ end
19
+
20
+ attr_reader :default, :file, :user
21
+
22
+ def [](key)
23
+ return @user[key] if @user.key?(key)
24
+ return @file[key] if @file.key?(key)
25
+
26
+ @default[key]
27
+ end
28
+
29
+ def key?(key) = @user.key?(key) || @file.key?(key) || @default.key?(key)
30
+
31
+ # Where a value came from. Used by `--dry-run` so a surprising setting can be
32
+ # traced to the line that set it.
33
+ def origin(key)
34
+ return :cli if @user.key?(key)
35
+ return :config if @file.key?(key)
36
+ return :default if @default.key?(key)
37
+
38
+ nil
39
+ end
40
+
41
+ def to_h
42
+ @default.merge(@file).merge(@user)
43
+ end
44
+ end
45
+
46
+ # The config-file DSL, evaluated against a plain Ruby file the way puma
47
+ # evaluates config/puma.rb.
48
+ #
49
+ # A Ruby file rather than YAML because the interesting things are not values.
50
+ # Lifecycle hooks are blocks, worker counts are routinely computed from
51
+ # `Etc.nprocessors` or an env var, and a config that can branch on
52
+ # `environment` is the reason anyone tolerates a config file at all.
53
+ #
54
+ # Names follow puma wherever the concept exists, so a config can be ported by
55
+ # deleting the lines that no longer apply rather than rewritten. Where puma has
56
+ # no equivalent -- autoscaling, Ractors, HTTP/2 and /3 -- the names are ours.
57
+ class ConfigFile
58
+ # Options a puma config sets that puma-plus has no equivalent for, mapped to
59
+ # why. Accepted and ignored with a warning rather than raising: a config
60
+ # ported from puma should boot, and then tell you what it dropped.
61
+ IGNORED = {
62
+ preload_app!: "puma-plus always preloads; the shepherd loads the app once and forks",
63
+ prune_bundler: "the Go server is not a gem, so there is no bundler to prune",
64
+ queue_requests: "requests are always queued, in Go -- that is the whole design",
65
+ wait_for_less_busy_worker: "Go dispatches to an idle worker by construction",
66
+ fork_worker: "not implemented",
67
+ nakayoshi_fork: "not implemented",
68
+ set_remote_address: "Go builds REMOTE_ADDR when it constructs the env"
69
+ }.freeze
70
+
71
+ def initialize(options, path: nil)
72
+ @options = options
73
+ @path = path
74
+ @hooks = Hash.new { |h, k| h[k] = [] }
75
+ @warnings = []
76
+ end
77
+
78
+ attr_reader :hooks, :warnings
79
+
80
+ def self.load(path, options)
81
+ dsl = new(options, path: path)
82
+ dsl.instance_eval(File.read(path), path, 1)
83
+ dsl
84
+ end
85
+
86
+ # --- listeners ------------------------------------------------------
87
+
88
+ # bind "tcp://0.0.0.0:9292"
89
+ # bind "ssl://0.0.0.0:9443"
90
+ #
91
+ # Unix sockets are deliberately unsupported for the *client* listener: the
92
+ # unix socket in this design is how workers dial in, and letting a config
93
+ # point a public listener at one invites confusing the two.
94
+ def bind(url)
95
+ uri = begin
96
+ URI.parse(url)
97
+ rescue URI::Error => e
98
+ raise ConfigError, "bind #{url.inspect}: #{e.message}"
99
+ end
100
+ case uri.scheme
101
+ when "tcp", "http"
102
+ set :listen, "#{uri.host}:#{uri.port || 9292}"
103
+ when "ssl", "https"
104
+ set :listen_tls, "#{uri.host}:#{uri.port || 9443}"
105
+ apply_ssl_params(URI.decode_www_form(uri.query.to_s).to_h, url)
106
+ when "unix"
107
+ raise ConfigError, "bind #{url.inspect}: unix sockets are not supported for " \
108
+ "the HTTP listener (the unix socket is how workers dial in)"
109
+ else
110
+ raise ConfigError, "bind #{url.inspect}: unknown scheme #{uri.scheme.inspect}"
111
+ end
112
+ end
113
+
114
+ def port(port, host = nil)
115
+ set :listen, "#{host || '0.0.0.0'}:#{Integer(port)}"
116
+ end
117
+
118
+ # HTTP/2 cleartext on the main listener. Nothing in puma to mirror.
119
+ def h2c(enabled = true) = set(:h2c, !!enabled)
120
+
121
+ # HTTP/3 needs its own address because QUIC is UDP.
122
+ def http3(addr) = set(:listen_h3, addr.to_s)
123
+
124
+ def tls_hosts(*names) = set(:tls_hosts, names.flatten.join(","))
125
+
126
+ # ssl_bind "0.0.0.0", 9443, cert: "...", key: "..."
127
+ #
128
+ # puma's signature, so an existing ssl_bind line ports unchanged. The
129
+ # equivalent query-string form on `bind` works too, since both end up here.
130
+ def ssl_bind(host, port, opts = {})
131
+ set :listen_tls, "#{host}:#{Integer(port)}"
132
+ apply_ssl_params(opts.transform_keys(&:to_s), "ssl_bind")
133
+ end
134
+
135
+ # Certificate and key paths for the TLS and HTTP/3 listeners.
136
+ def tls_cert(cert, key) = (set(:tls_cert, expand(cert)); set(:tls_key, expand(key)))
137
+
138
+ # --- concurrency ----------------------------------------------------
139
+
140
+ def workers(count) = set(:workers, Integer(count))
141
+
142
+ # threads min, max
143
+ #
144
+ # puma-plus has no thread pool to grow and shrink: a thread is a connection
145
+ # to Go, dialed at boot and held. So only the max is meaningful, and a
146
+ # config that asks for a range gets told which number was used rather than
147
+ # silently having one picked.
148
+ def threads(min, max = min)
149
+ min = Integer(min)
150
+ max = Integer(max)
151
+ raise ConfigError, "threads: min (#{min}) must be <= max (#{max})" if min > max
152
+
153
+ if min != max
154
+ @warnings << "threads #{min},#{max}: puma-plus holds a fixed number of " \
155
+ "worker connections, so #{max} is used and #{min} ignored"
156
+ end
157
+ set :threads, max
158
+ end
159
+
160
+ # Serve from N Ractors in one process instead of threads across forks.
161
+ def ractors(count) = set(:ractors, Integer(count))
162
+
163
+ def max_connections(n) = set(:max_conns, Integer(n))
164
+
165
+ # --- autoscaling ----------------------------------------------------
166
+
167
+ # autoscale min: 1, max: 8, target_queue_p95: "25ms"
168
+ def autoscale(min: nil, max: nil, target_queue_p95: nil, enabled: true)
169
+ set :autoscale, !!enabled
170
+ set :min_workers, Integer(min) if min
171
+ set :max_workers, Integer(max) if max
172
+ set :target_queue_p95, duration(target_queue_p95, "target_queue_p95") if target_queue_p95
173
+ end
174
+
175
+ def mem_limit(mb) = set(:mem_limit_mb, Integer(mb))
176
+ def decision_log(path) = set(:decision_log, path.to_s)
177
+ def acquire_timeout(d) = set(:acquire_timeout, duration(d, "acquire_timeout"))
178
+
179
+ # --- app ------------------------------------------------------------
180
+
181
+ def rackup(path) = set(:app, path.to_s)
182
+ def environment(env) = set(:environment, env.to_s)
183
+ def directory(dir) = set(:directory, File.expand_path(dir.to_s))
184
+ def tag(name) = set(:tag, name.to_s)
185
+ def pidfile(path) = set(:pidfile, path.to_s)
186
+
187
+ # Where /stats, /metrics and /health are served.
188
+ def activate_control_app(url = "tcp://127.0.0.1:9293", _opts = {})
189
+ set :control, Address.host_port(url, 9293, "activate_control_app")
190
+ end
191
+
192
+ def websockets(enabled = true) = set(:websockets, !!enabled)
193
+ def webtransport(enabled = true) = set(:webtransport, !!enabled)
194
+ def debug_headers(enabled = true) = set(:debug_headers, !!enabled)
195
+
196
+ # --- lifecycle hooks ------------------------------------------------
197
+ #
198
+ # Blocks, so they cannot be translated into flags for the Go process. The
199
+ # worker re-reads this same config file and runs them itself; the launcher
200
+ # only records that they exist so it can pass the path along.
201
+
202
+ def before_fork(&blk) = @hooks[:before_fork] << blk
203
+ def on_worker_boot(&blk) = @hooks[:on_worker_boot] << blk
204
+ def on_worker_shutdown(&blk) = @hooks[:on_worker_shutdown] << blk
205
+
206
+ # --- accepted and ignored -------------------------------------------
207
+
208
+ IGNORED.each do |name, why|
209
+ define_method(name) do |*_args, &_blk|
210
+ @warnings << "#{name}: ignored -- #{why}"
211
+ nil
212
+ end
213
+ end
214
+
215
+ private
216
+
217
+ # Shared by `bind "ssl://..."` and `ssl_bind`, so the two forms cannot drift.
218
+ #
219
+ # Only cert and key are honoured. The rest of puma's SSL options configure a
220
+ # TLS stack this server does not have, and quietly accepting `verify_mode`
221
+ # would imply client-certificate verification that is not happening.
222
+ def apply_ssl_params(params, where)
223
+ cert = params["cert"] || params["cert_pem"]
224
+ key = params["key"] || params["key_pem"]
225
+
226
+ if cert && key
227
+ set :tls_cert, expand(cert)
228
+ set :tls_key, expand(key)
229
+ elsif cert || key
230
+ raise ConfigError, "#{where}: cert and key must be given together " \
231
+ "(got only #{cert ? 'cert' : 'key'})"
232
+ end
233
+
234
+ ignored = params.keys - %w[cert key cert_pem key_pem]
235
+ return if ignored.empty?
236
+
237
+ @warnings << "#{where}: ignored SSL options #{ignored.join(', ')} -- only " \
238
+ "cert and key are supported"
239
+ end
240
+
241
+ def expand(path) = File.expand_path(path.to_s)
242
+
243
+ def set(key, value)
244
+ @options.file[key] = value
245
+ end
246
+
247
+ # Accepts "25ms", "1s", 0.25 (seconds), or an Integer of seconds, and always
248
+ # emits a Go-parseable duration string.
249
+ def duration(value, what)
250
+ case value
251
+ when Numeric then "#{(value * 1000).round}ms"
252
+ when String
253
+ unless value =~ /\A\d+(\.\d+)?(ns|us|ms|s|m|h)\z/
254
+ raise ConfigError, "#{what}: #{value.inspect} is not a duration like \"25ms\" or \"1s\""
255
+ end
256
+
257
+ value
258
+ else
259
+ raise ConfigError, "#{what}: expected a duration, got #{value.class}"
260
+ end
261
+ end
262
+ end
263
+
264
+ class ConfigError < StandardError; end
265
+ end
266
+
267
+ require "uri"