hyperion-rb 2.15.0 → 2.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89ad0e591a8a44e23597ab8aa800ea65437c3e90d77903f7b3433c36368c87de
4
- data.tar.gz: b8a17ba534c62e8aa9d1b91f23e7baebf5a49d3074617fbcf73a328363e76aa4
3
+ metadata.gz: d599cad6595983a3aef241b7e5895b613c6275e44e8f05925f29c82fba99072c
4
+ data.tar.gz: c733f8b8e0f2d7de93a5bed82d4cea1331bbafafde347334cf08bbb00f8c1474
5
5
  SHA512:
6
- metadata.gz: 7e0059eba0f4d925bab72a1a7c4371e4e74c7336e62ac26135d3450480bfe6979ade740f0b7c236bfac67592eec40662ba0fca72c761cc2e208b8063dc6c04bb
7
- data.tar.gz: 5e38e2d43c8c8dea1f1dfce54639c7ea11377905a7193b3e87199c78f7aacccbed30218ec49ba1e1f39317541f361501671ded4b0d0875503806cc3680a9a856
6
+ metadata.gz: 8311ce71399125875a3bab4499244801dfeac405512a6a3339762dc075e495a86e961f1135eb33ec8ac30c7d2e0bb46d0951a53ba144f42a1a40fce855889a74
7
+ data.tar.gz: c1eee7b3bdc928bdf328223abf54c80d5a1d9742a770968063659a98943a04e7c0f3b5530c4b5e5fef62847ccf29d6ddafb4cf8e7b4ee187968a82c27ad0f8b5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.16.0 — 2026-05-04
4
+
5
+ ### 2.16-A — `preload false`: macOS fork+resolver escape hatch
6
+
7
+ **Why.** On macOS, Hyperion's "always preload" model deadlocks
8
+ post-fork DNS resolution for any deployment whose master process
9
+ ends up touching `Network.framework` — typically through native
10
+ gems loaded transitively via `Bundler.require` (OpenSSL session
11
+ caches, observability agents, Foundation-backed clients). The
12
+ master's `Network.framework` path evaluator initializes against XPC
13
+ peers in `mDNSResponder`; after `fork()` those XPC connections are
14
+ invalid in the workers, and the next `getaddrinfo` call hangs
15
+ forever inside `nw_path_evaluator_evaluate` →
16
+ `nw_nat64_v4_address_requires_synthesis`. The symptom: workers
17
+ spin at 99% CPU, requests TCP-connect but never get a response,
18
+ no per-request log line ever fires.
19
+ `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` does not help — that
20
+ flag only covers Foundation / Obj-C runtime init, not
21
+ `Network.framework`.
22
+
23
+ The reason Puma users don't hit this: Puma without explicit
24
+ `preload_app! true` runs in non-preload mode, so the master is a
25
+ thin supervisor and each worker loads the Rack app post-fork
26
+ against a clean process. Hyperion shipped no such option — preload
27
+ was the only mode.
28
+
29
+ **What 2.16-A ships.**
30
+
31
+ 1. **`preload` config field** (default `true` to preserve current
32
+ behaviour). When `preload false` is set in `config/hyperion.rb`
33
+ or `--no-preload` is passed on the CLI, the master never loads
34
+ `config.ru` and never calls `Hyperion.warmup!`. Each worker
35
+ parses the rackup itself post-fork via the same
36
+ `Hyperion::CLI.load_rack_app` path the master used to use; the
37
+ admin middleware wrap is preserved per-worker. `--preload` is
38
+ the inverse for argv overrides.
39
+
40
+ 2. **Single-worker mode is exempt.** With `workers <= 1` there is
41
+ no fork to protect against, so `run_single` always loads the
42
+ app in-process — deferring the parse buys nothing and would
43
+ surface lifecycle hooks against an unloaded app.
44
+
45
+ 3. **Master/Worker plumbing.** `Master.new` gains a `rackup_path:`
46
+ kwarg; `Worker.new` mirrors it and lazy-parses if `@app` is
47
+ `nil` and a path was provided. Both keep their existing
48
+ contract for the preload path (master receives `app:`, workers
49
+ inherit via fork).
50
+
51
+ **Trade-off.** Non-preload loses copy-on-write — each worker pays
52
+ the full Rails-boot RSS independently. Steady-state memory is N×
53
+ higher and worker boot is slower. The escape hatch is meant for
54
+ operators who hit the macOS deadlock; Linux users should leave it
55
+ at the default (`preload true`).
56
+
57
+ **Verification.**
58
+
59
+ - `bin/check` green (81 examples, 0 failures).
60
+ - Hand-reproduced against a Rails 8.1 application with a typical
61
+ native-gem stack (OpenSSL, observability agent, multi-A-record
62
+ DNS host for the database). Without `preload false`: deadlock
63
+ reproduces deterministically; CPU pegs at 99% per worker; curl
64
+ times out. With `preload false`: requests return promptly.
65
+
3
66
  ## 2.15.0 — 2026-05-02
