Class: Hyperion::Master
- Inherits:
-
Object
- Object
- Hyperion::Master
- Defined in:
- lib/hyperion/master.rb
Overview
Pre-fork master process. Owns the supervision loop. Each worker is a full fiber-scheduler ‘Hyperion::Server` running its own accept loop.
rc15 — per-OS worker model. There are two ways to give N children a listening socket on the same port:
-
‘:reuseport` (Linux): each worker binds its OWN socket with SO_REUSEPORT. The kernel hashes incoming connections across the sibling sockets — no thundering herd, no shared accept lock, linear scaling with worker count. The master never binds.
-
‘:share` (macOS / BSD / everything else): the master binds a single TCPServer (or SSLServer) BEFORE fork. Children inherit the fd via fork(2) and race on `accept(2)` — whichever child wins gets the connection. This is Puma’s model. We use it on Darwin because Darwin’s SO_REUSEPORT distributor hashes unevenly: at ‘-w 4` against a real Rails app a single curl probe cannot get answered inside 120s in the worst case, because the kernel keeps routing to a worker whose accept queue is already full.
Detection: ‘RbConfig::CONFIG` matching `linux` picks `:reuseport`; everything else picks `:share`. Operators can pin the mode explicitly with `HYPERION_WORKER_MODEL=share|reuseport` (used by the test suite to exercise both paths on a single host).
Constant Summary collapse
- DEFAULT_WORKER_COUNT =
nil → Etc.nprocessors
nil- GRACEFUL_TIMEOUT_SECONDS =
30- WORKER_MODELS =
%i[reuseport share].freeze
Class Method Summary collapse
-
.build_h2_settings(config) ⇒ Object
Pulls the four configurable HTTP/2 SETTINGS values out of the Config and returns them as a Hash.
- .detect_worker_model ⇒ Object
Instance Method Summary collapse
-
#initialize(host:, port:, app:, workers: DEFAULT_WORKER_COUNT, read_timeout: Server::DEFAULT_READ_TIMEOUT_SECONDS, tls: nil, thread_count: Server::DEFAULT_THREAD_COUNT, config: nil) ⇒ Master
constructor
A new instance of Master.
- #run ⇒ Object
Constructor Details
#initialize(host:, port:, app:, workers: DEFAULT_WORKER_COUNT, read_timeout: Server::DEFAULT_READ_TIMEOUT_SECONDS, tls: nil, thread_count: Server::DEFAULT_THREAD_COUNT, config: nil) ⇒ Master
Returns a new instance of Master.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/hyperion/master.rb', line 69 def initialize(host:, port:, app:, workers: DEFAULT_WORKER_COUNT, read_timeout: Server::DEFAULT_READ_TIMEOUT_SECONDS, tls: nil, thread_count: Server::DEFAULT_THREAD_COUNT, config: nil) @host = host @port = port @app = app @workers = workers || Etc.nprocessors @read_timeout = read_timeout @tls = tls @thread_count = thread_count @config = config || Hyperion::Config.new # 2.0 default flip (RFC A7): if the operator hasn't already # finalized the config (e.g. via the CLI bootstrap path), do it # now so the worker count for the auto-cap formula is the one # Master actually uses. `finalize!` is idempotent — a config the # CLI already finalized passes through unchanged. @config.finalize!(workers: @workers || 1) @graceful_timeout = @config.graceful_timeout || GRACEFUL_TIMEOUT_SECONDS @children = {} # pid => worker_index @next_index = 0 @stopping = false @worker_model = self.class.detect_worker_model @listener = nil # populated only in :share mode @worker_max_rss_mb = @config.worker_health.max_rss_mb @worker_check_interval = @config.worker_health.check_interval || 30 @last_health_check = 0 # monotonic seconds @cycling = {} # pid => true while we wait for it to exit end |
Class Method Details
.build_h2_settings(config) ⇒ Object
Pulls the four configurable HTTP/2 SETTINGS values out of the Config and returns them as a Hash. Nils are stripped so an operator who explicitly sets one to ‘nil` (meaning “leave protocol-http2 default in place”) doesn’t accidentally send a SETTINGS entry with a nil value. Empty hash → no override → Http2Handler skips the SETTINGS push.
55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/hyperion/master.rb', line 55 def self.build_h2_settings(config) # 1.7.0 (RFC A4): read from the nested `H2Settings` subconfig. # The flat-name forwarders on `Config` still work for callers # holding a 1.6.x reference, but Master is in-tree so we point # at the nested object directly to avoid the extra hop. h2 = config.h2 { max_concurrent_streams: h2.max_concurrent_streams, initial_window_size: h2.initial_window_size, max_frame_size: h2.max_frame_size, max_header_list_size: h2.max_header_list_size }.compact end |
.detect_worker_model ⇒ Object
39 40 41 42 43 44 45 46 47 48 |
# File 'lib/hyperion/master.rb', line 39 def self.detect_worker_model override = ENV['HYPERION_WORKER_MODEL']&.to_sym return override if WORKER_MODELS.include?(override) host_os = RbConfig::CONFIG['host_os'].to_s case host_os when /linux/ then :reuseport else :share # macOS, BSD, anything else: shared-FD model (Puma-style) end end |
Instance Method Details
#run ⇒ Object
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/hyperion/master.rb', line 98 def run install_signal_handlers # Record master PID + export to ENV BEFORE the first fork. Workers # inherit the env var via copy-on-write so AdminMiddleware can target # the master regardless of whether `Process.ppid` is meaningful in # the deployment (containerd / Docker run hyperion as PID 1, where # ppid would point at the host's init or 0). See Hyperion.master_pid. Hyperion.master_pid!(Process.pid) Hyperion.logger.info do { message: 'master starting', pid: Process.pid, workers: @workers, host: @host, port: @port, worker_model: @worker_model } end # Pre-allocate Rack env-pool entries and eager-touch lazy constants # BEFORE we fork. Children inherit the warm memory via copy-on-write # so the first batch of requests on each fresh worker doesn't pay # the allocation/autoload tax. Hyperion.warmup! # `before_fork` runs ONCE in the master before any worker is forked. # Operators use it to close shared resources (DB pools, Redis sockets) # so each child gets fresh connections rather than inheriting the # parent's open fds. Mirrors Puma's hook of the same name. # # IMPORTANT: must fire BEFORE the master binds its listening socket on # `:share` mode. In `:reuseport` mode the master never binds — workers # bind their own SO_REUSEPORT sockets after fork — so `before_fork` # there trivially runs "before any listener exists." Pre-1.6.3 we # bound the master listener first on `:share` and ran `before_fork` # afterwards, which made the two worker models hand off the lifecycle # asymmetrically: an operator using `before_fork` to mutate listening # behaviour saw a different world depending on host OS. Binding here # restores symmetry — in both modes `before_fork` precedes any socket. @config.before_fork.each(&:call) bind_master_listener if @worker_model == :share @workers.times { spawn_worker } supervise ensure # The master keeps the listener open across its lifetime so it can # respawn workers (the new fork inherits the same fd). It only gets # closed here once the master itself is exiting. @listener&.close end |