hyperion-rb 1.4.2 → 1.6.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 +71 -0
- data/README.md +13 -0
- data/ext/hyperion_http/parser.c +322 -0
- data/lib/hyperion/adapter/rack.rb +29 -2
- data/lib/hyperion/cli.rb +112 -41
- data/lib/hyperion/connection.rb +22 -2
- data/lib/hyperion/http2_handler.rb +287 -24
- data/lib/hyperion/logger.rb +17 -1
- data/lib/hyperion/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eecb708980287e22968f1405778e91632ecd26eaae33845891ee00e94adf6839
|
|
4
|
+
data.tar.gz: e433cd1bff6039cd8c30bbc9fdce6cd4176ca1e03d33362f9e67ceddd7d89880
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c02db1008beea0246601193a4ca18e8ac931f9a4952a2571a88ecc24028a838707a55e0ecf2159ba53f76b4a3c32c94c635afeb957304ae16ca2e0c2951ef7a3
|
|
7
|
+
data.tar.gz: 8c3bb4674315a4c41cd4bf38296ab2c991b113f3bcb0ced20ae3a143572f289c3d9a00ce258ba317045a5427b4b7a7986bf6de9f499c84d0ec61b505d8563518
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.0] - 2026-04-27
|
|
4
|
+
|
|
5
|
+
Two parallel improvements landing in 1.6.0:
|
|
6
|
+
1. Three small C-extension additions on the request hot path (sibling commit — see "Performance" below).
|
|
7
|
+
2. Architectural rewrite of the HTTP/2 outbound write path — per-stream send queue + dedicated writer fiber replace the global `@send_mutex` (see "HTTP/2 writer architecture" below).
|
|
8
|
+
|
|
9
|
+
These are independent and can be reviewed / reverted separately. The CHANGELOG sub-sections will be merged before tag.
|
|
10
|
+
|
|
11
|
+
### HTTP/2 writer architecture (Changed)
|
|
12
|
+
- **`Hyperion::Http2Handler` now uses a per-connection writer fiber instead of a single send Mutex.** Pre-1.6.0 every framer write — HEADERS, DATA, RST_STREAM, GOAWAY — ran inside one `@send_mutex.synchronize { socket.write(...) }`. That capped per-connection h2 throughput at "one socket-write at a time" regardless of how many streams were concurrently in flight: a slow socket (kernel send buffer full, peer reading slowly) blocked every other stream's writes too. 1.6.0 splits the path:
|
|
13
|
+
- **Encode + frame format** (HPACK encoding, frame layout) is fast (microseconds, in-memory) and stays serialized on the calling fiber via `WriterContext#encode_mutex`. HPACK state is connection-scoped and stateful across HEADERS frames; per-stream wire order (HEADERS → DATA → END_STREAM) must also be preserved. Holding the encode mutex across a `stream.send_*` call satisfies both.
|
|
14
|
+
- **Bytes-to-socket** is owned by a dedicated `run_writer_loop` fiber spawned per connection. Encoder fibers hand bytes off via `WriterContext#enqueue` (non-blocking, signals an `Async::Notification`); the writer pops chunks from the queue and writes them. Only this fiber ever calls `socket.write`, satisfying SSLSocket's "no concurrent writes from different fibers" constraint.
|
|
15
|
+
- **Net effect**: a stream that has bytes ready can encode and enqueue while the writer is mid-flush of an earlier chunk — the slow-socket case no longer serializes encode work across streams. Mutex hold time drops from "until the kernel accepts the write" to "until the bytes are appended to the in-memory queue."
|
|
16
|
+
- **Per-connection backpressure cap** (`MAX_PER_CONN_PENDING_BYTES = 16 MiB`). Pathological clients that read very slowly could otherwise let the queue grow without bound. `WriterContext#enqueue` parks the encoder on `@drained_notify` once `@pending_bytes` exceeds the cap; the writer signals `@drained_notify` after each drain pass.
|
|
17
|
+
- **Coordinated shutdown**: when `Http2Handler#serve` exits (clean close, peer disconnect, or protocol error), the `ensure` block sets `WriterContext#shutdown!` and `writer_task.wait`s for the final drain BEFORE closing the socket. Order matters — closing the socket first would discard final RST_STREAM / GOAWAY / END_STREAM frames sitting in the queue.
|
|
18
|
+
|
|
19
|
+
### HTTP/2 writer architecture (Added)
|
|
20
|
+
- **`Hyperion::Http2Handler::SendQueueIO`** — IO-shaped wrapper passed to `Protocol::HTTP2::Framer` in place of the raw socket. `read` is a passthrough (single-reader on the connection fiber); `write` enqueues onto the connection-wide queue. Reports `closed?` from the underlying socket so framer EOF detection still works.
|
|
21
|
+
- **`Hyperion::Http2Handler::WriterContext`** — holds the per-connection queue, the encode mutex, the send/drained notifications, and the byte-budget counters. One instance per connection; lives for the lifetime of `Http2Handler#serve`.
|
|
22
|
+
- **9 new specs in `spec/hyperion/http2_writer_loop_spec.rb`**:
|
|
23
|
+
- `SendQueueIO#write` returns bytesize, enqueues without writing the socket, no-ops on empty/nil, reports the underlying socket's `closed?` state (4).
|
|
24
|
+
- Writer loop drains a single encoder's frames in enqueue order (1).
|
|
25
|
+
- Two encoder fibers pushing concurrently — bytes for both streams reach the wire and per-stream order (HEADERS → DATA → END) is preserved (1).
|
|
26
|
+
- Backpressure parks the encoder when `@pending_bytes` exceeds `max_pending_bytes`; encoder resumes after the writer drains (1).
|
|
27
|
+
- Shutdown drains all queued frames before the writer fiber exits; shutdown with an empty queue exits cleanly (2).
|
|
28
|
+
- **`bench/h2_streams.sh`** — `h2load`-driven recipe (`-c 1 -m 100 -n 5000`) for measuring per-connection multi-stream rps. Skips with a clear message if `h2load` isn't on PATH; emits a one-line JSON summary so cross-version diffs are easy.
|
|
29
|
+
|
|
30
|
+
### HTTP/2 writer architecture (Migration)
|
|
31
|
+
- No public-API changes. Operators do not need to touch config or restart with new flags. The architectural change is internal to `Http2Handler`.
|
|
32
|
+
|
|
33
|
+
### HTTP/2 writer architecture (Notes)
|
|
34
|
+
- HPACK's dynamic-table state is shared across all streams on a connection (per RFC 7541 §2.3.2.1). That is why we still serialize encode work — two fibers calling `stream.send_headers` concurrently would corrupt the encoder's table state. The mutex is now microseconds-of-CPU rather than "however long the socket takes to drain N MB."
|
|
35
|
+
- `Async::Notification#signal` is a no-op when there are no waiters (signals are not buffered). The writer loop accordingly re-checks `writer_done? && queue_empty?` before parking, so a `shutdown!` call that races a `wait_for_signal` doesn't deadlock.
|
|
36
|
+
|
|
37
|
+
### Performance
|
|
38
|
+
- **`Hyperion::CParser.upcase_underscore(name)` — C-level Rack header-name normalizer.** Replaces the per-uncached-header `"HTTP_#{name.upcase.tr('-', '_')}"` allocation in `Adapter::Rack#build_env`. Single allocation (5 prefix bytes + N source bytes), single byte loop, no Ruby intermediates. Microbench (5 typical X-* names per call): 460k i/s Ruby → 2.21M i/s C, **4.80×** faster (2.17 μs → 452 ns/iter). On a header-heavy hello-world rackup with 8 X-Custom-* request headers + 9 response headers, headline throughput went from ~16.6k r/s to ~18.0k r/s wrk-driven (~+8.5%, averaged across 3 trials). The 16-name `HTTP_KEY_CACHE` still short-circuits the common headers; this only fires on uncached customs.
|
|
39
|
+
- **`Hyperion::CParser.chunked_body_complete?(buffer, body_start)` — chunked-transfer body completion check in C.** Replaces the pure-Ruby walker in `Connection#chunked_body_complete?` with a C-level loop that scans CRLF boundaries, decodes hex sizes, and advances the cursor without per-iteration `String#index` / `byteslice` / `split` allocations. Returns `[complete?, last_safe_offset]` so the caller can persist parse progress across read boundaries (handy for pipelined / streaming buffers, even though Connection currently only consults the boolean). Microbench (3 mixed buffers per iter): 283k i/s Ruby → 3.73M i/s C, **13.19×** faster (3.54 μs → 268 ns/iter). Profit is small in production because chunked uploads are rare, but the path now matches the rest of the parser in cost shape.
|
|
40
|
+
- **`Hyperion::CParser.build_access_line_colored(...)` — TTY-coloured access-log builder in C.** Mirrors `build_access_line` with the green ANSI escape pair `\e[32mINFO \e[0m` baked into the level label. Ten extra bytes per line, single allocation. The pre-1.6.0 `Logger#access` path fell back to the slower Ruby builder whenever `@colorize` was on (i.e. local TTY / dev runs); now the C builder fires there too. Microbench: 1.78M i/s Ruby → 2.90M i/s C, **1.63×** faster (561 ns → 345 ns per line). Smaller win than the others — the Ruby builder was already a single interpolation — but closes the parity gap so dev-loop `tail -f` doesn't pay an avoidable Ruby tax.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- **9 new specs in `spec/hyperion/c_upcase_underscore_spec.rb`** plus a fallback-parity assertion that flips `Hyperion::Adapter::Rack.@c_upcase_available` to walk both the C and Ruby branches in one process. Covers lowercase / uppercase / multi-dash / empty / single-byte / non-ASCII byte-pass-through / digit-preservation / Ruby-equivalence on a panel of canonical custom names / encoding (US-ASCII).
|
|
44
|
+
- **13 new specs in `spec/hyperion/c_chunked_body_complete_spec.rb`** including a fallback-parity assertion against the original Ruby walker. Covers single chunk, multi-chunk, trailers, partial CRLF, partial size token, partial chunk data, chunk extensions, body_start offset, last-safe-cursor reporting on partial buffers, ArgumentError on out-of-range body_start, and a panel of mixed inputs that must agree byte-for-byte with the Ruby walker.
|
|
45
|
+
- **9 new specs in `spec/hyperion/c_access_line_colored_spec.rb`** plus a Logger#access integration test that constructs a TTY-faking IO and asserts the green INFO label appears in the emitted line. Covers text + json formats, query nil/empty/quote-trigger, remote_addr nil, ANSI absence in JSON, and byte-for-byte parity against a hand-rolled Ruby colored builder.
|
|
46
|
+
|
|
47
|
+
## [1.5.0] - 2026-04-27
|
|
48
|
+
|
|
49
|
+
Audit-driven CLI + adapter polish. No breaking changes; pure additions to the operator surface and a hardening of the host-header parser.
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- **CLI flag coverage for 8 Config DSL settings.** Pre-1.5.0 these settings could only be reached by writing a `config/hyperion.rb` file; operators who don't keep one in their repo had no way to flip them without authoring one. They now flow through the same CLI > config-file > default precedence as the rest of the flags:
|
|
53
|
+
- `--max-body-bytes BYTES` (Integer, default 16 MiB)
|
|
54
|
+
- `--max-header-bytes BYTES` (Integer, default 64 KiB)
|
|
55
|
+
- `--max-pending COUNT` (Integer, default unbounded)
|
|
56
|
+
- `--max-request-read-seconds SECONDS` (Float, default 60)
|
|
57
|
+
- `--admin-token TOKEN` (String, default unset) — gates `POST /-/quit` and `GET /-/metrics`
|
|
58
|
+
- `--admin-token-file PATH` — sibling that reads the token from disk; refuses to load if the file is missing, unreadable, world-readable (perms must mask `0o007`), or empty. Production deployments should prefer this over `--admin-token` because argv is visible via `ps`.
|
|
59
|
+
- `--worker-max-rss-mb MB` (Integer, default unset) — RSS-based worker recycling
|
|
60
|
+
- `--idle-keepalive SECONDS` (Float, default 5)
|
|
61
|
+
- `--graceful-timeout SECONDS` (Integer, default 30)
|
|
62
|
+
- **`Hyperion::CLI.parse_argv!` extracted as a public class method** so the flag-to-`cli_opts` mapping is unit-testable without booting a server. `CLI.run` is now a thin wrapper around it.
|
|
63
|
+
- **README CLI flags table** extended with the 8 new flags plus `--[no-]yjit` / `--[no-]async-io` (already wired but previously undocumented in the table).
|
|
64
|
+
- **17 new specs**:
|
|
65
|
+
- 14 in `spec/hyperion/cli_flags_spec.rb` cover per-flag parsing, the `merge_cli!` handoff for all 8 new flags, the CLI-wins precedence rule, and the four `--admin-token-file` abort paths (missing / unreadable / world-readable / empty).
|
|
66
|
+
- 3 in `spec/hyperion/adapter/rack_spec.rb` cover plain IPv4-with-port, bare hostname (no port), and the malformed-bracket regression below.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
- **`Hyperion::Adapter::Rack#split_host` accepted malformed bracketed IPv6.** Pre-1.5.0 a `Host: [::1` header (no closing bracket) was returned as-is in `SERVER_NAME`, leaking attacker-controlled bytes into Rack env where downstream URL generators / SSRF allow-lists / audit logs would trust them. The adapter now fails closed to `localhost:80` and bumps a `:malformed_host_header` counter so operators can alert on attack-pattern volume. No raise — Rack apps don't expect a server adapter to throw on header-parse failures, so we degrade gracefully instead.
|
|
70
|
+
|
|
71
|
+
### Security
|
|
72
|
+
- `--admin-token` help text warns that argv is visible via `ps` and points operators at `--admin-token-file` for production. The token value is never echoed back in any log line.
|
|
73
|
+
|
|
3
74
|
## [1.4.2] - 2026-04-27
|
|
4
75
|
|
|
5
76
|
Audit-driven cleanup. No behaviour changes; fiber-correctness + docs polish.
|
data/README.md
CHANGED
|
@@ -201,6 +201,8 @@ The architectural difference shows up under **load**, not at idle: Puma can only
|
|
|
201
201
|
|
|
202
202
|
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.
|
|
203
203
|
|
|
204
|
+
> **1.6.0 outbound write path** — `Http2Handler` no longer serializes every framer write through one `Mutex#synchronize { socket.write(...) }`. HPACK encoding (microseconds, in-memory) still serializes on a fast encode mutex, but the actual `socket.write` is owned by a dedicated per-connection writer fiber draining a queue. On per-connection multi-stream workloads where the kernel send buffer or peer reads are slow, encode work for ready streams overlaps the writer's flush of earlier chunks, instead of stacking up behind it. See `bench/h2_streams.sh` (`h2load -c 1 -m 100 -n 5000`) for a recipe to compare 1.5.0 vs 1.6.0 on a workload of your choice.
|
|
205
|
+
|
|
204
206
|
### Reproduce
|
|
205
207
|
|
|
206
208
|
```sh
|
|
@@ -255,6 +257,17 @@ Three layers, in precedence order: explicit CLI flag > environment variable > `c
|
|
|
255
257
|
| `--log-format FORMAT` | `auto` | `text` / `json` / `auto`. Auto: JSON when `RAILS_ENV`/`RACK_ENV` is `production`/`staging`, colored text on TTY, JSON otherwise. |
|
|
256
258
|
| `--[no-]log-requests` | ON | Per-request access log. |
|
|
257
259
|
| `--fiber-local-shim` | off | Patches `Thread#thread_variable_*` to fiber storage for older Rails idioms. |
|
|
260
|
+
| `--[no-]yjit` | auto | Force YJIT on/off. Default: auto-on under `RAILS_ENV`/`RACK_ENV` = `production`/`staging`. |
|
|
261
|
+
| `--[no-]async-io` | off | Run plain HTTP/1.1 connections under `Async::Scheduler`. Required for `hyperion-async-pg` on plain HTTP. TLS h1 / HTTP/2 always run under the scheduler regardless. |
|
|
262
|
+
| `--max-body-bytes BYTES` | `16777216` (16 MiB) | Maximum request body size. |
|
|
263
|
+
| `--max-header-bytes BYTES` | `65536` (64 KiB) | Maximum total request-header size. |
|
|
264
|
+
| `--max-pending COUNT` | unbounded | Per-worker accept-queue cap before new connections are rejected with HTTP 503 + `Retry-After: 1`. |
|
|
265
|
+
| `--max-request-read-seconds SECONDS` | `60` | Total wallclock budget for reading request line + headers + body for ONE request. Slowloris defence. |
|
|
266
|
+
| `--admin-token TOKEN` | unset | Bearer token for `POST /-/quit` and `GET /-/metrics`. **Production: prefer `--admin-token-file` — argv is visible via `ps`.** |
|
|
267
|
+
| `--admin-token-file PATH` | unset | Read the admin token from a file. Refuses to load if the file is missing or world-readable (mode must mask `0o007`). |
|
|
268
|
+
| `--worker-max-rss-mb MB` | unset | Master gracefully recycles a worker once its RSS exceeds this many megabytes. nil = disabled. |
|
|
269
|
+
| `--idle-keepalive SECONDS` | `5` | Keep-alive idle timeout. Connection closes after this many seconds of inactivity. |
|
|
270
|
+
| `--graceful-timeout SECONDS` | `30` | Shutdown deadline before SIGKILL is delivered to a worker that hasn't drained. |
|
|
258
271
|
|
|
259
272
|
### Environment variables
|
|
260
273
|
|
data/ext/hyperion_http/parser.c
CHANGED
|
@@ -543,6 +543,322 @@ static VALUE cbuild_access_line(VALUE self,
|
|
|
543
543
|
}
|
|
544
544
|
#undef CAT_LIT
|
|
545
545
|
|
|
546
|
+
/* Hyperion::CParser.build_access_line_colored(format, ts, method, path, query,
|
|
547
|
+
* status, duration_ms, remote_addr,
|
|
548
|
+
* http_version) -> String
|
|
549
|
+
*
|
|
550
|
+
* TTY-coloured variant of build_access_line. The text path wraps the level
|
|
551
|
+
* label with ANSI escape "\e[32mINFO \e[0m" so a developer running Hyperion
|
|
552
|
+
* in a terminal sees a green INFO tag. The :json branch is identical to the
|
|
553
|
+
* non-coloured builder — JSON access lines are machine-readable and never
|
|
554
|
+
* carry ANSI escapes.
|
|
555
|
+
*
|
|
556
|
+
* Lifted from cbuild_access_line above; the only divergence is the level
|
|
557
|
+
* label injection in the text branch. We deliberately duplicate the text
|
|
558
|
+
* format rather than templating, because the text body is short and a
|
|
559
|
+
* single function with a colour flag would compile to the same code with an
|
|
560
|
+
* extra branch in the hot loop.
|
|
561
|
+
*/
|
|
562
|
+
static VALUE cbuild_access_line_colored(VALUE self,
|
|
563
|
+
VALUE format_sym, VALUE rb_ts,
|
|
564
|
+
VALUE rb_method, VALUE rb_path,
|
|
565
|
+
VALUE rb_query, VALUE rb_status,
|
|
566
|
+
VALUE rb_duration, VALUE rb_remote,
|
|
567
|
+
VALUE rb_http_version) {
|
|
568
|
+
(void)self;
|
|
569
|
+
Check_Type(rb_ts, T_STRING);
|
|
570
|
+
Check_Type(rb_method, T_STRING);
|
|
571
|
+
Check_Type(rb_path, T_STRING);
|
|
572
|
+
Check_Type(rb_http_version, T_STRING);
|
|
573
|
+
|
|
574
|
+
int is_json = (TYPE(format_sym) == T_SYMBOL) &&
|
|
575
|
+
(SYM2ID(format_sym) == rb_intern("json"));
|
|
576
|
+
|
|
577
|
+
int status = NUM2INT(rb_status);
|
|
578
|
+
double dur_ms = NUM2DBL(rb_duration);
|
|
579
|
+
|
|
580
|
+
int has_query = !NIL_P(rb_query) && RSTRING_LEN(rb_query) > 0;
|
|
581
|
+
int has_remote = !NIL_P(rb_remote) && RSTRING_LEN(rb_remote) > 0;
|
|
582
|
+
|
|
583
|
+
#define CAT_LIT(b, s) rb_str_cat((b), (s), (long)(sizeof(s) - 1))
|
|
584
|
+
|
|
585
|
+
VALUE buf = rb_str_buf_new(512);
|
|
586
|
+
|
|
587
|
+
if (is_json) {
|
|
588
|
+
/* JSON output is identical to the non-coloured path — ANSI escapes
|
|
589
|
+
* have no place in a structured log record. */
|
|
590
|
+
CAT_LIT(buf, "{\"ts\":\"");
|
|
591
|
+
rb_str_cat(buf, RSTRING_PTR(rb_ts), RSTRING_LEN(rb_ts));
|
|
592
|
+
CAT_LIT(buf, "\",\"level\":\"info\",\"source\":\"hyperion\",\"message\":\"request\",");
|
|
593
|
+
CAT_LIT(buf, "\"method\":\"");
|
|
594
|
+
rb_str_cat(buf, RSTRING_PTR(rb_method), RSTRING_LEN(rb_method));
|
|
595
|
+
CAT_LIT(buf, "\",\"path\":\"");
|
|
596
|
+
rb_str_cat(buf, RSTRING_PTR(rb_path), RSTRING_LEN(rb_path));
|
|
597
|
+
CAT_LIT(buf, "\"");
|
|
598
|
+
|
|
599
|
+
if (has_query) {
|
|
600
|
+
CAT_LIT(buf, ",\"query\":\"");
|
|
601
|
+
rb_str_cat(buf, RSTRING_PTR(rb_query), RSTRING_LEN(rb_query));
|
|
602
|
+
CAT_LIT(buf, "\"");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
char num[64];
|
|
606
|
+
int n = snprintf(num, sizeof(num), ",\"status\":%d,\"duration_ms\":%g,",
|
|
607
|
+
status, dur_ms);
|
|
608
|
+
rb_str_cat(buf, num, n);
|
|
609
|
+
|
|
610
|
+
if (has_remote) {
|
|
611
|
+
CAT_LIT(buf, "\"remote_addr\":\"");
|
|
612
|
+
rb_str_cat(buf, RSTRING_PTR(rb_remote), RSTRING_LEN(rb_remote));
|
|
613
|
+
CAT_LIT(buf, "\",");
|
|
614
|
+
} else {
|
|
615
|
+
CAT_LIT(buf, "\"remote_addr\":null,");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
CAT_LIT(buf, "\"http_version\":\"");
|
|
619
|
+
rb_str_cat(buf, RSTRING_PTR(rb_http_version), RSTRING_LEN(rb_http_version));
|
|
620
|
+
CAT_LIT(buf, "\"}\n");
|
|
621
|
+
} else {
|
|
622
|
+
/* text: "<ts> \e[32mINFO \e[0m [hyperion] message=request method=..." */
|
|
623
|
+
rb_str_cat(buf, RSTRING_PTR(rb_ts), RSTRING_LEN(rb_ts));
|
|
624
|
+
CAT_LIT(buf, " \x1b[32mINFO \x1b[0m [hyperion] message=request method=");
|
|
625
|
+
rb_str_cat(buf, RSTRING_PTR(rb_method), RSTRING_LEN(rb_method));
|
|
626
|
+
CAT_LIT(buf, " path=");
|
|
627
|
+
rb_str_cat(buf, RSTRING_PTR(rb_path), RSTRING_LEN(rb_path));
|
|
628
|
+
|
|
629
|
+
if (has_query) {
|
|
630
|
+
const char *q_ptr = RSTRING_PTR(rb_query);
|
|
631
|
+
long q_len = RSTRING_LEN(rb_query);
|
|
632
|
+
int need_quote = 0;
|
|
633
|
+
for (long j = 0; j < q_len; j++) {
|
|
634
|
+
char c = q_ptr[j];
|
|
635
|
+
if (c == ' ' || c == '\t' || c == '\n' || c == '\r' ||
|
|
636
|
+
c == '"' || c == '=') {
|
|
637
|
+
need_quote = 1;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (need_quote) {
|
|
642
|
+
VALUE quoted = rb_funcall(rb_query, rb_intern("inspect"), 0);
|
|
643
|
+
CAT_LIT(buf, " query=");
|
|
644
|
+
rb_str_cat(buf, RSTRING_PTR(quoted), RSTRING_LEN(quoted));
|
|
645
|
+
} else {
|
|
646
|
+
CAT_LIT(buf, " query=");
|
|
647
|
+
rb_str_cat(buf, q_ptr, q_len);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
char num[80];
|
|
652
|
+
int n = snprintf(num, sizeof(num), " status=%d duration_ms=%g remote_addr=",
|
|
653
|
+
status, dur_ms);
|
|
654
|
+
rb_str_cat(buf, num, n);
|
|
655
|
+
|
|
656
|
+
if (has_remote) {
|
|
657
|
+
rb_str_cat(buf, RSTRING_PTR(rb_remote), RSTRING_LEN(rb_remote));
|
|
658
|
+
} else {
|
|
659
|
+
CAT_LIT(buf, "nil");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
CAT_LIT(buf, " http_version=");
|
|
663
|
+
rb_str_cat(buf, RSTRING_PTR(rb_http_version), RSTRING_LEN(rb_http_version));
|
|
664
|
+
CAT_LIT(buf, "\n");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return buf;
|
|
668
|
+
}
|
|
669
|
+
#undef CAT_LIT
|
|
670
|
+
|
|
671
|
+
/* Hyperion::CParser.upcase_underscore(name) -> "HTTP_<UPCASED_UNDERSCORED>"
|
|
672
|
+
*
|
|
673
|
+
* Single-allocation replacement for `"HTTP_#{name.upcase.tr('-', '_')}"`.
|
|
674
|
+
* Hot path on the Rack adapter: every uncached request header (any
|
|
675
|
+
* `X-*` custom header) hits this on every request, and the Ruby version
|
|
676
|
+
* spawns three String allocations (the upcase result, the tr result, and the
|
|
677
|
+
* "HTTP_..." interpolation) plus a per-byte loop in tr.
|
|
678
|
+
*
|
|
679
|
+
* We allocate one Ruby String of length 5 + name.bytesize, fill it in a
|
|
680
|
+
* single byte loop, return it. ASCII letters get OR'd with 0x20 inverted
|
|
681
|
+
* (i.e. cleared bit 5 to upcase 'a'..'z'); '-' becomes '_'; everything else
|
|
682
|
+
* passes through (header names are ASCII per RFC 9110, but multi-byte UTF-8
|
|
683
|
+
* bytes pass through bytewise unmolested rather than crashing).
|
|
684
|
+
*
|
|
685
|
+
* Encoding is set to US-ASCII because Ruby's String#upcase on an ASCII-only
|
|
686
|
+
* input returns a US-ASCII string, and the env-key lookup downstream is
|
|
687
|
+
* encoding-agnostic anyway.
|
|
688
|
+
*/
|
|
689
|
+
static VALUE cupcase_underscore(VALUE self, VALUE rb_name) {
|
|
690
|
+
(void)self;
|
|
691
|
+
Check_Type(rb_name, T_STRING);
|
|
692
|
+
|
|
693
|
+
const char *src = RSTRING_PTR(rb_name);
|
|
694
|
+
long src_len = RSTRING_LEN(rb_name);
|
|
695
|
+
|
|
696
|
+
/* Single allocation: 5 prefix bytes + N source bytes. */
|
|
697
|
+
VALUE out = rb_str_new(NULL, 5 + src_len);
|
|
698
|
+
char *dst = RSTRING_PTR(out);
|
|
699
|
+
|
|
700
|
+
dst[0] = 'H';
|
|
701
|
+
dst[1] = 'T';
|
|
702
|
+
dst[2] = 'T';
|
|
703
|
+
dst[3] = 'P';
|
|
704
|
+
dst[4] = '_';
|
|
705
|
+
|
|
706
|
+
for (long i = 0; i < src_len; i++) {
|
|
707
|
+
unsigned char c = (unsigned char)src[i];
|
|
708
|
+
if (c >= 'a' && c <= 'z') {
|
|
709
|
+
dst[5 + i] = (char)(c - 32);
|
|
710
|
+
} else if (c == '-') {
|
|
711
|
+
dst[5 + i] = '_';
|
|
712
|
+
} else {
|
|
713
|
+
dst[5 + i] = (char)c;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
rb_enc_associate(out, rb_usascii_encoding());
|
|
718
|
+
/* Keep rb_name live across the loop above. RSTRING_PTR returns an
|
|
719
|
+
* interior pointer that becomes invalid if the GC moves the source
|
|
720
|
+
* String — unlikely on this tight path, but cheap insurance. */
|
|
721
|
+
RB_GC_GUARD(rb_name);
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/* Hyperion::CParser.chunked_body_complete?(buffer, body_start)
|
|
726
|
+
* -> [complete?, end_offset]
|
|
727
|
+
*
|
|
728
|
+
* Walks chunked-transfer framing in `buffer` starting at byte offset
|
|
729
|
+
* `body_start`. Returns a 2-element array:
|
|
730
|
+
* [true, end_offset] — chunked body fully buffered; end_offset is the
|
|
731
|
+
* byte just after the trailer CRLF (where pipelined
|
|
732
|
+
* bytes from a follow-on request would begin).
|
|
733
|
+
* [false, last_safe] — body is not yet complete; last_safe is the
|
|
734
|
+
* furthest cursor we successfully advanced to,
|
|
735
|
+
* useful as a hint for incremental parsing.
|
|
736
|
+
*
|
|
737
|
+
* Mirrors Connection#chunked_body_complete? in pure Ruby — see lib/hyperion/
|
|
738
|
+
* connection.rb. Trailing whitespace after the size token (e.g. "5 ; ext\r\n")
|
|
739
|
+
* is permitted as a permissive parse to match the upstream Ruby `.strip`.
|
|
740
|
+
*/
|
|
741
|
+
static VALUE cchunked_body_complete(VALUE self, VALUE rb_buffer, VALUE rb_body_start) {
|
|
742
|
+
(void)self;
|
|
743
|
+
Check_Type(rb_buffer, T_STRING);
|
|
744
|
+
|
|
745
|
+
const char *data = RSTRING_PTR(rb_buffer);
|
|
746
|
+
long len = RSTRING_LEN(rb_buffer);
|
|
747
|
+
long cursor = NUM2LONG(rb_body_start);
|
|
748
|
+
|
|
749
|
+
if (cursor < 0 || cursor > len) {
|
|
750
|
+
rb_raise(rb_eArgError, "body_start out of range");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
long last_safe = cursor;
|
|
754
|
+
VALUE result = rb_ary_new_capa(2);
|
|
755
|
+
|
|
756
|
+
while (1) {
|
|
757
|
+
/* Find the next CRLF starting at cursor. */
|
|
758
|
+
long line_end = -1;
|
|
759
|
+
for (long i = cursor; i + 1 < len; i++) {
|
|
760
|
+
if (data[i] == '\r' && data[i + 1] == '\n') {
|
|
761
|
+
line_end = i;
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (line_end < 0) {
|
|
766
|
+
rb_ary_push(result, Qfalse);
|
|
767
|
+
rb_ary_push(result, LONG2NUM(last_safe));
|
|
768
|
+
RB_GC_GUARD(rb_buffer);
|
|
769
|
+
return result;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/* Parse the size token: hex digits up to ';' or whitespace, optional
|
|
773
|
+
* chunk extension after ';' which we ignore wholesale. */
|
|
774
|
+
long tok_start = cursor;
|
|
775
|
+
long tok_end = line_end;
|
|
776
|
+
for (long i = cursor; i < line_end; i++) {
|
|
777
|
+
if (data[i] == ';') { tok_end = i; break; }
|
|
778
|
+
}
|
|
779
|
+
/* Trim leading/trailing ASCII whitespace from the token. */
|
|
780
|
+
while (tok_start < tok_end &&
|
|
781
|
+
(data[tok_start] == ' ' || data[tok_start] == '\t')) {
|
|
782
|
+
tok_start++;
|
|
783
|
+
}
|
|
784
|
+
while (tok_end > tok_start &&
|
|
785
|
+
(data[tok_end - 1] == ' ' || data[tok_end - 1] == '\t')) {
|
|
786
|
+
tok_end--;
|
|
787
|
+
}
|
|
788
|
+
if (tok_end <= tok_start) {
|
|
789
|
+
/* Empty size token — incomplete frame. */
|
|
790
|
+
rb_ary_push(result, Qfalse);
|
|
791
|
+
rb_ary_push(result, LONG2NUM(last_safe));
|
|
792
|
+
RB_GC_GUARD(rb_buffer);
|
|
793
|
+
return result;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/* Validate + decode hex. */
|
|
797
|
+
unsigned long size = 0;
|
|
798
|
+
for (long i = tok_start; i < tok_end; i++) {
|
|
799
|
+
unsigned char c = (unsigned char)data[i];
|
|
800
|
+
unsigned int digit;
|
|
801
|
+
if (c >= '0' && c <= '9') {
|
|
802
|
+
digit = c - '0';
|
|
803
|
+
} else if (c >= 'a' && c <= 'f') {
|
|
804
|
+
digit = 10 + (c - 'a');
|
|
805
|
+
} else if (c >= 'A' && c <= 'F') {
|
|
806
|
+
digit = 10 + (c - 'A');
|
|
807
|
+
} else {
|
|
808
|
+
/* Non-hex byte: incomplete/malformed. Match the Ruby
|
|
809
|
+
* regex `/\A\h+\z/` semantics — return false, advance no
|
|
810
|
+
* further. The caller will read more bytes and retry. */
|
|
811
|
+
rb_ary_push(result, Qfalse);
|
|
812
|
+
rb_ary_push(result, LONG2NUM(last_safe));
|
|
813
|
+
RB_GC_GUARD(rb_buffer);
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
816
|
+
size = (size << 4) | digit;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
cursor = line_end + 2;
|
|
820
|
+
|
|
821
|
+
if (size == 0) {
|
|
822
|
+
/* Final chunk — walk trailer headers until we hit "\r\n\r\n"
|
|
823
|
+
* (i.e. an empty trailer line directly after the size line). */
|
|
824
|
+
while (1) {
|
|
825
|
+
long nl = -1;
|
|
826
|
+
for (long i = cursor; i + 1 < len; i++) {
|
|
827
|
+
if (data[i] == '\r' && data[i + 1] == '\n') {
|
|
828
|
+
nl = i;
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (nl < 0) {
|
|
833
|
+
rb_ary_push(result, Qfalse);
|
|
834
|
+
rb_ary_push(result, LONG2NUM(last_safe));
|
|
835
|
+
RB_GC_GUARD(rb_buffer);
|
|
836
|
+
return result;
|
|
837
|
+
}
|
|
838
|
+
if (nl == cursor) {
|
|
839
|
+
/* Empty line — body complete. */
|
|
840
|
+
rb_ary_push(result, Qtrue);
|
|
841
|
+
rb_ary_push(result, LONG2NUM(nl + 2));
|
|
842
|
+
RB_GC_GUARD(rb_buffer);
|
|
843
|
+
return result;
|
|
844
|
+
}
|
|
845
|
+
cursor = nl + 2;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/* Need cursor + size + 2 bytes (chunk data + trailing CRLF). */
|
|
850
|
+
if ((unsigned long)(len - cursor) < size + 2) {
|
|
851
|
+
rb_ary_push(result, Qfalse);
|
|
852
|
+
rb_ary_push(result, LONG2NUM(last_safe));
|
|
853
|
+
RB_GC_GUARD(rb_buffer);
|
|
854
|
+
return result;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
cursor += (long)size + 2;
|
|
858
|
+
last_safe = cursor;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
546
862
|
void Init_hyperion_http(void) {
|
|
547
863
|
install_settings();
|
|
548
864
|
|
|
@@ -557,6 +873,12 @@ void Init_hyperion_http(void) {
|
|
|
557
873
|
cbuild_response_head, 6);
|
|
558
874
|
rb_define_singleton_method(rb_cCParser, "build_access_line",
|
|
559
875
|
cbuild_access_line, 9);
|
|
876
|
+
rb_define_singleton_method(rb_cCParser, "build_access_line_colored",
|
|
877
|
+
cbuild_access_line_colored, 9);
|
|
878
|
+
rb_define_singleton_method(rb_cCParser, "upcase_underscore",
|
|
879
|
+
cupcase_underscore, 1);
|
|
880
|
+
rb_define_singleton_method(rb_cCParser, "chunked_body_complete?",
|
|
881
|
+
cchunked_body_complete, 2);
|
|
560
882
|
|
|
561
883
|
id_new = rb_intern("new");
|
|
562
884
|
id_downcase = rb_intern("downcase");
|
|
@@ -48,6 +48,17 @@ module Hyperion
|
|
|
48
48
|
}
|
|
49
49
|
)
|
|
50
50
|
|
|
51
|
+
# Whether Hyperion::CParser.upcase_underscore is available. Probed lazily
|
|
52
|
+
# at first use (CParser is required after this file, so an eager check
|
|
53
|
+
# at load time would always be false). Memoised in a class-level ivar to
|
|
54
|
+
# keep the hot path branchless.
|
|
55
|
+
def self.c_upcase_available?
|
|
56
|
+
return @c_upcase_available unless @c_upcase_available.nil?
|
|
57
|
+
|
|
58
|
+
@c_upcase_available = defined?(::Hyperion::CParser) &&
|
|
59
|
+
::Hyperion::CParser.respond_to?(:upcase_underscore)
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
class << self
|
|
52
63
|
# Pre-allocate `n` env-hash and rack-input objects in master before
|
|
53
64
|
# fork. Children inherit the populated free-list via copy-on-write —
|
|
@@ -122,8 +133,14 @@ module Hyperion
|
|
|
122
133
|
env['rack.run_once'] = false
|
|
123
134
|
env['SCRIPT_NAME'] = ''
|
|
124
135
|
|
|
136
|
+
# Header-name → Rack env-key conversion. Cache covers the 16 most
|
|
137
|
+
# common names; uncached headers (X-* customs, vendor-specific) flow
|
|
138
|
+
# through CParser.upcase_underscore (single C-level allocation) when
|
|
139
|
+
# the extension is built, else the pure-Ruby triple-allocation path.
|
|
140
|
+
c_upcase = Rack.c_upcase_available?
|
|
125
141
|
request.headers.each do |name, value|
|
|
126
|
-
key = HTTP_KEY_CACHE[name] ||
|
|
142
|
+
key = HTTP_KEY_CACHE[name] ||
|
|
143
|
+
(c_upcase ? ::Hyperion::CParser.upcase_underscore(name) : "HTTP_#{name.upcase.tr('-', '_')}")
|
|
127
144
|
env[key] = value
|
|
128
145
|
end
|
|
129
146
|
|
|
@@ -138,7 +155,17 @@ module Hyperion
|
|
|
138
155
|
|
|
139
156
|
if host_header.start_with?('[')
|
|
140
157
|
close = host_header.index(']')
|
|
141
|
-
|
|
158
|
+
# Malformed bracketed IPv6 (no closing bracket): we used to return
|
|
159
|
+
# the raw garbage as SERVER_NAME, which then leaked into Rack env
|
|
160
|
+
# where downstream URL generators / loggers / SSRF allow-lists
|
|
161
|
+
# would trust attacker-controlled bytes. Fail closed to a safe
|
|
162
|
+
# default and bump a counter so operators can alert on volume.
|
|
163
|
+
# No raise — Rack apps don't expect Hyperion's adapter to throw
|
|
164
|
+
# on header-parse failures, so we degrade gracefully instead.
|
|
165
|
+
unless close
|
|
166
|
+
Hyperion.metrics.increment(:malformed_host_header)
|
|
167
|
+
return %w[localhost 80]
|
|
168
|
+
end
|
|
142
169
|
|
|
143
170
|
name = host_header[0..close]
|
|
144
171
|
rest = host_header[(close + 1)..]
|
data/lib/hyperion/cli.rb
CHANGED
|
@@ -11,6 +11,55 @@ module Hyperion
|
|
|
11
11
|
DEFAULT_CONFIG_PATH = 'config/hyperion.rb'
|
|
12
12
|
|
|
13
13
|
def self.run(argv)
|
|
14
|
+
cli_opts, config_path = parse_argv!(argv)
|
|
15
|
+
|
|
16
|
+
# Precedence: CLI > config file > built-in default. We auto-load
|
|
17
|
+
# config/hyperion.rb if present so operators can drop a file in their
|
|
18
|
+
# repo and have it take effect without having to remember -C.
|
|
19
|
+
config_path ||= DEFAULT_CONFIG_PATH if File.exist?(DEFAULT_CONFIG_PATH)
|
|
20
|
+
config = config_path ? Hyperion::Config.load(config_path) : Hyperion::Config.new
|
|
21
|
+
config.merge_cli!(cli_opts)
|
|
22
|
+
|
|
23
|
+
# Install logger early so every subsequent log call honours the operator's
|
|
24
|
+
# chosen format/level (config file or CLI) before anything else logs.
|
|
25
|
+
if config.log_level || config.log_format
|
|
26
|
+
Hyperion.logger = Hyperion::Logger.new(level: config.log_level, format: config.log_format)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Propagate log_requests so every Connection picks it up via
|
|
30
|
+
# `Hyperion.log_requests?` without needing to thread it through
|
|
31
|
+
# Server/ThreadPool/Master plumbing. Default is ON; nil means "don't
|
|
32
|
+
# touch — fall through to the env/default chain in Hyperion.log_requests?".
|
|
33
|
+
Hyperion.log_requests = config.log_requests unless config.log_requests.nil?
|
|
34
|
+
|
|
35
|
+
# Enable YJIT before workers fork / connections start. Auto-on in
|
|
36
|
+
# production/staging gives operators the perf bump for free; explicit
|
|
37
|
+
# config.yjit (true/false) overrides the env-based default.
|
|
38
|
+
maybe_enable_yjit(config)
|
|
39
|
+
|
|
40
|
+
rackup = argv.first || 'config.ru'
|
|
41
|
+
abort("[hyperion] no such rackup file: #{rackup}") unless File.exist?(rackup)
|
|
42
|
+
|
|
43
|
+
if config.fiber_local_shim
|
|
44
|
+
Hyperion::FiberLocal.install!
|
|
45
|
+
Hyperion.logger.info { { message: 'FiberLocal shim installed' } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
app = load_rack_app(rackup)
|
|
49
|
+
app = wrap_admin_middleware(app, config)
|
|
50
|
+
workers = config.workers.zero? ? Etc.nprocessors : config.workers
|
|
51
|
+
|
|
52
|
+
if workers <= 1
|
|
53
|
+
run_single(config, app)
|
|
54
|
+
else
|
|
55
|
+
run_cluster(config, app, workers)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Extracted from #run so the flag-to-cli_opts mapping can be unit-tested
|
|
60
|
+
# without booting a server. Returns [cli_opts, config_path]. Mutates argv
|
|
61
|
+
# in place (consumes flags, leaves the rackup path for the caller).
|
|
62
|
+
def self.parse_argv!(argv)
|
|
14
63
|
cli_opts = {}
|
|
15
64
|
config_path = nil
|
|
16
65
|
|
|
@@ -61,6 +110,46 @@ module Hyperion
|
|
|
61
110
|
'Run plain HTTP/1.1 connections under Async::Scheduler (required for hyperion-async-pg and other fiber-cooperative I/O; default off)') do |v|
|
|
62
111
|
cli_opts[:async_io] = v
|
|
63
112
|
end
|
|
113
|
+
o.on('--max-body-bytes BYTES', Integer,
|
|
114
|
+
'Maximum request body size in bytes (default 16777216 = 16 MiB)') do |n|
|
|
115
|
+
cli_opts[:max_body_bytes] = n
|
|
116
|
+
end
|
|
117
|
+
o.on('--max-header-bytes BYTES', Integer,
|
|
118
|
+
'Maximum total request-header size in bytes (default 65536 = 64 KiB)') do |n|
|
|
119
|
+
cli_opts[:max_header_bytes] = n
|
|
120
|
+
end
|
|
121
|
+
o.on('--max-pending COUNT', Integer,
|
|
122
|
+
'Maximum queued connections per worker before new accepts are rejected with 503 (default unbounded)') do |n|
|
|
123
|
+
cli_opts[:max_pending] = n
|
|
124
|
+
end
|
|
125
|
+
o.on('--max-request-read-seconds SECONDS', Float,
|
|
126
|
+
'Total wallclock budget for reading request line + headers + body (default 60.0; 0 disables)') do |n|
|
|
127
|
+
cli_opts[:max_request_read_seconds] = n
|
|
128
|
+
end
|
|
129
|
+
# Security-sensitive: read the token verbatim and never echo it back
|
|
130
|
+
# in any subsequent log/help line. argv is visible via `ps` on most
|
|
131
|
+
# systems; production deployments should prefer --admin-token-file.
|
|
132
|
+
o.on('--admin-token TOKEN',
|
|
133
|
+
"Bearer token for the /-/quit and /-/metrics admin endpoints. \
|
|
134
|
+
WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production.") do |t|
|
|
135
|
+
cli_opts[:admin_token] = t
|
|
136
|
+
end
|
|
137
|
+
o.on('--admin-token-file PATH',
|
|
138
|
+
'Read the admin token from a file. File must NOT be world-readable (perms must mask 0o007).') do |p|
|
|
139
|
+
cli_opts[:admin_token] = read_admin_token_file(p)
|
|
140
|
+
end
|
|
141
|
+
o.on('--worker-max-rss-mb MB', Integer,
|
|
142
|
+
'Recycle a worker when its RSS exceeds MB megabytes (default unset; nil disables)') do |n|
|
|
143
|
+
cli_opts[:worker_max_rss_mb] = n
|
|
144
|
+
end
|
|
145
|
+
o.on('--idle-keepalive SECONDS', Float,
|
|
146
|
+
'Idle keep-alive timeout in seconds (default 5.0)') do |n|
|
|
147
|
+
cli_opts[:idle_keepalive] = n
|
|
148
|
+
end
|
|
149
|
+
o.on('--graceful-timeout SECONDS', Integer,
|
|
150
|
+
'Graceful shutdown deadline in seconds before SIGKILL (default 30)') do |n|
|
|
151
|
+
cli_opts[:graceful_timeout] = n
|
|
152
|
+
end
|
|
64
153
|
o.on('-h', '--help', 'show help') do
|
|
65
154
|
puts o
|
|
66
155
|
exit 0
|
|
@@ -68,47 +157,7 @@ module Hyperion
|
|
|
68
157
|
end
|
|
69
158
|
parser.parse!(argv)
|
|
70
159
|
|
|
71
|
-
|
|
72
|
-
# config/hyperion.rb if present so operators can drop a file in their
|
|
73
|
-
# repo and have it take effect without having to remember -C.
|
|
74
|
-
config_path ||= DEFAULT_CONFIG_PATH if File.exist?(DEFAULT_CONFIG_PATH)
|
|
75
|
-
config = config_path ? Hyperion::Config.load(config_path) : Hyperion::Config.new
|
|
76
|
-
config.merge_cli!(cli_opts)
|
|
77
|
-
|
|
78
|
-
# Install logger early so every subsequent log call honours the operator's
|
|
79
|
-
# chosen format/level (config file or CLI) before anything else logs.
|
|
80
|
-
if config.log_level || config.log_format
|
|
81
|
-
Hyperion.logger = Hyperion::Logger.new(level: config.log_level, format: config.log_format)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Propagate log_requests so every Connection picks it up via
|
|
85
|
-
# `Hyperion.log_requests?` without needing to thread it through
|
|
86
|
-
# Server/ThreadPool/Master plumbing. Default is ON; nil means "don't
|
|
87
|
-
# touch — fall through to the env/default chain in Hyperion.log_requests?".
|
|
88
|
-
Hyperion.log_requests = config.log_requests unless config.log_requests.nil?
|
|
89
|
-
|
|
90
|
-
# Enable YJIT before workers fork / connections start. Auto-on in
|
|
91
|
-
# production/staging gives operators the perf bump for free; explicit
|
|
92
|
-
# config.yjit (true/false) overrides the env-based default.
|
|
93
|
-
maybe_enable_yjit(config)
|
|
94
|
-
|
|
95
|
-
rackup = argv.first || 'config.ru'
|
|
96
|
-
abort("[hyperion] no such rackup file: #{rackup}") unless File.exist?(rackup)
|
|
97
|
-
|
|
98
|
-
if config.fiber_local_shim
|
|
99
|
-
Hyperion::FiberLocal.install!
|
|
100
|
-
Hyperion.logger.info { { message: 'FiberLocal shim installed' } }
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
app = load_rack_app(rackup)
|
|
104
|
-
app = wrap_admin_middleware(app, config)
|
|
105
|
-
workers = config.workers.zero? ? Etc.nprocessors : config.workers
|
|
106
|
-
|
|
107
|
-
if workers <= 1
|
|
108
|
-
run_single(config, app)
|
|
109
|
-
else
|
|
110
|
-
run_cluster(config, app, workers)
|
|
111
|
-
end
|
|
160
|
+
[cli_opts, config_path]
|
|
112
161
|
end
|
|
113
162
|
|
|
114
163
|
def self.run_single(config, app)
|
|
@@ -227,6 +276,28 @@ module Hyperion
|
|
|
227
276
|
end
|
|
228
277
|
private_class_method :wrap_admin_middleware
|
|
229
278
|
|
|
279
|
+
# Read the admin token from a file on disk. Refuses to load if the file
|
|
280
|
+
# is missing, unreadable, or world-readable — the whole point of using a
|
|
281
|
+
# file instead of `--admin-token` is to keep the token off argv (which
|
|
282
|
+
# `ps` exposes) and off other-user-readable storage. Trailing whitespace
|
|
283
|
+
# is stripped so operators can use `echo "$TOKEN" > /etc/hyperion-token`
|
|
284
|
+
# without inadvertently embedding a newline. Empty files abort.
|
|
285
|
+
def self.read_admin_token_file(path)
|
|
286
|
+
abort("[hyperion] admin token file not found: #{path}") unless File.file?(path)
|
|
287
|
+
abort("[hyperion] admin token file not readable: #{path}") unless File.readable?(path)
|
|
288
|
+
|
|
289
|
+
mode = File.stat(path).mode & 0o777
|
|
290
|
+
if (mode & 0o007).positive?
|
|
291
|
+
abort("[hyperion] admin token file #{path} is world-readable (mode #{format('%04o', mode)}); chmod 600")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
token = File.read(path).strip
|
|
295
|
+
abort("[hyperion] admin token file is empty: #{path}") if token.empty?
|
|
296
|
+
|
|
297
|
+
token
|
|
298
|
+
end
|
|
299
|
+
private_class_method :read_admin_token_file
|
|
300
|
+
|
|
230
301
|
# Warn loudly at boot if the C parser didn't load — operators running
|
|
231
302
|
# production with the pure-Ruby fallback are paying ~2× CPU on parse-heavy
|
|
232
303
|
# workloads and probably don't know it.
|
data/lib/hyperion/connection.rb
CHANGED
|
@@ -287,9 +287,29 @@ module Hyperion
|
|
|
287
287
|
|
|
288
288
|
# Walks chunked framing in `buffer` starting at `body_start` and
|
|
289
289
|
# returns true once the final 0-sized chunk (and trailer terminator)
|
|
290
|
-
# is fully buffered.
|
|
291
|
-
#
|
|
290
|
+
# is fully buffered. The C extension folds the size-line scan + hex
|
|
291
|
+
# decode + chunk advance into a single tight loop with no per-iteration
|
|
292
|
+
# Ruby allocation; the pure-Ruby fallback below preserves the original
|
|
293
|
+
# semantics for environments where the C extension didn't build.
|
|
292
294
|
def chunked_body_complete?(buffer, body_start)
|
|
295
|
+
if self.class.c_chunked_available?
|
|
296
|
+
::Hyperion::CParser.chunked_body_complete?(buffer, body_start).first
|
|
297
|
+
else
|
|
298
|
+
chunked_body_complete_ruby?(buffer, body_start)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Whether Hyperion::CParser.chunked_body_complete? is available. Probed
|
|
303
|
+
# lazily at first use; memoised in a class-level ivar to keep the
|
|
304
|
+
# per-request hot path branchless.
|
|
305
|
+
def self.c_chunked_available?
|
|
306
|
+
return @c_chunked_available unless @c_chunked_available.nil?
|
|
307
|
+
|
|
308
|
+
@c_chunked_available = defined?(::Hyperion::CParser) &&
|
|
309
|
+
::Hyperion::CParser.respond_to?(:chunked_body_complete?)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def chunked_body_complete_ruby?(buffer, body_start)
|
|
293
313
|
cursor = body_start
|
|
294
314
|
loop do
|
|
295
315
|
line_end = buffer.index("\r\n", cursor)
|
|
@@ -18,11 +18,40 @@ module Hyperion
|
|
|
18
18
|
# dispatch — slow handlers no longer block other streams on the same
|
|
19
19
|
# connection.
|
|
20
20
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
21
|
+
# ## Outbound write architecture (1.6.0+)
|
|
22
|
+
#
|
|
23
|
+
# Pre-1.6.0 every framer write (HEADERS / DATA / RST_STREAM / GOAWAY) ran
|
|
24
|
+
# under one connection-scoped `Mutex#synchronize { socket.write(...) }`.
|
|
25
|
+
# That capped per-connection h2 throughput to "one socket-write at a time"
|
|
26
|
+
# regardless of stream count: a slow socket (kernel send buffer full,
|
|
27
|
+
# remote peer reading slowly) blocked every other stream's writes too.
|
|
28
|
+
#
|
|
29
|
+
# 1.6.0 splits the path:
|
|
30
|
+
# * The HPACK encode + frame format step is fast (microseconds, in-memory)
|
|
31
|
+
# and remains serialized on the calling fiber via `@encode_mutex`. HPACK
|
|
32
|
+
# state is stateful across HEADERS frames per connection, and frames for
|
|
33
|
+
# a single stream must be wire-ordered (HEADERS → DATA → END_STREAM).
|
|
34
|
+
# Holding the encode mutex across a `send_*` call accomplishes both.
|
|
35
|
+
# * The framer writes through a `SendQueueIO` wrapper (wraps the real
|
|
36
|
+
# socket). `SendQueueIO#write(bytes)` enqueues onto a connection-wide
|
|
37
|
+
# `@send_queue` and signals `@send_notify`; it never touches the real
|
|
38
|
+
# socket.
|
|
39
|
+
# * A dedicated **writer fiber** owns the real socket. It pops byte chunks
|
|
40
|
+
# off the queue, writes them, and parks on `@send_notify` when empty.
|
|
41
|
+
# Only this fiber ever calls `socket.write` — the SSLSocket cross-fiber
|
|
42
|
+
# unsafety constraint is satisfied.
|
|
43
|
+
#
|
|
44
|
+
# Net effect: the slow-socket case no longer serializes encode work across
|
|
45
|
+
# streams. A stream that has bytes ready to encode can encode and enqueue
|
|
46
|
+
# while the writer is mid-flush of an earlier chunk. The mutex hold time
|
|
47
|
+
# drops from "until the kernel accepts the write" to "until the bytes are
|
|
48
|
+
# appended to the in-memory queue."
|
|
49
|
+
#
|
|
50
|
+
# Backpressure: pathological clients (slow-read h2) could otherwise let the
|
|
51
|
+
# queue grow without bound. We track `@pending_bytes`; once it exceeds
|
|
52
|
+
# `MAX_PER_CONN_PENDING_BYTES`, encoding fibers wait on `@drained_notify`
|
|
53
|
+
# before enqueueing more. The writer signals `@drained_notify` after each
|
|
54
|
+
# drain pass.
|
|
26
55
|
#
|
|
27
56
|
# Flow control: `RequestStream#window_updated` overrides the protocol-http2
|
|
28
57
|
# default to fan a notification out to any fiber blocked in `send_body`
|
|
@@ -31,6 +60,153 @@ module Hyperion
|
|
|
31
60
|
# size and yields on the notification when the window is exhausted, so
|
|
32
61
|
# large bodies never trip a FlowControlError.
|
|
33
62
|
class Http2Handler
|
|
63
|
+
# Cap on bytes that may sit in a connection's send queue waiting for the
|
|
64
|
+
# writer fiber to drain. Slow-read h2 clients can otherwise let an
|
|
65
|
+
# encoder fiber pile arbitrary bytes into RAM. 16 MiB matches the upper
|
|
66
|
+
# bound a well-behaved peer will buffer — anything beyond that is the
|
|
67
|
+
# writer being starved, and the right answer is to backpressure the
|
|
68
|
+
# encoder rather than allocate more.
|
|
69
|
+
MAX_PER_CONN_PENDING_BYTES = 16 * 1024 * 1024
|
|
70
|
+
|
|
71
|
+
# IO-shaped wrapper passed to `Protocol::HTTP2::Framer` in place of the
|
|
72
|
+
# real socket. Reads are direct passthroughs (the read loop runs on the
|
|
73
|
+
# connection fiber and there's only one reader). Writes are enqueued
|
|
74
|
+
# onto the connection-wide `WriterContext#queue`; the writer fiber owns
|
|
75
|
+
# the real socket and drains the queue.
|
|
76
|
+
#
|
|
77
|
+
# We deliberately do NOT delegate `flush` to the real socket: writes
|
|
78
|
+
# don't reach it from this object — the writer fiber does that. `flush`
|
|
79
|
+
# here is a no-op (the writer flushes after each batch).
|
|
80
|
+
#
|
|
81
|
+
# `closed?` reports the real socket's state so protocol-http2's read
|
|
82
|
+
# loop sees EOF the same way it always has.
|
|
83
|
+
class SendQueueIO
|
|
84
|
+
attr_reader :real_socket
|
|
85
|
+
|
|
86
|
+
def initialize(real_socket, writer_ctx)
|
|
87
|
+
@real_socket = real_socket
|
|
88
|
+
@writer_ctx = writer_ctx
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Framer's read path — direct delegation. Single-reader (the conn
|
|
92
|
+
# fiber), so no contention here.
|
|
93
|
+
def read(*args)
|
|
94
|
+
@real_socket.read(*args)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Framer's write path — non-blocking handoff into the send queue.
|
|
98
|
+
# Backpressure is applied here: if pending bytes exceed the cap, the
|
|
99
|
+
# calling fiber parks on the drained notification until the writer
|
|
100
|
+
# has flushed enough to bring us below the threshold.
|
|
101
|
+
def write(bytes)
|
|
102
|
+
return 0 if bytes.nil? || bytes.empty?
|
|
103
|
+
|
|
104
|
+
@writer_ctx.enqueue(bytes)
|
|
105
|
+
bytes.bytesize
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def flush
|
|
109
|
+
# No-op: bytes don't live in this object, they live in the queue.
|
|
110
|
+
# The writer fiber flushes the real socket as it drains.
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def close
|
|
115
|
+
@real_socket.close unless @real_socket.closed?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Multi-line on purpose: a single-line `def closed?; @real_socket.closed?; end`
|
|
119
|
+
# gets autocorrected to `delegate :closed?, to: :@real_socket` by Rails-aware
|
|
120
|
+
# ruby-lsp formatters, which is wrong here (this is a plain gem, no
|
|
121
|
+
# ActiveSupport on the dependency graph).
|
|
122
|
+
def closed?
|
|
123
|
+
socket = @real_socket
|
|
124
|
+
socket.closed?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Holds the per-connection outbound coordination state (queue,
|
|
129
|
+
# notifications, byte counters, shutdown flag) plus the encode mutex
|
|
130
|
+
# that protects HPACK state and per-stream frame ordering.
|
|
131
|
+
#
|
|
132
|
+
# Single instance per connection, lives for the lifetime of `serve`.
|
|
133
|
+
class WriterContext
|
|
134
|
+
attr_reader :encode_mutex
|
|
135
|
+
|
|
136
|
+
def initialize(max_pending_bytes: MAX_PER_CONN_PENDING_BYTES)
|
|
137
|
+
@queue = ::Thread::Queue.new
|
|
138
|
+
@send_notify = ::Async::Notification.new
|
|
139
|
+
@drained_notify = ::Async::Notification.new
|
|
140
|
+
@encode_mutex = ::Mutex.new
|
|
141
|
+
@pending_bytes = 0
|
|
142
|
+
@pending_bytes_lock = ::Mutex.new
|
|
143
|
+
@max_pending_bytes = max_pending_bytes
|
|
144
|
+
@writer_done = false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Called by SendQueueIO#write on the calling (encoder) fiber. Enforces
|
|
148
|
+
# the per-connection backpressure cap before enqueuing.
|
|
149
|
+
def enqueue(bytes)
|
|
150
|
+
wait_for_drain_if_full(bytes.bytesize)
|
|
151
|
+
@pending_bytes_lock.synchronize { @pending_bytes += bytes.bytesize }
|
|
152
|
+
@queue << bytes
|
|
153
|
+
@send_notify.signal
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Pops a single chunk; returns nil if the queue is empty (non-blocking).
|
|
157
|
+
def try_pop
|
|
158
|
+
@queue.pop(true)
|
|
159
|
+
rescue ::ThreadError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Called by the writer fiber after each successful drain to release
|
|
164
|
+
# any encoders blocked on the cap.
|
|
165
|
+
def note_drained(bytesize)
|
|
166
|
+
@pending_bytes_lock.synchronize do
|
|
167
|
+
@pending_bytes -= bytesize
|
|
168
|
+
@pending_bytes = 0 if @pending_bytes.negative? # paranoia
|
|
169
|
+
end
|
|
170
|
+
@drained_notify.signal
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def wait_for_signal
|
|
174
|
+
@send_notify.wait
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def shutdown!
|
|
178
|
+
@writer_done = true
|
|
179
|
+
# Wake the writer if it's parked, and any encoder waiting on drain.
|
|
180
|
+
@send_notify.signal
|
|
181
|
+
@drained_notify.signal
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def writer_done?
|
|
185
|
+
@writer_done
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def queue_empty?
|
|
189
|
+
@queue.empty?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def pending_bytes
|
|
193
|
+
@pending_bytes_lock.synchronize { @pending_bytes }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def wait_for_drain_if_full(incoming_bytes)
|
|
199
|
+
# If we're already at/above the cap, park until the writer has
|
|
200
|
+
# drained. We re-check after every signal because multiple encoders
|
|
201
|
+
# can wake on a single drain notification.
|
|
202
|
+
while !@writer_done &&
|
|
203
|
+
@pending_bytes_lock.synchronize { @pending_bytes + incoming_bytes > @max_pending_bytes } &&
|
|
204
|
+
!@queue.empty?
|
|
205
|
+
@drained_notify.wait
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
34
210
|
# Per-stream subclass that captures decoded request pseudo-headers,
|
|
35
211
|
# regular headers, and any DATA frame body bytes for later dispatch.
|
|
36
212
|
# Also exposes a `window_available` notification fan-out so the
|
|
@@ -247,21 +423,29 @@ module Hyperion
|
|
|
247
423
|
def serve(socket)
|
|
248
424
|
@metrics.increment(:connections_accepted)
|
|
249
425
|
@metrics.increment(:connections_active)
|
|
250
|
-
|
|
251
|
-
|
|
426
|
+
|
|
427
|
+
# Per-connection outbound coordination. Encoder fibers enqueue bytes;
|
|
428
|
+
# the writer fiber owns the real socket and drains. See class docstring.
|
|
429
|
+
writer_ctx = WriterContext.new
|
|
430
|
+
send_io = SendQueueIO.new(socket, writer_ctx)
|
|
431
|
+
framer = ::Protocol::HTTP2::Framer.new(send_io)
|
|
432
|
+
server = build_server(framer)
|
|
433
|
+
|
|
434
|
+
task = ::Async::Task.current
|
|
435
|
+
|
|
436
|
+
# Spawn the dedicated writer fiber BEFORE the preface exchange.
|
|
437
|
+
# `Server#read_connection_preface` writes the server's SETTINGS frame
|
|
438
|
+
# via the framer; if the writer isn't running, those bytes sit in the
|
|
439
|
+
# queue. Spawning first guarantees they flush as soon as the scheduler
|
|
440
|
+
# ticks, avoiding any pathological deadlock where a client implementation
|
|
441
|
+
# waits for our SETTINGS before sending more frames.
|
|
442
|
+
writer_task = task.async { run_writer_loop(socket, writer_ctx) }
|
|
443
|
+
|
|
252
444
|
server.read_connection_preface(initial_settings_payload)
|
|
253
445
|
|
|
254
446
|
# Extract once — the same TCP peer drives every stream on this conn.
|
|
255
447
|
peer_addr = peer_address(socket)
|
|
256
448
|
|
|
257
|
-
# All framer writes (HEADERS / DATA / RST_STREAM / GOAWAY) must be
|
|
258
|
-
# serialized: the underlying SSLSocket is not safe across fibers, and
|
|
259
|
-
# the HPACK encoder is also stateful. The connection's own frame loop
|
|
260
|
-
# uses this mutex too — see `dispatch_stream` and `send_body`.
|
|
261
|
-
send_mutex = ::Mutex.new
|
|
262
|
-
|
|
263
|
-
task = ::Async::Task.current
|
|
264
|
-
|
|
265
449
|
# Track in-flight per-stream dispatch fibers so we can drain them on
|
|
266
450
|
# connection close.
|
|
267
451
|
stream_tasks = []
|
|
@@ -284,7 +468,7 @@ module Hyperion
|
|
|
284
468
|
stream.instance_variable_set(:@hyperion_dispatched, true)
|
|
285
469
|
|
|
286
470
|
stream_tasks << task.async do
|
|
287
|
-
dispatch_stream(stream,
|
|
471
|
+
dispatch_stream(stream, writer_ctx, peer_addr)
|
|
288
472
|
end
|
|
289
473
|
end
|
|
290
474
|
end
|
|
@@ -309,6 +493,18 @@ module Hyperion
|
|
|
309
493
|
}
|
|
310
494
|
end
|
|
311
495
|
ensure
|
|
496
|
+
# Coordinated shutdown: flag the writer, signal it, wait for the final
|
|
497
|
+
# drain, then close the real socket. Order matters — closing the
|
|
498
|
+
# socket before the writer drains would discard final RST_STREAM /
|
|
499
|
+
# GOAWAY / END_STREAM frames in the queue.
|
|
500
|
+
if writer_ctx
|
|
501
|
+
writer_ctx.shutdown!
|
|
502
|
+
begin
|
|
503
|
+
writer_task&.wait
|
|
504
|
+
rescue StandardError
|
|
505
|
+
nil
|
|
506
|
+
end
|
|
507
|
+
end
|
|
312
508
|
@metrics.decrement(:connections_active)
|
|
313
509
|
socket.close unless socket.closed?
|
|
314
510
|
end
|
|
@@ -394,7 +590,7 @@ module Hyperion
|
|
|
394
590
|
server
|
|
395
591
|
end
|
|
396
592
|
|
|
397
|
-
def dispatch_stream(stream,
|
|
593
|
+
def dispatch_stream(stream, writer_ctx, peer_addr = nil)
|
|
398
594
|
# RFC 7540 §8.1.2 — header validation flagged this stream as malformed.
|
|
399
595
|
# Send RST_STREAM PROTOCOL_ERROR instead of invoking the app.
|
|
400
596
|
if stream.protocol_error?
|
|
@@ -403,7 +599,7 @@ module Hyperion
|
|
|
403
599
|
end
|
|
404
600
|
@metrics.increment(:requests_rejected)
|
|
405
601
|
begin
|
|
406
|
-
|
|
602
|
+
writer_ctx.encode_mutex.synchronize do
|
|
407
603
|
stream.send_reset_stream(::Protocol::HTTP2::Error::PROTOCOL_ERROR) unless stream.closed?
|
|
408
604
|
end
|
|
409
605
|
rescue StandardError
|
|
@@ -459,8 +655,8 @@ module Hyperion
|
|
|
459
655
|
body_chunks.each { |c| payload << c.to_s }
|
|
460
656
|
body_chunks.close if body_chunks.respond_to?(:close)
|
|
461
657
|
|
|
462
|
-
|
|
463
|
-
send_body(stream, payload,
|
|
658
|
+
writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
|
|
659
|
+
send_body(stream, payload, writer_ctx)
|
|
464
660
|
@metrics.increment_status(status)
|
|
465
661
|
rescue StandardError => e
|
|
466
662
|
@metrics.increment(:app_errors)
|
|
@@ -473,7 +669,9 @@ module Hyperion
|
|
|
473
669
|
}
|
|
474
670
|
end
|
|
475
671
|
begin
|
|
476
|
-
|
|
672
|
+
writer_ctx.encode_mutex.synchronize do
|
|
673
|
+
stream.send_reset_stream(::Protocol::HTTP2::Error::INTERNAL_ERROR)
|
|
674
|
+
end
|
|
477
675
|
rescue StandardError
|
|
478
676
|
nil
|
|
479
677
|
end
|
|
@@ -485,9 +683,12 @@ module Hyperion
|
|
|
485
683
|
# notification — protocol-http2 calls `window_updated` on every active
|
|
486
684
|
# stream when WINDOW_UPDATE frames arrive (either stream- or
|
|
487
685
|
# connection-scoped), which signals the notification.
|
|
488
|
-
|
|
686
|
+
#
|
|
687
|
+
# The encode_mutex protects HPACK state and per-stream frame ordering;
|
|
688
|
+
# the actual socket write happens off-fiber via the writer task.
|
|
689
|
+
def send_body(stream, payload, writer_ctx)
|
|
489
690
|
if payload.empty?
|
|
490
|
-
|
|
691
|
+
writer_ctx.encode_mutex.synchronize { stream.send_data('', ::Protocol::HTTP2::END_STREAM) }
|
|
491
692
|
return
|
|
492
693
|
end
|
|
493
694
|
|
|
@@ -508,7 +709,69 @@ module Hyperion
|
|
|
508
709
|
offset += chunk.bytesize
|
|
509
710
|
flags = offset >= bytesize ? ::Protocol::HTTP2::END_STREAM : 0
|
|
510
711
|
|
|
511
|
-
|
|
712
|
+
writer_ctx.encode_mutex.synchronize { stream.send_data(chunk, flags) }
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Drain bytes off the per-connection send queue onto the real socket.
|
|
717
|
+
# This fiber is the SOLE writer to `socket` for the connection's
|
|
718
|
+
# lifetime, which satisfies SSLSocket's "no concurrent writes from
|
|
719
|
+
# different fibers" constraint.
|
|
720
|
+
#
|
|
721
|
+
# The loop:
|
|
722
|
+
# 1. Drain everything currently enqueued (non-blocking pops).
|
|
723
|
+
# 2. If we drained anything, signal `@drained_notify` so backpressured
|
|
724
|
+
# encoders can resume, then loop again — more bytes may have been
|
|
725
|
+
# enqueued while we were writing.
|
|
726
|
+
# 3. If shutdown was requested AND the queue is empty, exit.
|
|
727
|
+
# 4. Otherwise park on the send notification until an encoder pokes us.
|
|
728
|
+
def run_writer_loop(socket, writer_ctx)
|
|
729
|
+
loop do
|
|
730
|
+
drained_bytes = 0
|
|
731
|
+
while (chunk = writer_ctx.try_pop)
|
|
732
|
+
begin
|
|
733
|
+
socket.write(chunk)
|
|
734
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
735
|
+
# Peer hung up. Release THIS chunk's byte budget, then drain the
|
|
736
|
+
# rest of the queue (without writing) so backpressured encoders
|
|
737
|
+
# don't stall waiting on a writer that's about to exit. Any
|
|
738
|
+
# remaining queued bytes are dropped — the connection is dead.
|
|
739
|
+
writer_ctx.note_drained(chunk.bytesize)
|
|
740
|
+
drain_and_discard_queue(writer_ctx)
|
|
741
|
+
return
|
|
742
|
+
end
|
|
743
|
+
drained_bytes += chunk.bytesize
|
|
744
|
+
writer_ctx.note_drained(chunk.bytesize)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Some sockets (SSLSocket on a TCPSocket whose Nagle is off) need an
|
|
748
|
+
# explicit flush to push small final frames (END_STREAM data, GOAWAY)
|
|
749
|
+
# without waiting for the next write. Cheap when there's nothing
|
|
750
|
+
# buffered.
|
|
751
|
+
socket.flush if drained_bytes.positive? && socket.respond_to?(:flush) && !socket.closed?
|
|
752
|
+
|
|
753
|
+
return if writer_ctx.writer_done? && writer_ctx.queue_empty?
|
|
754
|
+
|
|
755
|
+
writer_ctx.wait_for_signal
|
|
756
|
+
end
|
|
757
|
+
rescue StandardError => e
|
|
758
|
+
@logger.error do
|
|
759
|
+
{
|
|
760
|
+
message: 'h2 writer loop error',
|
|
761
|
+
error: e.message,
|
|
762
|
+
error_class: e.class.name,
|
|
763
|
+
backtrace: (e.backtrace || []).first(10).join(' | ')
|
|
764
|
+
}
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
# On peer-disconnect we discard any queued bytes (we can't write them),
|
|
769
|
+
# but we MUST still decrement the byte counter for each one or
|
|
770
|
+
# backpressured encoder fibers will park forever on the drain
|
|
771
|
+
# notification.
|
|
772
|
+
def drain_and_discard_queue(writer_ctx)
|
|
773
|
+
while (chunk = writer_ctx.try_pop)
|
|
774
|
+
writer_ctx.note_drained(chunk.bytesize)
|
|
512
775
|
end
|
|
513
776
|
end
|
|
514
777
|
|
data/lib/hyperion/logger.rb
CHANGED
|
@@ -65,6 +65,7 @@ module Hyperion
|
|
|
65
65
|
# check the regular stream here — colored text is for humans.
|
|
66
66
|
@colorize = @format == :text && tty?(@out)
|
|
67
67
|
@c_access_available = nil # lazy-computed on first access — see below.
|
|
68
|
+
@c_access_colored_available = nil # ditto for the coloured TTY variant.
|
|
68
69
|
# Registry of every per-thread access buffer ever allocated through
|
|
69
70
|
# this Logger instance. Walked by #flush_all on shutdown so SIGTERM
|
|
70
71
|
# doesn't strand buffered lines in dying threads. The Mutex guards
|
|
@@ -94,6 +95,16 @@ module Hyperion
|
|
|
94
95
|
::Hyperion::CParser.respond_to?(:build_access_line)
|
|
95
96
|
end
|
|
96
97
|
|
|
98
|
+
# Whether Hyperion::CParser.build_access_line_colored is available. Same
|
|
99
|
+
# lazy-probe pattern as #c_access_available?; lets a colored-TTY run pick
|
|
100
|
+
# up the C path instead of the Ruby fallback.
|
|
101
|
+
def c_access_colored_available?
|
|
102
|
+
return @c_access_colored_available unless @c_access_colored_available.nil?
|
|
103
|
+
|
|
104
|
+
@c_access_colored_available = defined?(::Hyperion::CParser) &&
|
|
105
|
+
::Hyperion::CParser.respond_to?(:build_access_line_colored)
|
|
106
|
+
end
|
|
107
|
+
|
|
97
108
|
LEVELS.each_key do |lvl|
|
|
98
109
|
define_method(lvl) do |payload = nil, &block|
|
|
99
110
|
next unless emit?(lvl)
|
|
@@ -140,7 +151,12 @@ module Hyperion
|
|
|
140
151
|
# which the C builder doesn't emit. Production deploys (non-TTY,
|
|
141
152
|
# log-aggregator destinations) take the C path; local TTY runs keep the
|
|
142
153
|
# colored Ruby fallback.
|
|
143
|
-
line = if
|
|
154
|
+
line = if @colorize && c_access_colored_available?
|
|
155
|
+
# Colored TTY path: green INFO label baked into the C builder.
|
|
156
|
+
::Hyperion::CParser.build_access_line_colored(@format, ts, method, path,
|
|
157
|
+
query, status, duration_ms,
|
|
158
|
+
remote_addr, http_version)
|
|
159
|
+
elsif !@colorize && c_access_available?
|
|
144
160
|
::Hyperion::CParser.build_access_line(@format, ts, method, path,
|
|
145
161
|
query, status, duration_ms,
|
|
146
162
|
remote_addr, http_version)
|
data/lib/hyperion/version.rb
CHANGED