hyperion-rb 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85d66793d64b68cd4a7abcabddbe39f0bff7e2f2ba79b94ea1e3347badd0cee5
4
- data.tar.gz: f1f42a47ce8849487f56c14b2e1ac1787f02617cc28972ecfd904233663b9acf
3
+ metadata.gz: 5670da7700c48436d0e3ded790cf5df090deeebd38bc0b7024b9b6e95c20b5c8
4
+ data.tar.gz: 10bedef6e02717511eb83bea0044e71978d7e13b9d93d0ac37310a89f6581e9a
5
5
  SHA512:
6
- metadata.gz: 5d028d0c624bf56d64cc6b9859043deeec7c2c7c677a632d5a890cb6190468f97cc2632fccd9d90a6e1f5c4fcedd056c326e0873f012a3ac09a1546f931793ec
7
- data.tar.gz: fbcb9b5fec440bf89fe54d3744bdab755582809aaeb4f7f098b7f4d7678e60f494cf5395f7ed6d05a8ebeb816c9e8c3bd7ced0da18d126b4812e22084883342d
6
+ metadata.gz: e791cdd9271cb954ddc11ee037ced8c182fffa4c8b27ded1d0c5672cada1d62fb4095d9e4c440136ce8eeed746eca6e4d99ebb3b1e42a2bc9bbd7bce5c1d9615
7
+ data.tar.gz: 4728b4bf159583fc6f46bd8c33dbcf916b74dddd49dd685159d39950112f5716cdc8108903d0ca312b31eef397d2237fab9d2f34d51e90822a7d3cab9c1b6691
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.0] - 2026-04-27
4
+
5
+ First minor release after 1.0.0. Production hardening + perf wins, no breaking changes.
6
+
7
+ ### Added
8
+ - **HTTP/2 §8.1.2 semantic validation** — Hyperion now rejects malformed `:method` / `:path` / `:scheme` pseudo-headers, connection-specific headers (`connection`, `te`, `transfer-encoding`, `keep-alive`, `upgrade`, `proxy-connection`), and inconsistent `content-length` framing with `RST_STREAM PROTOCOL_ERROR`. h2spec conformance pass rate is now 100% on the §8.1.2 suite (was 76.7% in 1.0.x).
9
+ - **Worker recycling (`worker_max_rss_mb`)** — master polls each child's RSS via `/proc/<pid>/statm` (Linux) or `ps -o rss=` (macOS/BSD) every `worker_check_interval` seconds (default 30s). Workers exceeding the configured RSS ceiling are gracefully cycled (SIGTERM, drain, respawn). Disabled when `worker_max_rss_mb` is nil.
10
+ - **Admin drain endpoint (`POST /-/quit`)** — token-protected Rack middleware that triggers the same SIGTERM-driven graceful shutdown as the signal path. Disabled by default; mount by setting `admin_token` in the Hyperion config DSL. Auth via `X-Hyperion-Admin-Token` header (constant-time comparison). Returns 202 + `{"status":"draining"}` on success, 401 on missing/wrong token.
11
+ - **YJIT auto-enable** — Hyperion enables YJIT automatically in production/staging environments (`RAILS_ENV` / `RACK_ENV` / `HYPERION_ENV`). Override with the `yjit` config setting (true/false) or `--[no-]yjit` CLI flag. No-op on Rubies built without YJIT.
12
+ - **C-extension access-log line builder** (`Hyperion::CParser.build_access_line`) — single-allocation line construction in C, ~10× faster than the Ruby interpolation path. Auto-selected on non-TTY destinations (production); colored TTY runs keep the Ruby fallback.
13
+ - **Date-header cache** — per-thread, per-second cache of `Time.now.httpdate` in `ResponseWriter`. Eliminates ~3 String allocations per response.
14
+ - **`bytes_read` / `bytes_written` metrics** — counters exposed via `Hyperion.stats` for connection-level bandwidth monitoring.
15
+ - **`Hyperion.c_parser_available?`** module accessor + boot-time warn line if the llhttp C extension didn't load (so operators running production with the slower pure-Ruby fallback notice immediately).
16
+ - **`MIGRATING_FROM_PUMA.md`** — operator guide covering config translation, lifecycle hook mapping, signal differences, and observability gaps.
17
+ - **Concurrency-at-scale benchmarks** — README now documents 10 000-connection keep-alive throughput and h2 multiplexing numbers vs Puma/Falcon.
18
+
19
+ ### Changed
20
+ - **Plain HTTP/1.1 accept loop bypasses Async** — when no TLS is configured, Hyperion uses a raw `IO.select` + `accept_nonblock` loop instead of wrapping the loop in an Async task. Worker-owns-connection semantics are unchanged. Removes ~2 µs of fiber-scheduler overhead from the hot accept path.
21
+
22
+ ### Fixed
23
+ - **Lost shutdown log lines under SIGTERM** — `Master#shutdown_children` and `CLI.run_single` now call `Logger#flush_all`, which walks every per-thread access-log buffer registered through the Logger and `IO#flush`es both stdout and stderr before the process exits. Operators no longer have to chase missing `master draining` / `master exiting` lines after a graceful shutdown.
24
+ - **Cross-instance Logger buffer leak** — per-thread access-log buffers are now namespaced per Logger instance (`:"__hyperion_access_buf_<oid>__"`). Previously a globally-shared key meant a buffer registered against an early Logger could be written to by a later Logger whose `flush_all` couldn't see it. The hot path remains a single `Thread.current` read.
25
+
26
+ ## [1.0.1] - 2026-04-26
27
+
28
+ ### Fixed
29
+ - Bumped `required_ruby_version` floor from `>= 3.2.0` to `>= 3.3.0` to match actual transitive dependency reality (`protocol-http2 ~> 0.26` requires Ruby >= 3.3). Previously, installing on Ruby 3.2 produced an opaque dep-resolution error mentioning protocol-http2 instead of a clean Ruby-version mismatch.
30
+ - CI matrix dropped `3.2.x` for the same reason.
31
+
3
32
  ## [1.0.0] - 2026-04-26
4
33
 
5
34
  First stable release. Same code as rc18; promoted from prerelease after smoke