4
67
 
5
68
  ### 2.15-A — Fresh bench, README split, CI flake fix
data/bin/check ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # Hyperion health-check. Run this BEFORE claiming a change is "all green".
3
+ #
4
+ # Usage:
5
+ # bin/check # default: compile + syntax + smoke specs (~10s)
6
+ # bin/check --quick # same as default
7
+ # bin/check --full # compile + syntax + full rspec (perf excluded)
8
+ # bin/check --syntax-only # compile + ruby -wc on lib/
9
+ # bin/check spec/path/foo_spec.rb [more...] # compile + targeted specs
10
+ #
11
+ # Exits non-zero on the first failing stage. Hooks-friendly: stdout is
12
+ # stage-tagged so .claude/bin/run-bounded.sh shows the right slice on tail.
13
+ #
14
+ # Smoke set covers the request hot path: parser, request, response writer,
15
+ # connection lifecycle, server loop, runtime. Each spec runs in <1s on a
16
+ # warm bundler cache; the full set lands in ~5–10s total.
17
+
18
+ set -uo pipefail
19
+
20
+ cd "$(dirname "$0")/.."
21
+
22
+ mode="quick"
23
+ specs=()
24
+ if [ $# -gt 0 ]; then
25
+ case "$1" in
26
+ --quick) mode="quick"; shift ;;
27
+ --full) mode="full"; shift ;;
28
+ --syntax-only) mode="syntax" ;;
29
+ -h|--help)
30
+ sed -n '2,15p' "$0"
31
+ exit 0 ;;
32
+ *) mode="targeted"; specs=("$@") ;;
33
+ esac
34
+ fi
35
+
36
+ stage() { printf '\n=== %s ===\n' "$1"; }
37
+ fail() { printf '\n!!! FAIL: %s\n' "$1"; exit 1; }
38
+
39
+ # --- 1. Compile (always; cheap if already built) -----------------------------
40
+ stage "compile (rake compile)"
41
+ bundle exec rake compile || fail "rake compile"
42
+
43
+ # --- 2. Syntax check on lib/ (fast, catches typos before specs load) --------
44
+ stage "ruby -wc on lib/hyperion/**/*.rb"
45
+ syntax_errors=0
46
+ while IFS= read -r -d '' f; do
47
+ if ! ruby -wc "$f" >/dev/null 2>/tmp/hyperion-check-syntax.err; then
48
+ printf ' syntax: %s\n' "$f"
49
+ cat /tmp/hyperion-check-syntax.err
50
+ syntax_errors=$((syntax_errors + 1))
51
+ fi
52
+ done < <(find lib -name '*.rb' -type f -print0)
53
+ [ "$syntax_errors" -eq 0 ] || fail "$syntax_errors file(s) failed ruby -wc"
54
+
55
+ if [ "$mode" = "syntax" ]; then
56
+ printf '\nOK (compile + syntax only)\n'
57
+ exit 0
58
+ fi
59
+
60
+ # --- 3. Specs ---------------------------------------------------------------
61
+ case "$mode" in
62
+ quick)
63
+ stage "rspec smoke set"
64
+ bundle exec rspec --fail-fast \
65
+ spec/hyperion/c_parser_spec.rb \
66
+ spec/hyperion/parser_spec.rb \
67
+ spec/hyperion/request_spec.rb \
68
+ spec/hyperion/response_writer_spec.rb \
69
+ spec/hyperion/connection_spec.rb \
70
+ spec/hyperion/runtime_spec.rb \
71
+ spec/hyperion/server_spec.rb \
72
+ spec/hyperion/config_spec.rb \
73
+ || fail "smoke specs"
74
+ ;;
75
+ full)
76
+ stage "rspec (full; :perf excluded by spec_helper)"
77
+ bundle exec rspec --fail-fast || fail "full rspec"
78
+ ;;
79
+ targeted)
80
+ stage "rspec ${specs[*]}"
81
+ bundle exec rspec --fail-fast "${specs[@]}" || fail "targeted specs"
82
+ ;;
83
+ esac
84
+
85
+ printf '\nOK (mode=%s)\n' "$mode"
data/lib/hyperion/cli.rb CHANGED
@@ -97,8 +97,6 @@ module Hyperion
97
97
  Hyperion.logger.info { { message: 'FiberLocal shim installed' } } if Hyperion::FiberLocal.installed?
