Class: Hyperion::Master

Inherits:
Object
  • Object
show all
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:

  1. ‘: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.

  2. ‘: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

Instance Method Summary collapse

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_modelObject



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

#runObject



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