data/README.md CHANGED
@@ -7,7 +7,7 @@ High-performance Ruby HTTP server. Falcon-class fiber concurrency, Puma-class co
7
7
  [![License: MIT](https://img.shields.io/github/license/andrew-woblavobla/hyperion.svg)](https://github.com/andrew-woblavobla/hyperion/blob/master/LICENSE)
8
8
 
9
9
  ```sh
10
- gem install hyperion-rb --pre
10
+ gem install hyperion-rb
11
11
  bundle exec hyperion config.ru
12
12
  ```
13
13
 
@@ -77,6 +77,35 @@ Health endpoint that traverses the full middleware chain (rack-attack, locale re
77
77
 
78
78
  On Grape and Rails-controller workloads Puma hits wrk's 2 s timeout cap on ~⅔ of requests — its real p99 is censored above 2 s. Hyperion serves all of its requests under 1.2 s with 0 to 16 timeouts. **1.14–1.48× Puma throughput** depending on endpoint.
79
79
 
80
+ ### Concurrency at scale (architectural advantages)
81
+
82
+ These workloads demonstrate structural differences between Hyperion's fiber-per-connection / fiber-per-stream model and Puma's thread-pool model. Numbers are illustrative; the architecture is what matters. Run on Ubuntu 24.04 / Ruby 3.3.3, single worker, h2load `-c <conns> -n 100000 --rps 1000 --h1`.
83
+
84
+ **5,000 concurrent keep-alive connections (50,000 requests):**
85
+
86
+ | | succeeded | r/s | wall | master RSS |
87
+ |---|---:|---:|---:|---:|
88
+ | Hyperion `-w 1 -t 10` | 50,000 / 50,000 | 3,460 | 14.45 s | 53.5 MB |
89
+ | Puma `-w 1 -t 10:10` | 50,000 / 50,000 | 1,762 | 28.37 s | 36.9 MB |
90
+
91
+ **10,000 concurrent keep-alive connections (100,000 requests):**
92
+
93
+ | | succeeded | failed | r/s | wall |
94
+ |---|---:|---:|---:|---:|
95
+ | Hyperion `-w 1 -t 10` | 93,090 | 6,910 | 3,446 | 27.01 s |
96
+ | Puma `-w 1 -t 10:10` | 77,340 | 22,660 | 706 | 109.59 s |
97
+
98
+ Hyperion holds each connection in a ~1 KB fiber stack; Puma needs an OS thread (~1–8 MB each, capped at `max_threads`). At 10k concurrent connections Hyperion serves **~5× the throughput** of Puma with **~20% fewer dropped requests**, while the per-connection bookkeeping cost is bounded by fiber size, not by `max_threads`.
99
+
100
+ **HTTP/2 multiplexing — 1 connection × 100 concurrent streams (handler sleeps 50 ms):**
101
+
102
+ | | wall time |
103
+ |---|---:|
104
+ | Hyperion (per-stream fiber dispatch) | **1.04 s** |
105
+ | Serial baseline (100 × 50 ms) | 5.00 s |
106
+
107
+ Hyperion fans 100 in-flight streams across separate fibers within a single TCP connection. A serial server would take 5 s; the fiber-multiplexed result (1.04 s, ~96 req/s on one socket) is bounded by single-handler sleep time plus framing overhead. Puma has no native HTTP/2 path — production deployments terminate h2 at nginx and forward h1 to the worker pool, which serializes again.
108
+
80
109
  ### Reproduce
81
110
 
82
111
  ```sh
@@ -107,6 +136,8 @@ curl --http2 -k https://127.0.0.1:9443/
107
136
 
108
137
  `bundle exec rake spec` (and the `default` task) auto-invoke `compile`, so a fresh checkout just needs `bundle install && bundle exec rake` to get a green run.
109
138
 
139
+ **Migrating from Puma?** See [docs/MIGRATING_FROM_PUMA.md](docs/MIGRATING_FROM_PUMA.md).
140
+
110
141
  ## Configuration
111
142
 
112
143
  Three layers, in precedence order: explicit CLI flag > environment variable > `config/hyperion.rb` > built-in default.
@@ -244,7 +275,7 @@ Smuggling defenses for HTTP/1.1: `Content-Length` + `Transfer-Encoding` together
244
275
 
245
276
  ## Compatibility
246
277
 
247
- - **Ruby 3.2+** required.
278
+ - **Ruby 3.3+** required (the `protocol-http2 ~> 0.26` transitive dep imposes this floor; older Ruby installs error at `bundle install`).
248
279
  - **Rack 3** (auto-sets `SERVER_SOFTWARE`, `rack.version`, `REMOTE_ADDR`, IPv6-safe `Host` parsing, CRLF guard).
249
280
  - **`Hyperion::FiberLocal.install!`** opt-in shim for older Rails apps that store request-scoped data via `Thread.current.thread_variable_*` (modern Rails 7.1+ already uses Fiber storage natively; the shim handles the residual footgun).
250
281
  - **`Hyperion::FiberLocal.verify_environment!`** runtime check that `Thread.current[:k]` is fiber-local on the current Ruby (it is on 3.2+).
@@ -404,6 +404,145 @@ static VALUE cbuild_response_head(VALUE self, VALUE rb_status, VALUE rb_reason,
404
404
  return buf;
405
405
  }
406
406
 
407
+ /* Hyperion::CParser.build_access_line(format, ts, method, path, query,
408
+ * status, duration_ms, remote_addr,
409
+ * http_version) -> String
410
+ *
411
+ * Hand-rolled access-log line builder used by Hyperion::Logger#access on the
412
+ * hot path. The Ruby version allocates 1-2 throwaway Strings per line; this
413
+ * builds the line into a stack scratch buffer (with rb_str_buf overflow for
414
+ * extreme cases) and returns a single Ruby String. ~10× faster on the
415
+ * common case, which closes the perf gap between log_requests on/off.
416
+ *
417
+ * `format` is :text or :json (Symbol). The format strings here mirror
418
+ * Logger#build_access_text / #build_access_json byte-for-byte (no colour —
419
+ * the C builder is only used when @colorize is false, i.e. non-TTY production
420
+ * deployments where access logs are the highest-volume log line).
421
+ *
422
+ * String inputs are passed through verbatim. Access logs are best-effort
423
+ * structured output, not a security boundary; CRLF in path/remote_addr would
424
+ * be a log-injection nuisance but cannot escalate. Status (int) and
425
+ * duration_ms (double/Numeric) go through snprintf, which is type-safe.
426
+ */
427
+ static VALUE cbuild_access_line(VALUE self,
428
+ VALUE format_sym, VALUE rb_ts, VALUE rb_method,
429
+ VALUE rb_path, VALUE rb_query, VALUE rb_status,
430
+ VALUE rb_duration, VALUE rb_remote,
431
+ VALUE rb_http_version) {
432
+ (void)self;
433
+ Check_Type(rb_ts, T_STRING);
434
+ Check_Type(rb_method, T_STRING);
435
+ Check_Type(rb_path, T_STRING);
436
+ Check_Type(rb_http_version, T_STRING);
437
+
438
+ int is_json = (TYPE(format_sym) == T_SYMBOL) &&
439
+ (SYM2ID(format_sym) == rb_intern("json"));
440
+
441
+ int status = NUM2INT(rb_status);
442
+ double dur_ms = NUM2DBL(rb_duration);
443
+
444
+ int has_query = !NIL_P(rb_query) && RSTRING_LEN(rb_query) > 0;
445
+ int has_remote = !NIL_P(rb_remote) && RSTRING_LEN(rb_remote) > 0;
446
+
447
+ /* 1 KiB initial buffer covers the vast majority of access-log lines
448
+ * (timestamp + level + path + status + addr ~= 200 bytes). rb_str_cat
449
+ * grows on overflow.
450
+ *
451
+ * We use a CAT_LIT macro for literal-string appends so the compiler
452
+ * computes length via sizeof — manual byte counts on hand-rolled
453
+ * literal lengths are an off-by-one waiting to happen. */
454
+ #define CAT_LIT(b, s) rb_str_cat((b), (s), (long)(sizeof(s) - 1))
455
+
456
+ VALUE buf = rb_str_buf_new(512);
457
+
458
+ if (is_json) {
459
+ /* Prefix: {"ts":"...","level":"info","source":"hyperion","message":"request", */
460
+ CAT_LIT(buf, "{\"ts\":\"");
461
+ rb_str_cat(buf, RSTRING_PTR(rb_ts), RSTRING_LEN(rb_ts));
462
+ CAT_LIT(buf, "\",\"level\":\"info\",\"source\":\"hyperion\",\"message\":\"request\",");
463
+ CAT_LIT(buf, "\"method\":\"");
464
+ rb_str_cat(buf, RSTRING_PTR(rb_method), RSTRING_LEN(rb_method));
465
+ CAT_LIT(buf, "\",\"path\":\"");
466
+ rb_str_cat(buf, RSTRING_PTR(rb_path), RSTRING_LEN(rb_path));
467
+ CAT_LIT(buf, "\"");
468
+
469
+ if (has_query) {
470
+ CAT_LIT(buf, ",\"query\":\"");
471
+ rb_str_cat(buf, RSTRING_PTR(rb_query), RSTRING_LEN(rb_query));
472
+ CAT_LIT(buf, "\"");
473
+ }
474
+
475
+ char num[64];
476
+ int n = snprintf(num, sizeof(num), ",\"status\":%d,\"duration_ms\":%g,",
477
+ status, dur_ms);
478
+ rb_str_cat(buf, num, n);
479
+
480
+ if (has_remote) {
481
+ CAT_LIT(buf, "\"remote_addr\":\"");
482
+ rb_str_cat(buf, RSTRING_PTR(rb_remote), RSTRING_LEN(rb_remote));
483
+ CAT_LIT(buf, "\",");
484
+ } else {
485
+ CAT_LIT(buf, "\"remote_addr\":null,");
486
+ }
487
+
488
+ CAT_LIT(buf, "\"http_version\":\"");
489
+ rb_str_cat(buf, RSTRING_PTR(rb_http_version), RSTRING_LEN(rb_http_version));
490
+ CAT_LIT(buf, "\"}\n");
491
+ } else {
492
+ /* text: "<ts> INFO [hyperion] message=request method=... path=... [query=...] status=... duration_ms=... remote_addr=... http_version=...\n" */
493
+ rb_str_cat(buf, RSTRING_PTR(rb_ts), RSTRING_LEN(rb_ts));
494
+ CAT_LIT(buf, " INFO [hyperion] message=request method=");
495
+ rb_str_cat(buf, RSTRING_PTR(rb_method), RSTRING_LEN(rb_method));
496
+ CAT_LIT(buf, " path=");
497
+ rb_str_cat(buf, RSTRING_PTR(rb_path), RSTRING_LEN(rb_path));
498
+
499
+ if (has_query) {
500
+ /* Mirror Logger#quote_if_needed: quote if value contains
501
+ * whitespace, '"', or '='. Hot path skips quoting. */
502
+ const char *q_ptr = RSTRING_PTR(rb_query);
503
+ long q_len = RSTRING_LEN(rb_query);
504
+ int need_quote = 0;
505
+ for (long j = 0; j < q_len; j++) {
506
+ char c = q_ptr[j];
507
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r' ||
508
+ c == '"' || c == '=') {
509
+ need_quote = 1;
510
+ break;
511
+ }
512
+ }
513
+ if (need_quote) {
514
+ /* Defer to Ruby's String#inspect for correct quoting. */
515
+ VALUE quoted = rb_funcall(rb_query, rb_intern("inspect"), 0);
516
+ CAT_LIT(buf, " query=");
517
+ rb_str_cat(buf, RSTRING_PTR(quoted), RSTRING_LEN(quoted));
518
+ } else {
519
+ CAT_LIT(buf, " query=");
520
+ rb_str_cat(buf, q_ptr, q_len);
521
+ }
522
+ }
523
+
524
+ char num[80];
525
+ /* Use %g to match the existing Ruby format which interpolates
526
+ * Float#to_s (no fixed precision). Status is an int. */
527
+ int n = snprintf(num, sizeof(num), " status=%d duration_ms=%g remote_addr=",
528
+ status, dur_ms);
529
+ rb_str_cat(buf, num, n);
530
+
531
+ if (has_remote) {
532
+ rb_str_cat(buf, RSTRING_PTR(rb_remote), RSTRING_LEN(rb_remote));
533
+ } else {
534
+ CAT_LIT(buf, "nil");
535
+ }
536
+
537
+ CAT_LIT(buf, " http_version=");
538
+ rb_str_cat(buf, RSTRING_PTR(rb_http_version), RSTRING_LEN(rb_http_version));
539
+ CAT_LIT(buf, "\n");
540
+ }
541
+
542
+ return buf;
543
+ }
544
+ #undef CAT_LIT
545
+
407
546
  void Init_hyperion_http(void) {
408
547
  install_settings();
409
548
 
@@ -416,6 +555,8 @@ void Init_hyperion_http(void) {
416
555
  rb_define_method(rb_cCParser, "parse", cparser_parse, 1);
417
556
  rb_define_singleton_method(rb_cCParser, "build_response_head",
418
557
  cbuild_response_head, 6);
558
+ rb_define_singleton_method(rb_cCParser, "build_access_line",
559
+ cbuild_access_line, 9);
419
560
 
420
561
  id_new = rb_intern("new");
421
562
  id_downcase = rb_intern("downcase");
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/utils'
4
+
5
+ module Hyperion
6
+ # Rack middleware that exposes administrative endpoints on the same
7
+ # listener as the application. Disabled by default — only mounted when
8
+ # `admin_token` is configured. Currently provides:
9
+ #
10
+ # POST /-/quit → triggers graceful master drain (SIGTERM to ppid)
11
+ #
12
+ # Auth: the request must include `X-Hyperion-Admin-Token: <token>`.
13
+ # Mismatch → 401. Path/method mismatch → falls through to the app
14
+ # (so the app can still own /-/anything if Hyperion's admin is off).
15
+ # When the token is unset, the constructor refuses to wrap — callers
16
+ # must skip mounting this middleware at all.
17
+ #
18
+ # SECURITY: the bearer token is defense-in-depth, not a substitute for
19
+ # network isolation. Operators MUST keep the listener on a private
20
+ # network or behind TLS + an authenticating reverse proxy. Anyone who
21
+ # can reach the listener AND knows the token can drain the server.
22
+ class AdminMiddleware
23
+ PATH = '/-/quit'
24
+
25
+ def initialize(app, token:, signal_target: nil)
26
+ raise ArgumentError, 'admin_token must be a non-empty String' if token.nil? || token.to_s.empty?
27
+
28
+ @app = app
29
+ @token = token.to_s
30
+ # Override hook for tests. Defaults to ppid in worker context, pid
31
+ # for single-worker context (caller decides).
32
+ @signal_target = signal_target
33
+ end
34
+
35
+ def call(env)
36
+ return @app.call(env) unless admin_request?(env)
37
+
38
+ provided = env['HTTP_X_HYPERION_ADMIN_TOKEN'].to_s
39
+ # Constant-time comparison. Rack::Utils.secure_compare requires same
40
+ # length, so prefix-pad first to avoid a length-leak side channel.
41
+ unless secure_match?(provided)
42
+ return [401, { 'content-type' => 'application/json' },
43
+ [%({"error":"unauthorized"}\n)]]
44
+ end
45
+
46
+ target = resolve_signal_target
47
+ Hyperion.logger.info { { message: 'admin drain requested', remote_addr: env['REMOTE_ADDR'], target_pid: target } }
48
+ begin
49
+ Process.kill('TERM', target)
50
+ rescue StandardError => e
51
+ Hyperion.logger.warn { { message: 'admin drain signal failed', error: e.message } }
52
+ return [500, { 'content-type' => 'application/json' }, [%({"error":"signal_failed"}\n)]]
53
+ end
54
+
55
+ [202, { 'content-type' => 'application/json' }, [%({"status":"draining"}\n)]]
56
+ end
57
+
58
+ private
59
+
60
+ def admin_request?(env)
61
+ env['PATH_INFO'] == PATH && env['REQUEST_METHOD'] == 'POST'
62
+ end
63
+
64
+ def secure_match?(provided)
65
+ return false if provided.empty?
66
+ return false unless provided.bytesize == @token.bytesize
67
+
68
+ Rack::Utils.secure_compare(provided, @token)
69
+ end
70
+
71
+ def resolve_signal_target
72
+ return @signal_target if @signal_target
73
+
74
+ # In a forked worker, ppid IS the master; in single-worker mode,
75
+ # the master + worker are the same process — signal self.
76
+ ppid = Process.ppid
77
+ ppid > 1 ? ppid : Process.pid
78
+ end
79
+ end
80
+ end
data/lib/hyperion/cli.rb CHANGED
@@ -53,6 +53,10 @@ module Hyperion
53
53
  o.on('--fiber-local-shim', 'Patch Thread.current[] to be fiber-local (Rails-compat for older gems)') do
54
54
  cli_opts[:fiber_local_shim] = true
55
55
  end
56
+ o.on('--[no-]yjit',
57
+ 'Enable Ruby YJIT (default: auto on RAILS_ENV/RACK_ENV=production/staging)') do |v|
58
+ cli_opts[:yjit] = v
59
+ end
56
60
  o.on('-h', '--help', 'show help') do
57
61
  puts o
58
62
  exit 0
@@ -79,6 +83,11 @@ module Hyperion
79
83
  # touch — fall through to the env/default chain in Hyperion.log_requests?".
80
84
  Hyperion.log_requests = config.log_requests unless config.log_requests.nil?
81
85
 
86
+ # Enable YJIT before workers fork / connections start. Auto-on in
87
+ # production/staging gives operators the perf bump for free; explicit
88
+ # config.yjit (true/false) overrides the env-based default.
89
+ maybe_enable_yjit(config)
90
+
82
91
  rackup = argv.first || 'config.ru'
83
92
  abort("[hyperion] no such rackup file: #{rackup}") unless File.exist?(rackup)
84
93
 
@@ -88,6 +97,7 @@ module Hyperion
88
97
  end
89
98
 
90
99
  app = load_rack_app(rackup)
100
+ app = wrap_admin_middleware(app, config)
91
101
  workers = config.workers.zero? ? Etc.nprocessors : config.workers
92
102
 
93
103
  if workers <= 1
@@ -105,6 +115,7 @@ module Hyperion
105
115
  server.listen
106
116
  scheme = tls ? 'https' : 'http'
107
117
  Hyperion.logger.info { { message: 'listening', url: "#{scheme}://#{server.host}:#{server.port}" } }
118
+ warn_c_parser_unavailable
108
119
 
109
120
  # Single-worker mode reuses the lifecycle hooks: before_fork is a no-op
110
121
  # here (no fork happens), and on_worker_boot/on_worker_shutdown fire
@@ -130,6 +141,11 @@ module Hyperion
130
141
  server.start
131
142
  shutdown_thread.join
132
143
  config.on_worker_shutdown.each { |h| h.call(0) }
144
+ # Drain per-thread access buffers + sync stdio. Single-worker mode
145
+ # doesn't go through Master#shutdown_children, so without this call
146
+ # buffered access lines + final shutdown messages can be lost on
147
+ # SIGTERM. See Hyperion::Logger#flush_all.
148
+ Hyperion.logger.flush_all
133
149
  end
134
150
 
135
151
  def self.run_cluster(config, app, workers)
@@ -155,5 +171,58 @@ module Hyperion
155
171
  { cert: config.tls_cert, key: config.tls_key }
156
172
  end
157
173
  private_class_method :build_tls_from_config
174
+
175
+ # Decide whether to enable YJIT and flip the switch once at boot.
176
+ # Precedence:
177
+ # 1. config.yjit explicitly true/false → honour exactly.
178
+ # 2. config.yjit nil (default) → auto: on for production/staging.
179
+ # No-op on Rubies without YJIT (e.g. JRuby/TruffleRuby) and idempotent if
180
+ # the operator already passed `ruby --yjit` upstream.
181
+ def self.maybe_enable_yjit(config)
182
+ return unless defined?(::RubyVM::YJIT)
183
+ return if ::RubyVM::YJIT.enabled?
184
+
185
+ enable = if config.yjit.nil?
186
+ env_name = ENV['HYPERION_ENV'] || ENV['RAILS_ENV'] || ENV['RACK_ENV']
187
+ %w[production staging].include?(env_name)
188
+ else
189
+ config.yjit
190
+ end
191
+
192
+ return unless enable
193
+
194
+ ::RubyVM::YJIT.enable
195
+ Hyperion.logger.info do
196
+ { message: 'YJIT enabled', mode: config.yjit.nil? ? 'auto' : 'explicit' }
197
+ end
198
+ end
199
+ private_class_method :maybe_enable_yjit
200
+
201
+ # When admin_token is configured, wrap the app in AdminMiddleware so
202
+ # POST /-/quit becomes a token-protected drain endpoint. Skipped when
203
+ # the token is unset — the path falls through to the app, so apps may
204
+ # still own /-/anything if Hyperion's admin is off.
205
+ def self.wrap_admin_middleware(app, config)
206
+ return app if config.admin_token.nil? || config.admin_token.to_s.empty?
207
+
208
+ Hyperion.logger.info { { message: 'admin endpoint enabled', path: AdminMiddleware::PATH } }
209
+ AdminMiddleware.new(app, token: config.admin_token)
210
+ end
211
+ private_class_method :wrap_admin_middleware
212
+
213
+ # Warn loudly at boot if the C parser didn't load — operators running
214
+ # production with the pure-Ruby fallback are paying ~2× CPU on parse-heavy
215
+ # workloads and probably don't know it.
216
+ def self.warn_c_parser_unavailable
217
+ return if Hyperion.c_parser_available?
218
+
219
+ Hyperion.logger.warn do
220
+ {
221
+ message: 'llhttp C parser not loaded — using pure-Ruby fallback (slower)',
222
+ remediation: 'rebuild the gem with `bundle exec rake compile` or check your OpenSSL/build-essential install'
223
+ }
224
+ end
225
+ end
226
+ private_class_method :warn_c_parser_unavailable
158
227
  end
159
228
  end
@@ -24,7 +24,11 @@ module Hyperion
24
24
  log_level: nil, # nil → Logger picks from env / default
25
25
  log_format: nil, # nil → Logger picks via auto rule
26
26
  log_requests: nil, # nil → Hyperion.log_requests? (default true)
27
- fiber_local_shim: false
27
+ fiber_local_shim: false,
28
+ yjit: nil, # nil → auto: enable on production/staging; true/false to force.
29
+ worker_max_rss_mb: nil, # Integer, e.g. 1024. When a worker exceeds this RSS in MB, master gracefully cycles it. nil disables.
30
+ worker_check_interval: 30, # Seconds between RSS polls. Tradeoff: tighter = faster recycle, more ps calls. 30s matches Puma WorkerKiller.
31
+ admin_token: nil # String. When set, POST /-/quit triggers graceful drain. nil disables endpoint entirely (returns 404).
28
32
  }.freeze
29
33
 
30
34
  HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
@@ -69,6 +69,7 @@ module Hyperion
69
69
  carry = +(buffer.byteslice(body_end, buffer.bytesize - body_end) || '')
70
70
  request = enrich_with_peer(request, peer_addr) if peer_addr && request.peer_address.nil?
71
71
 
72
+ @metrics.increment(:bytes_read, body_end)
72
73
  @metrics.increment(:requests_total)
73
74
  @metrics.increment(:requests_in_flight)
74
75
  request_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) if @log_requests
@@ -36,33 +36,143 @@ module Hyperion
36
36
  # Also exposes a `window_available` notification fan-out so the
37
37
  # response-writer fiber can sleep until WINDOW_UPDATE arrives.
38
38
  class RequestStream < ::Protocol::HTTP2::Stream
39
- attr_reader :request_headers, :request_body, :request_complete
39
+ # RFC 7540 §8.1.2.1 — the only pseudo-headers a server MUST accept on a
40
+ # request. Anything else (notably `:status`, which is response-only, or
41
+ # an unknown `:foo`) is a malformed request that we reject with
42
+ # PROTOCOL_ERROR.
43
+ VALID_REQUEST_PSEUDO_HEADERS = %w[:method :path :scheme :authority].freeze
44
+
45
+ # RFC 7540 §8.1.2.2 — these connection-specific headers MUST NOT appear
46
+ # in HTTP/2 requests; their semantics are folded into HTTP/2 framing.
47
+ FORBIDDEN_HEADERS = %w[connection transfer-encoding keep-alive upgrade proxy-connection].freeze
48
+
49
+ attr_reader :request_headers, :request_body, :request_complete, :protocol_error_reason
40
50
 
41
51
  def initialize(*)
42
52
  super
43
53
  @request_headers = []
44
54
  @request_body = +''
55
+ @request_body_bytes = 0
45
56
  @request_complete = false
46
57
  @window_available = ::Async::Notification.new
58
+ @protocol_error_reason = nil
59
+ @declared_content_length = nil
60
+ end
61
+
62
+ # Used by the dispatch loop to decide whether to invoke the app or
63
+ # send RST_STREAM PROTOCOL_ERROR. Set by `validate_request_headers!`
64
+ # and `validate_body_length!`.
65
+ def protocol_error?
66
+ !@protocol_error_reason.nil?
47
67
  end
48
68
 
49
69
  def process_headers(frame)
50
70
  decoded = super
71
+ # First HEADERS frame on a stream carries the request header block;
72
+ # any later HEADERS frame is trailers (§8.1) and we deliberately do
73
+ # not re-validate (re-running the validator would see the original
74
+ # request pseudo-headers plus the new trailer block and falsely flag
75
+ # them as misordered).
76
+ first_block = @request_headers.empty?
51
77
  # decoded is an Array of [name, value] pairs (HPACK output).
52
78
  decoded.each { |pair| @request_headers << pair }
53
- @request_complete = true if frame.end_stream?
79
+ # Run RFC 7540 §8.1.2 validation as soon as we have a complete header
80
+ # block. We do it here (not at end_stream) so the dispatcher sees the
81
+ # error flag before it spawns a fiber for the request.
82
+ validate_request_headers! if first_block && !protocol_error?
83
+ if frame.end_stream?
84
+ validate_body_length! unless protocol_error?
85
+ @request_complete = true
86
+ end
54
87
  decoded
55
88
  end
56
89
 
57
90
  def process_data(frame)
58
91
  data = super
59
92
  # rubocop:disable Rails/Present
60
- @request_body << data if data && !data.empty?
93
+ if data && !data.empty?
94
+ @request_body << data
95
+ @request_body_bytes += data.bytesize
96
+ end
61
97
  # rubocop:enable Rails/Present
62
- @request_complete = true if frame.end_stream?
98
+ if frame.end_stream?
99
+ validate_body_length! unless protocol_error?
100
+ @request_complete = true
101
+ end
63
102
  data
64
103
  end
65
104
 
105
+ # RFC 7540 §8.1.2 — request header validation. Sets
106
+ # `@protocol_error_reason` on the first violation we hit; the dispatch
107
+ # loop turns that into RST_STREAM PROTOCOL_ERROR.
108
+ def validate_request_headers!
109
+ seen_regular = false
110
+ pseudo_counts = Hash.new(0)
111
+ @request_headers.each do |pair|
112
+ name, value = pair
113
+ name = name.to_s
114
+ if name.start_with?(':')
115
+ # §8.1.2.1: pseudo-headers MUST precede regular headers.
116
+ return fail_validation!('pseudo-header after regular header') if seen_regular
117
+ # §8.1.2.1: only the four request pseudo-headers are valid; in
118
+ # particular, `:status` is response-only.
119
+ unless VALID_REQUEST_PSEUDO_HEADERS.include?(name)
120
+ return fail_validation!("invalid request pseudo-header: #{name}")
121
+ end
122
+
123
+ pseudo_counts[name] += 1
124
+ else
125
+ seen_regular = true
126
+ # §8.1.2: header names must be lowercase in HTTP/2.
127
+ return fail_validation!('uppercase header name') if /[A-Z]/.match?(name)
128
+ # §8.1.2.2: connection-specific headers are forbidden.
129
+ return fail_validation!("forbidden connection-specific header: #{name}") if FORBIDDEN_HEADERS.include?(name)
130
+ # §8.1.2.2: TE may only carry the value `trailers`.
131
+ if name == 'te' && value.to_s.downcase.strip != 'trailers'
132
+ return fail_validation!('TE header with non-trailers value')
133
+ end
134
+
135
+ # Track declared content-length for later body-byte cross-check.
136
+ @declared_content_length = value.to_s.to_i if name == 'content-length'
137
+ end
138
+ end
139
+
140
+ # §8.1.2.3: every pseudo-header may appear at most once.
141
+ pseudo_counts.each do |name, count|
142
+ return fail_validation!("duplicated pseudo-header: #{name}") if count > 1
143
+ end
144
+
145
+ method = pseudo_value(':method')
146
+ # CONNECT (§8.3) has its own rules; everything else MUST carry
147
+ # :method, :scheme and a non-empty :path.
148
+ if method == 'CONNECT'
149
+ return fail_validation!('CONNECT with :scheme') if pseudo_value(':scheme')
150
+ return fail_validation!('CONNECT with :path') if pseudo_value(':path')
151
+ return fail_validation!('CONNECT without :authority') unless pseudo_value(':authority')
152
+ else
153
+ return fail_validation!('missing :method') if method.nil? || method.empty?
154
+
155
+ scheme = pseudo_value(':scheme')
156
+ return fail_validation!('missing :scheme') if scheme.nil? || scheme.empty?
157
+
158
+ path = pseudo_value(':path')
159
+ return fail_validation!('missing or empty :path') if path.nil? || path.empty?
160
+ end
161
+
162
+ nil
163
+ end
164
+
165
+ # RFC 7540 §8.1.2.6 — if `content-length` was advertised, the actual
166
+ # number of DATA bytes received (across all DATA frames) MUST match.
167
+ def validate_body_length!
168
+ return if @declared_content_length.nil?
169
+ return if @declared_content_length == @request_body_bytes
170
+
171
+ fail_validation!(
172
+ "content-length mismatch: declared #{@declared_content_length}, received #{@request_body_bytes}"
173
+ )
174
+ end
175
+
66
176
  # Called by protocol-http2 whenever the remote peer's flow-control
67
177
  # window opens up — either via a stream-level WINDOW_UPDATE or via the
68
178
  # connection-level fan-out in `Connection#consume_window`. We poke the
@@ -78,6 +188,28 @@ module Hyperion
78
188
  def wait_for_window
79
189
  @window_available.wait
80
190
  end
191
+
192
+ private
193
+
194
+ # Look up a pseudo-header by name (e.g. `:method`) by scanning the raw
195
+ # collected pairs. Returns nil if absent. We don't pre-build a hash
196
+ # because the validator needs to detect duplicates first.
197
+ def pseudo_value(name)
198
+ @request_headers.each do |pair|
199
+ return pair[1].to_s if pair[0].to_s == name
200
+ end
201
+ nil
202
+ end
203
+
204
+ # Record the first protocol-error reason and short-circuit further
205
+ # validation. Returns nil so callers can `return fail_validation!(...)`.
206
+ def fail_validation!(reason)
207
+ @protocol_error_reason ||= reason
208
+ # As soon as a header-block violation is detected we treat the request
209
+ # as "complete" so the dispatch loop wakes up and emits RST_STREAM.
210
+ @request_complete = true
211
+ nil
212
+ end
81
213
  end
82
214
 
83
215
  def initialize(app:, thread_pool: nil)
@@ -175,6 +307,23 @@ module Hyperion
175
307
  end
176
308
 
177
309
  def dispatch_stream(stream, send_mutex, peer_addr = nil)
310
+ # RFC 7540 §8.1.2 — header validation flagged this stream as malformed.
311
+ # Send RST_STREAM PROTOCOL_ERROR instead of invoking the app.
312
+ if stream.protocol_error?
313
+ @logger.debug do
314
+ { message: 'h2 request rejected', reason: stream.protocol_error_reason, stream_id: stream.id }
315
+ end
316
+ @metrics.increment(:requests_rejected)
317
+ begin
318
+ send_mutex.synchronize do
319
+ stream.send_reset_stream(::Protocol::HTTP2::Error::PROTOCOL_ERROR) unless stream.closed?
320
+ end
321
+ rescue StandardError
322
+ nil
323
+ end
324
+ return
325
+ end
326
+
178
327
  pseudo, regular = partition_pseudo(stream.request_headers)
179
328
 
180
329
  method = pseudo[':method'] || 'GET'
@@ -64,6 +64,34 @@ module Hyperion
64
64
  # Colorize when format is text AND the destination is a TTY. We only
65
65
  # check the regular stream here — colored text is for humans.
66
66
  @colorize = @format == :text && tty?(@out)
67
+ @c_access_available = nil # lazy-computed on first access — see below.
68
+ # Registry of every per-thread access buffer ever allocated through
69
+ # this Logger instance. Walked by #flush_all on shutdown so SIGTERM
70
+ # doesn't strand buffered lines in dying threads. The Mutex guards
71
+ # registration on first allocation per thread (rare) and the shutdown
72
+ # walk; the hot #access path stays lock-free.
73
+ @access_buffers = []
74
+ @access_buffers_mutex = Mutex.new
75
+ # Per-instance thread-local key. A globally-shared key (e.g. a frozen
76
+ # Symbol constant) lets a buffer created by an earlier Logger in this
77
+ # thread be picked up by a later Logger — but the buffer is registered
78
+ # against the *earlier* Logger's @access_buffers, so the new Logger's
79
+ # #flush_all can't see it. Namespacing the key per-instance fixes that:
80
+ # each Logger gets its own per-thread buffer, and the registry it
81
+ # walks at shutdown matches the one #access wrote to. The Symbol is
82
+ # allocated once at construction; the hot path just reads it.
83
+ @buffer_key = :"__hyperion_access_buf_#{object_id}__"
84
+ end
85
+
86
+ # Whether Hyperion::CParser.build_access_line is available. Probed lazily
87
+ # on first call (the C parser is required after Logger is required, so we
88
+ # can't cache this at constant-define time — it would always be false).
89
+ # Memoised per-instance to keep the hot path branchless.
90
+ def c_access_available?
91
+ return @c_access_available unless @c_access_available.nil?
92
+
93
+ @c_access_available = defined?(::Hyperion::CParser) &&
94
+ ::Hyperion::CParser.respond_to?(:build_access_line)
67
95
  end
68
96
 
69
97
  LEVELS.each_key do |lvl|
@@ -106,13 +134,23 @@ module Hyperion
106
134
  return unless emit?(:info)
107
135
 
108
136
  ts = cached_timestamp
109
- line = if @format == :json
137
+ # The C extension builds the line in a stack scratch buffer (~10× faster
138
+ # than the Ruby interpolation path). It only fires when colorization is
139
+ # off — a colored TTY line needs ANSI escapes around the level label,
140
+ # which the C builder doesn't emit. Production deploys (non-TTY,
141
+ # log-aggregator destinations) take the C path; local TTY runs keep the
142
+ # colored Ruby fallback.
143
+ line = if !@colorize && c_access_available?
144
+ ::Hyperion::CParser.build_access_line(@format, ts, method, path,
145
+ query, status, duration_ms,
146
+ remote_addr, http_version)
147
+ elsif @format == :json
110
148
  build_access_json(ts, method, path, query, status, duration_ms, remote_addr, http_version)
111
149
  else
112
150
  build_access_text(ts, method, path, query, status, duration_ms, remote_addr, http_version)
113
151
  end
114
152
 
115
- buf = (Thread.current[:__hyperion_access_buf__] ||= +'')
153
+ buf = Thread.current[@buffer_key] || allocate_access_buffer
116
154
  buf << line
117
155
  return if buf.bytesize < ACCESS_FLUSH_BYTES
118
156
 
@@ -126,7 +164,7 @@ module Hyperion
126
164
  # loop when a connection closes (so log lines from a closing keep-alive
127
165
  # session don't get stuck behind the buffer until the next connection).
128
166
  def flush_access_buffer
129
- buf = Thread.current[:__hyperion_access_buf__]
167
+ buf = Thread.current[@buffer_key]
130
168
  return if buf.nil? || buf.empty?
131
169
 
132
170
  @out.write(buf)
@@ -135,8 +173,61 @@ module Hyperion
135
173
  # Swallow logger failures — never let logging crash the server.
136
174
  end
137
175
 
176
+ # Flush every per-thread access-log buffer ever allocated through this
177
+ # Logger, then sync the underlying IOs.
178
+ #
179
+ # Why this exists: under SIGTERM, Master#shutdown_children logs the
180
+ # 'master draining' / 'master exiting' lines and then exits. The 'info'
181
+ # path doesn't go through the access buffer, but it does rely on glibc
182
+ # stdio buffering being flushed before the process dies — and per-thread
183
+ # access buffers (Thread.current[:__hyperion_access_buf__]) are *only*
184
+ # flushed when the buffer reaches ACCESS_FLUSH_BYTES or when the owning
185
+ # thread closes a connection. On a clean SIGTERM both can be missed and
186
+ # the operator sees nothing in the captured log. This method walks every
187
+ # registered per-thread buffer, writes any pending bytes, then calls
188
+ # IO#flush on @out / @err so the kernel sees them before exec_exit.
189
+ #
190
+ # Safe to call from any thread. Idempotent. Never raises.
191
+ def flush_all
192
+ buffers = @access_buffers_mutex.synchronize { @access_buffers.dup }
193
+ buffers.each do |buf|
194
+ next if buf.empty?
195
+
196
+ begin
197
+ @out.write(buf)
198
+ buf.clear
199
+ rescue StandardError
200
+ # Continue — one bad buffer must not block the rest.
201
+ end
202
+ end
203
+
204
+ flush_io(@out)
205
+ flush_io(@err) unless @err.equal?(@out)
206
+ rescue StandardError
207
+ # Swallow logger failures — never let logging crash the server.
208
+ end
209
+
138
210
  private
139
211
 
212
+ # First-touch path for a thread's access buffer. Allocates the String,
213
+ # stores it in the thread-local for lock-free access on subsequent calls,
214
+ # and registers it in @access_buffers so #flush_all can find it later.
215
+ # Mutex is taken once per thread (not per request).
216
+ def allocate_access_buffer
217
+ buf = +''
218
+ Thread.current[@buffer_key] = buf
219
+ @access_buffers_mutex.synchronize { @access_buffers << buf }
220
+ buf
221
+ end
222
+
223
+ def flush_io(io)
224
+ io.flush if io.respond_to?(:flush)
225
+ rescue StandardError
226
+ # Some IO destinations raise on flush (closed pipes during SIGPIPE,
227
+ # custom IO-likes that don't implement it cleanly). Logging must
228
+ # never crash the server, especially during shutdown.
229
+ end
230
+
140
231
  # Cached UTC iso8601(3) timestamp, refreshed at most once per millisecond
141
232
  # per thread. At 24k r/s with 16 threads we render ~1500 r/s/thread; only
142
233
  # ~1000 of those allocate a new String. The other 500 reuse the cached one.
@@ -64,6 +64,10 @@ module Hyperion
64
64
  @stopping = false
65
65
  @worker_model = self.class.detect_worker_model
66
66
  @listener = nil # populated only in :share mode
67
+ @worker_max_rss_mb = @config.worker_max_rss_mb
68
+ @worker_check_interval = @config.worker_check_interval || 30
69
+ @last_health_check = 0 # monotonic seconds
70
+ @cycling = {} # pid => true while we wait for it to exit
67
71
  end
68
72
 
69
73
  def run
@@ -165,6 +169,7 @@ module Hyperion
165
169
  end
166
170
 
167
171
  reap_and_respawn
172
+ maybe_cycle_workers
168
173
  end
169
174
 
170
175
  shutdown_children
@@ -177,12 +182,47 @@ module Hyperion
177
182
 
178
183
  Hyperion.logger.warn { { message: 'worker died, respawning', worker_pid: pid } }
179
184
  @children.delete(pid)
185
+ @cycling.delete(pid)
180
186
  spawn_worker unless @stopping
181
187
  end
182
188
  rescue Errno::ECHILD
183
189
  # No children — happens during shutdown.
184
190
  end
185
191
 
192
+ # Periodically poll worker RSS and SIGTERM any that exceed the configured
193
+ # cap. The dying worker is reaped by `reap_and_respawn` on the next tick,
194
+ # which also clears the @cycling guard so the slot can be replaced.
195
+ # Skips entirely when no cap is configured — zero overhead by default.
196
+ def maybe_cycle_workers
197
+ return unless @worker_max_rss_mb
198
+
199
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
200
+ return if now - @last_health_check < @worker_check_interval
201
+
202
+ @last_health_check = now
203
+ @children.each_key do |pid|
204
+ next if @cycling.key?(pid)
205
+
206
+ rss = WorkerHealth.rss_mb(pid)
207
+ next unless rss && rss > @worker_max_rss_mb
208
+
209
+ Hyperion.logger.warn do
210
+ {
211
+ message: 'cycling worker for memory',
212
+ worker_pid: pid,
213
+ rss_mb: rss,
214
+ limit_mb: @worker_max_rss_mb
215
+ }
216
+ end
217
+ @cycling[pid] = true
218
+ begin
219
+ Process.kill('TERM', pid)
220
+ rescue StandardError
221
+ # process already gone — reap_and_respawn will handle it
222
+ end
223
+ end
224
+ end
225
+
186
226
  def shutdown_children
187
227
  Hyperion.logger.info do
188
228
  { message: 'master draining', graceful_timeout: @graceful_timeout }
@@ -216,6 +256,11 @@ module Hyperion
216
256
  @children.clear
217
257
 
218
258
  Hyperion.logger.info { { message: 'master exiting' } }
259
+ # Drain per-thread access buffers + sync stdio so the 'master draining'
260
+ # / 'master exiting' lines (and any in-flight access-log lines from
261
+ # threads that never reached the 4-KiB flush threshold) actually reach
262
+ # the operator's log file before the process exits on SIGTERM.
263
+ Hyperion.logger.flush_all
219
264
  end
220
265
  end
221
266
  end
@@ -51,14 +51,18 @@ module Hyperion
51
51
  # SINGLE io.write call. Each syscall round-trip is ~1 usec on macOS
52
52
  # kqueue; before this change we issued (1 status) + (N headers) + (1 blank)
53
53
  # + (1 body) = 8+ syscalls per response. Now: 1 syscall.
54
- if buffered.empty?
55
- io.write(head)
56
- else
57
- # Concatenate into the head buffer (which is already a fresh +'' from
58
- # the C builder or the Ruby fallback) so we still emit a single write.
59
- head << buffered
60
- io.write(head)
61
- end
54
+ bytes_out = if buffered.empty?
55
+ io.write(head)
56
+ head.bytesize
57
+ else
58
+ # Concatenate into the head buffer (which is already a fresh +''
59
+ # from the C builder or the Ruby fallback) so we still emit a
60
+ # single write.
61
+ head << buffered
62
+ io.write(head)
63
+ head.bytesize
64
+ end
65
+ Hyperion.metrics.increment(:bytes_written, bytes_out)
62
66
  ensure
63
67
  body.close if body.respond_to?(:close)
64
68
  end
@@ -76,6 +80,19 @@ module Hyperion
76
80
  end
77
81
  end
78
82
 
83
+ # Cached HTTP `Date:` header at second resolution. `Time.now.httpdate`
84
+ # allocates several strings; at high r/s the cache reuses one String per
85
+ # second per thread instead of allocating per response.
86
+ def cached_date
87
+ now_s = Process.clock_gettime(Process::CLOCK_REALTIME, :second)
88
+ cache = (Thread.current[:__hyperion_date_cache__] ||= [-1, ''])
89
+ return cache[1] if cache[0] == now_s
90
+
91
+ cache[0] = now_s
92
+ cache[1] = Time.now.httpdate
93
+ cache[1]
94
+ end
95
+
79
96
  def build_head_ruby(status, reason, headers, body_size, keep_alive, date_str)
80
97
  normalized = {}
81
98
  headers.each { |k, v| normalized[k.to_s.downcase] = v }
@@ -85,24 +85,17 @@ module Hyperion
85
85
  listen unless @server
86
86
  @thread_pool = ThreadPool.new(size: @thread_count) if @thread_count.positive?
87
87
 
88
- Async do |task|
89
- until @stopped
90
- socket = accept_or_nil
91
- next unless socket
92
-
93
- apply_timeout(socket)
94
- # Plain HTTP/1.1 with a pool: submit straight to the worker — no
95
- # fiber wrap needed (submit_connection returns immediately and the
96
- # worker thread owns the connection for its lifetime).
97
- # TLS still goes through a fiber: ALPN negotiation determines h2
98
- # vs http/1.1, and h2 needs the fiber because each stream is its
99
- # own fiber inside Http2Handler.
100
- if @thread_pool && !@tls
101
- @thread_pool.submit_connection(socket, @app)
102
- else
103
- task.async { dispatch(socket) }
104
- end
105
- end
88
+ if @tls
89
+ # TLS path: ALPN may pick `h2`, and h2 spawns one fiber per stream
90
+ # inside Http2Handler. Keep the Async wrapper so the scheduler is
91
+ # available for those fibers and for handshake yields.
92
+ start_async_loop
93
+ else
94
+ # Plain HTTP/1.1: the worker thread owns each connection for its
95
+ # lifetime, so the Async wrapper adds zero value (no fibers ever
96
+ # run on this loop's task). Skip it — pure IO.select + accept_nonblock
97
+ # shaves measurable overhead off the accept hot path.
98
+ start_raw_loop
106
99
  end
107
100
  ensure
108
101
  @thread_pool&.shutdown
@@ -117,6 +110,39 @@ module Hyperion
117
110
 
118
111
  private
119
112
 
113
+ # Plain HTTP/1.1 accept loop — no fiber wrap. Connections go straight to
114
+ # a worker via the thread pool, or are served inline when no pool is
115
+ # configured (thread_count: 0). Matches the dispatch contract used by
116
+ # the TLS path; just skips the irrelevant h2/ALPN branch.
117
+ def start_raw_loop
118
+ until @stopped
119
+ socket = accept_or_nil
120
+ next unless socket
121
+
122
+ apply_timeout(socket)
123
+ if @thread_pool
124
+ @thread_pool.submit_connection(socket, @app)
125
+ else
126
+ Connection.new.serve(socket, @app)
127
+ end
128
+ end
129
+ end
130
+
131
+ # TLS / h2-capable accept loop. The Async wrapper is required because
132
+ # h2 streams (inside Http2Handler) and the ALPN handshake yield
133
+ # cooperatively via the scheduler.
134
+ def start_async_loop
135
+ Async do |task|
136
+ until @stopped
137
+ socket = accept_or_nil
138
+ next unless socket
139
+
140
+ apply_timeout(socket)
141
+ task.async { dispatch(socket) }
142
+ end
143
+ end
144
+ end
145
+
120
146
  def dispatch(socket)
121
147
  if socket.is_a?(::OpenSSL::SSL::SSLSocket) && socket.alpn_protocol == 'h2'
122
148
  # HTTP/2: each stream runs on a fiber inside Http2Handler. The
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ # Measures a worker process's resident set size (RSS) in MiB.
5
+ # Cross-platform: uses /proc/<pid>/statm on Linux (zero subprocess) and
6
+ # `ps -o rss= -p <pid>` everywhere else (macOS, BSD).
7
+ module WorkerHealth
8
+ module_function
9
+
10
+ # Returns the worker's RSS in MiB, or nil if it can't be read (process
11
+ # gone, ps not available, /proc not mounted). Callers must handle nil
12
+ # gracefully — health checks must never crash the supervisor.
13
+ def rss_mb(pid)
14
+ if File.readable?("/proc/#{pid}/statm")
15
+ # statm fields are in pages; column index 1 is "resident".
16
+ # PAGE_SIZE = 4096 on x86_64 / aarch64 Linux.
17
+ contents = File.read("/proc/#{pid}/statm")
18
+ pages = contents.split.fetch(1).to_i
19
+ bytes = pages * 4096
20
+ bytes / 1024 / 1024
21
+ else
22
+ # Fallback: ps emits RSS in KiB.
23
+ out = `ps -o rss= -p #{pid} 2>/dev/null`
24
+ kib = out.strip.to_i
25
+ return nil if kib.zero?
26
+
27
+ kib / 1024
28
+ end
29
+ rescue StandardError
30
+ nil
31
+ end
32
+ end
33
+ end
data/lib/hyperion.rb CHANGED
@@ -25,6 +25,23 @@ module Hyperion
25
25
  metrics.snapshot
26
26
  end
27
27
 
28
+ # Whether YJIT is currently enabled in this Ruby process. False on Rubies
29
+ # that don't ship YJIT (JRuby, TruffleRuby) and on CRuby builds compiled
30
+ # without YJIT support. Cheap (no allocations) — safe to call from hot
31
+ # paths if needed for diagnostics.
32
+ def yjit_enabled?
33
+ defined?(::RubyVM::YJIT) && ::RubyVM::YJIT.enabled?
34
+ end
35
+
36
+ # Whether the llhttp C extension loaded. False on JRuby/TruffleRuby and
37
+ # any environment where extconf.rb / make failed at install time. The
38
+ # pure-Ruby parser handles those cases correctly but is ~2× slower on
39
+ # parse-heavy workloads. Operators running production should confirm this
40
+ # returns true; CLI emits a startup banner if it doesn't.
41
+ def c_parser_available?
42
+ defined?(::Hyperion::CParser) && ::Hyperion::CParser.respond_to?(:build_response_head)
43
+ end
44
+
28
45
  # Per-request access logging is ON by default — matches Puma/Rails operator
29
46
  # expectations (Rails::Rack::Logger emits one line per request out of the
30
47
  # box). Operators can disable it via `--no-log-requests`,
@@ -72,6 +89,7 @@ require_relative 'hyperion/request'
72
89
  require_relative 'hyperion/parser'
73
90
  require_relative 'hyperion/c_parser'
74
91
  require_relative 'hyperion/adapter/rack'
92
+ require_relative 'hyperion/admin_middleware'
75
93
  require_relative 'hyperion/response_writer'
76
94
  require_relative 'hyperion/thread_pool'
77
95
  require_relative 'hyperion/connection'
@@ -79,4 +97,5 @@ require_relative 'hyperion/tls'
79
97
  require_relative 'hyperion/http2_handler'
80
98
  require_relative 'hyperion/server'
81
99
  require_relative 'hyperion/worker'
100
+ require_relative 'hyperion/worker_health'
82
101
  require_relative 'hyperion/master'
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: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov
@@ -148,6 +148,7 @@ files:
148
148
  - lib/hyperion-rb.rb
149
149
  - lib/hyperion.rb
150
150
  - lib/hyperion/adapter/rack.rb
151
+ - lib/hyperion/admin_middleware.rb
151
152
  - lib/hyperion/c_parser.rb
152
153
  - lib/hyperion/cli.rb
153
154
  - lib/hyperion/config.rb
@@ -166,6 +167,7 @@ files:
166
167
  - lib/hyperion/tls.rb
167
168
  - lib/hyperion/version.rb
168
169
  - lib/hyperion/worker.rb
170
+ - lib/hyperion/worker_health.rb
169
171
  homepage: https://github.com/andrew-woblavobla/hyperion
170
172
  licenses:
171
173
  - MIT
@@ -181,7 +183,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
181
183
  requirements:
182
184
  - - ">="
183
185
  - !ruby/object:Gem::Version
184
- version: 3.2.0
186
+ version: 3.3.0
185
187
  required_rubygems_version: !ruby/object:Gem::Requirement
186
188
  requirements:
187
189
  - - ">="