98
98
  end
99
99
 
100
- app = load_rack_app(rackup)
101
- app = wrap_admin_middleware(app, config)
102
100
  workers = config.workers.zero? ? Etc.nprocessors : config.workers
103
101
 
104
102
  # 2.0 default flip (RFC A7): resolve the `h2.max_total_streams`
@@ -107,10 +105,30 @@ module Hyperion
107
105
  # (operator-requested unbounded).
108
106
  config.finalize!(workers: workers)
109
107
 
108
+ # 2.16 — preload toggle. In preload mode (default) the master
109
+ # parses config.ru once and workers inherit the loaded app via
110
+ # copy-on-write. In non-preload mode the master never touches
111
+ # the app; each worker parses post-fork. The non-preload path
112
+ # is the documented escape hatch for macOS getaddrinfo+fork
113
+ # deadlocks; it costs CoW (each worker pays the full boot RSS).
114
+ preload = config.preload != false
115
+ if preload
116
+ app = wrap_admin_middleware(load_rack_app(rackup), config)
117
+ else
118
+ app = nil
119
+ Hyperion.logger.info do
120
+ { message: 'preload disabled; each worker will parse rackup after fork',
121
+ rackup: File.expand_path(rackup) }
122
+ end
123
+ end
124
+
110
125
  if workers <= 1
126
+ # Single-mode always preloads — there's no fork to protect from
127
+ # global state poisoning, so deferring the parse buys nothing.
128
+ app ||= wrap_admin_middleware(load_rack_app(rackup), config)
111
129
  run_single(config, app)
112
130
  else
113
- run_cluster(config, app, workers)
131
+ run_cluster(config, app, workers, rackup_path: preload ? nil : File.expand_path(rackup))
114
132
  end
115
133
  end
116
134
 
@@ -258,6 +276,15 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
258
276
  'Explicit `--preload-static` dirs still take effect.') do
259
277
  cli_opts[:auto_preload_static_disabled] = true
260
278
  end
279
+ # 2.16 — app preload toggle.
280
+ o.on('--[no-]preload',
281
+ 'Preload the Rack app in the master before fork (default ON). ' \
282
+ '--no-preload makes each worker parse config.ru post-fork; ' \
283
+ 'needed on macOS when native gems loaded in the master ' \
284
+ '(anything that touches Network.framework via XPC) ' \
285
+ 'deadlock getaddrinfo in workers post-fork.') do |v|
286
+ cli_opts[:preload] = v
287
+ end
261
288
  o.on('-h', '--help', 'show help') do
