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.



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/hyperion/master.rb', line 64

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
  @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_max_rss_mb
  @worker_check_interval = @config.worker_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
# File 'lib/hyperion/master.rb', line 55

def self.build_h2_settings(config)
  {
    max_concurrent_streams: config.h2_max_concurrent_streams,
    initial_window_size: config.h2_initial_window_size,
    max_frame_size: config.h2_max_frame_size,
    max_header_list_size: config.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



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/hyperion/master.rb', line 87

def run
  install_signal_handlers
  bind_master_listener if @worker_model == :share
  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.
  @config.before_fork.each(&:call)

  @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