hyperion-rb 1.0.1 → 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 +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +32 -1
- data/ext/hyperion_http/parser.c +141 -0
- data/lib/hyperion/admin_middleware.rb +80 -0
- data/lib/hyperion/cli.rb +69 -0
- data/lib/hyperion/config.rb +5 -1
- data/lib/hyperion/connection.rb +1 -0
- data/lib/hyperion/http2_handler.rb +153 -4
- data/lib/hyperion/logger.rb +94 -3
- data/lib/hyperion/master.rb +45 -0
- data/lib/hyperion/response_writer.rb +25 -8
- data/lib/hyperion/server.rb +44 -18
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/worker_health.rb +33 -0
- data/lib/hyperion.rb +19 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5670da7700c48436d0e3ded790cf5df090deeebd38bc0b7024b9b6e95c20b5c8
|
|
4
|
+
data.tar.gz: 10bedef6e02717511eb83bea0044e71978d7e13b9d93d0ac37310a89f6581e9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e791cdd9271cb954ddc11ee037ced8c182fffa4c8b27ded1d0c5672cada1d62fb4095d9e4c440136ce8eeed746eca6e4d99ebb3b1e42a2bc9bbd7bce5c1d9615
|
|
7
|
+
data.tar.gz: 4728b4bf159583fc6f46bd8c33dbcf916b74dddd49dd685159d39950112f5716cdc8108903d0ca312b31eef397d2237fab9d2f34d51e90822a7d3cab9c1b6691
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
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
|
+
|
|
3
26
|
## [1.0.1] - 2026-04-26
|
|
4
27
|
|
|
5
28
|
### Fixed
|
data/README.md
CHANGED
|
@@ -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.
|
|
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+).
|
data/ext/hyperion_http/parser.c
CHANGED
|
@@ -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
|
data/lib/hyperion/config.rb
CHANGED
|
@@ -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
|
data/lib/hyperion/connection.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
+
if data && !data.empty?
|
|
94
|
+
@request_body << data
|
|
95
|
+
@request_body_bytes += data.bytesize
|
|
96
|
+
end
|
|
61
97
|
# rubocop:enable Rails/Present
|
|
62
|
-
|
|
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'
|
data/lib/hyperion/logger.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
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[
|
|
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.
|
data/lib/hyperion/master.rb
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 }
|
data/lib/hyperion/server.rb
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
data/lib/hyperion/version.rb
CHANGED
|
@@ -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
|
|
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
|