262
289
  puts o
263
290
  exit 0
@@ -345,20 +372,22 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
345
372
  Hyperion.logger.flush_all
346
373
  end
347
374
 
348
- def self.run_cluster(config, app, workers)
375
+ def self.run_cluster(config, app, workers, rackup_path: nil)
349
376
  tls = build_tls_from_config(config)
350
377
  Master.new(host: config.host, port: config.port, app: app,
351
378
  workers: workers, tls: tls, thread_count: config.thread_count,
352
- read_timeout: config.read_timeout, config: config).run
379
+ read_timeout: config.read_timeout, config: config,
380
+ rackup_path: rackup_path).run
353
381
  end
354
382
 
355
383
  # Rack 3's parse_file returns a single app value; Rack 2 returned [app, options].
356
- # Normalize so we get just the app either way.
384
+ # Normalize so we get just the app either way. Used by both the preload
385
+ # path (master parses once, before fork) and the non-preload path
386
+ # (each worker parses post-fork) — see Worker#run.
357
387
  def self.load_rack_app(path)
358
388
  result = ::Rack::Builder.parse_file(path)
359
389
  result.is_a?(Array) ? result.first : result
360
390
  end
361
- private_class_method :load_rack_app
362
391
 
363
392
  def self.build_tls_from_config(config)
364
393
  return nil unless config.tls_cert || config.tls_key
@@ -610,7 +639,6 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
610
639
  end
611
640
  AdminMiddleware.new(app, token: config.admin.token)
612
641
  end
613
- private_class_method :wrap_admin_middleware
614
642
 
615
643
  # Read the admin token from a file on disk. Refuses to load if the file
616
644
  # is missing, unreadable, or world-readable — the whole point of using a
@@ -71,7 +71,25 @@ module Hyperion
71
71
  # the `--no-preload-static` CLI flag; lets operators turn off
72
72
  # auto-warming on a Rails app while still keeping the option to
73
73
  # configure explicit dirs via `preload_static`.
74
- auto_preload_static_disabled: false
74
+ auto_preload_static_disabled: false,
75
+ # 2.16: app preload toggle. When true (default) the master loads
76
+ # `config.ru` once before forking — workers inherit the loaded app
77
+ # via copy-on-write, the canonical Hyperion model. When false, the
78
+ # master stays a thin supervisor and each worker parses `config.ru`
79
+ # itself post-fork. Mirrors Puma's `preload_app! false` mode.
80
+ #
81
+ # The non-preload mode is the documented escape hatch for macOS
82
+ # workloads where loading native gems in the master (anything that
83
+ # initializes Network.framework / CoreFoundation via XPC) leaves
84
+ # the post-fork resolver in a deadlocked state — `getaddrinfo`
85
+ # hangs forever in `nw_path_evaluator_evaluate`. Setting `preload
86
+ # false` keeps the master's address space free of those globals so
87
+ # workers fork from a clean slate.
88
+ #
89
+ # Trade-off: each worker pays the boot cost (CPU + RSS) on its own,
90
+ # so steady-state RSS is N× higher and worker boot is slower. Linux
91
+ # users should leave this true.
92
+ preload: true
75
93
  }.freeze
76
94
 
77
95
  HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
@@ -68,10 +68,15 @@ module Hyperion
68
68
 
69
69
  def initialize(host:, port:, app:, workers: DEFAULT_WORKER_COUNT,
70
70
  read_timeout: Server::DEFAULT_READ_TIMEOUT_SECONDS, tls: nil,
71
- thread_count: Server::DEFAULT_THREAD_COUNT, config: nil)
71
+ thread_count: Server::DEFAULT_THREAD_COUNT, config: nil,
72
+ rackup_path: nil)
72
73
  @host = host
73
74
  @port = port
74
75
  @app = app
76
+ # 2.16 — non-preload mode: master holds a path; each worker parses
77
+ # post-fork. Mutually exclusive with @app (asserted by CLI flow:
78
+ # exactly one of the two is non-nil).
79
+ @rackup_path = rackup_path
75
80
  @workers = workers || Etc.nprocessors
76
81
  @read_timeout = read_timeout
77
82
  @tls = tls
@@ -118,7 +123,13 @@ module Hyperion
118
123
  # BEFORE we fork. Children inherit the warm memory via copy-on-write
119
124
  # so the first batch of requests on each fresh worker doesn't pay
120
125
  # the allocation/autoload tax.
121
- Hyperion.warmup!
126
+ #
127
+ # 2.16: skipped in non-preload mode. The whole point of `preload:
128
+ # false` is to keep the master's address space free of native-gem
129
+ # globals (OpenSSL session caches, Network.framework XPC state,
130
+ # etc.) so workers fork from a clean slate. Warming up here would
131
+ # re-introduce exactly the state we're trying to avoid.
132
+ Hyperion.warmup! if @app
122
133
 
123
134
  # `before_fork` runs ONCE in the master before any worker is forked.
124
135
  # Operators use it to close shared resources (DB pools, Redis sockets)
@@ -226,6 +237,10 @@ module Hyperion
226
237
  Signal.trap('TERM', 'DEFAULT')
227
238
  worker_args = {
228
239
  host: @host, port: @port, app: @app,
240
+ # 2.16 — propagate the deferred rackup path. Worker#run loads
241
+ # via Hyperion::CLI.load_rack_app + wraps admin middleware
242
+ # post-fork when @app is nil.
243
+ rackup_path: @rackup_path,
229
244
  read_timeout: @read_timeout, tls: @tls,
230
245
  thread_count: @thread_count, config: @config,
231
246
  worker_index: worker_index,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '2.15.0'
4
+ VERSION = '2.16.0'
5
5
  end
@@ -30,7 +30,8 @@ module Hyperion
30
30
  io_uring: :off,
31
31
  max_in_flight_per_conn: nil,
32
32
  tls_handshake_rate_limit: :unlimited,
33
- preload_static_dirs: nil)
33
+ preload_static_dirs: nil,
34
+ rackup_path: nil)
34
35
  @host = host
35
36
  @port = port
36
37
  @app = app
@@ -57,6 +58,7 @@ module Hyperion
57
58
  @max_in_flight_per_conn = max_in_flight_per_conn
58
59
  @tls_handshake_rate_limit = tls_handshake_rate_limit
59
60
  @preload_static_dirs = preload_static_dirs
61
+ @rackup_path = rackup_path
60
62
  end
61
63
 
62
64
  def run
@@ -70,6 +72,19 @@ module Hyperion
70
72
  }
71
73
  end
72
74
 
75
+ # 2.16 — non-preload mode: master never loaded the Rack app, so we
76
+ # parse it here, post-fork. Native gems load against THIS process's
77
+ # address space, not the master's, which avoids the macOS
78
+ # Network.framework + fork getaddrinfo deadlock (where post-fork
79
+ # XPC peers are wedged forever in `nw_path_evaluator_evaluate`).
80
+ if @app.nil? && @rackup_path
81
+ require_relative 'cli'
82
+ @app = ::Hyperion::CLI.wrap_admin_middleware(
83
+ ::Hyperion::CLI.load_rack_app(@rackup_path),
84
+ @config
85
+ )
86
+ end
87
+
73
88
  server = Server.new(host: @host, port: @port, app: @app,
74
89
  read_timeout: @read_timeout, tls: @tls,
75
90
  thread_count: @thread_count,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperion-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.15.0
4
+ version: 2.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov
@@ -154,6 +154,7 @@ files:
154
154
  - CHANGELOG.md
155
155
  - LICENSE
156
156
  - README.md
157
+ - bin/check
157
158
  - bin/hyperion
158
159
  - ext/hyperion_h2_codec/Cargo.lock
159
160
  - ext/hyperion_h2_codec/Cargo.toml