hyperion-rb 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64221d424c994e0757262dca6984cd2b65bf9ec3da6c85094daa717c716da906
4
- data.tar.gz: 236188ff1777b49178bb4b2bfe75fbea9309ec6f5d56ec01329943dfa1afbb36
3
+ metadata.gz: ab7691ac6671b0e0c9606c281c55659c76675b71f3d461d1fb5bf6a03680861b
4
+ data.tar.gz: b7ad35585d56e59d4a7b5c9fcb6d4e016e72b4c3f99496ba675ca7e871865718
5
5
  SHA512:
6
- metadata.gz: 9b196f8d046c828546f8f5fbac91e0300e8704a70f11b47096a9b0fb05caf2ec34f63e2f33768ab53b41413aaa4e957ac499aa4779ecd54b6a987deef7fd464e
7
- data.tar.gz: cb3d64f757736bcbc2632492e24b0794a67c98bfdcae7a1e53d89f583cf2eecc404e060fd3ec10e441ddfdcd11273c41e0d46fa9b80be904b29a7718fe0258ba
6
+ metadata.gz: 8911a91c7932b332a9d5f069099c7f6ded94d9b5978dffd259881ab482066d5328c508ca5983101c6d9d04b18c1353664766bf759ec66cb034d3bcdf84f01a89
7
+ data.tar.gz: 7c948c98eb9aea2cb31595e08deca0c4e98c2281105a18fc0419678da25a04ac9b04f9defe564ea660104cf935399582506eb87769f6f9fbbca74f568c8f904b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0] - 2026-04-27
4
+
5
+ Audit-driven CLI + adapter polish. No breaking changes; pure additions to the operator surface and a hardening of the host-header parser.
6
+
7
+ ### Added
8
+ - **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:
9
+ - `--max-body-bytes BYTES` (Integer, default 16 MiB)
10
+ - `--max-header-bytes BYTES` (Integer, default 64 KiB)
11
+ - `--max-pending COUNT` (Integer, default unbounded)
12
+ - `--max-request-read-seconds SECONDS` (Float, default 60)
13
+ - `--admin-token TOKEN` (String, default unset) — gates `POST /-/quit` and `GET /-/metrics`
14
+ - `--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`.
15
+ - `--worker-max-rss-mb MB` (Integer, default unset) — RSS-based worker recycling
16
+ - `--idle-keepalive SECONDS` (Float, default 5)
17
+ - `--graceful-timeout SECONDS` (Integer, default 30)
18
+ - **`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.
19
+ - **README CLI flags table** extended with the 8 new flags plus `--[no-]yjit` / `--[no-]async-io` (already wired but previously undocumented in the table).
20
+ - **17 new specs**:
21
+ - 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).
22
+ - 3 in `spec/hyperion/adapter/rack_spec.rb` cover plain IPv4-with-port, bare hostname (no port), and the malformed-bracket regression below.
23
+
24
+ ### Fixed
25
+ - **`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.
26
+
27
+ ### Security
28
+ - `--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.
29
+
30
+ ## [1.4.2] - 2026-04-27
31
+
32
+ Audit-driven cleanup. No behaviour changes; fiber-correctness + docs polish.
33
+
34
+ ### Fixed
35
+ - **`Hyperion::Logger` access buffer was fiber-local, not thread-local** — pre-1.4.2 the access-log write buffer was stored via `Thread.current[@buffer_key]`. Under an `Async::Scheduler` (TLS / h2 / `--async-io` plain HTTP/1.1) every handler fiber got its own private buffer, so the 4 KiB `ACCESS_FLUSH_BYTES` batching never fired — each fiber's buffer accumulated 1-3 lines before its connection closed and `flush_access_buffer` wrote them. At 24k r/s this meant ~12-24k `write(2)` syscalls/sec instead of the designed ~750/sec. Switched to `Thread#thread_variable_*` so all fibers on the same OS thread share one buffer and the batching actually fires. Same root cause as the 1.4.1 Metrics fix; surfaced by a code-audit grep for residual `Thread.current[:key]` patterns.
36
+ - **`Logger#cached_timestamp` and `ResponseWriter#cached_date`** — same fix. Pre-1.4.2 the per-second / per-millisecond Time-formatting caches were per-fiber, so under Async every fiber rebuilt the iso8601 / httpdate String on its first call after a tick. Now per-OS-thread, shared across fibers; one allocation per second per thread total.
37
+
38
+ ### Added
39
+ - **Prometheus exporter example output** in the README's Metrics section — shows what `curl -H 'X-Hyperion-Admin-Token: ...' /-/metrics` actually returns (HELP/TYPE lines, status-code labels, auto-export of unknown counters), plus the Prometheus scraper config sketch.
40
+ - **Regression spec** for the access-buffer cross-fiber bug — two fibers on the same OS thread write through one logger; verifies a single buffer is registered (not one per fiber) and both lines land via `flush_all`.
41
+ - **4 new Metrics specs** (already shipped in 1.4.1; called out here for coverage tracking) — cross-fiber on same thread, cross-thread, cross-fiber-on-different-thread, many-fibers-on-same-thread.
42
+
43
+ ### Changed
44
+ - **README benchmark section** version-stamped: clarifies that the headline numbers were measured against the noted Hyperion version (most are 1.2.0 hello-world / 1.3.0 PG-bound) and that 1.3.0+ `--async-io` + 1.4.0+ TLS-inline + 1.4.1+ Metrics fix preserve or improve these numbers. We re-run the headline configs each release.
45
+
3
46
  ## [1.4.1] - 2026-04-27
4
47
 
5
48
  ### Fixed
data/README.md CHANGED
@@ -25,7 +25,7 @@ bundle exec hyperion config.ru
25
25
 
26
26
  ## Benchmarks
27
27
 
28
- All numbers are real wrk runs against published Hyperion configs. Hyperion ships **with default-ON structured access logs**; Puma comparisons use Puma defaults (no per-request log emission).
28
+ All numbers are real wrk runs against published Hyperion configs. Hyperion ships **with default-ON structured access logs**; Puma comparisons use Puma defaults (no per-request log emission). Each section is stamped with the Hyperion version it was measured against — newer versions (1.3.0+ `--async-io`, 1.4.0+ TLS h1 inline, 1.4.1+ Metrics fiber-key fix) preserve or improve these numbers; we re-run the headline configs each release and have not seen regressions on these workloads.
29
29
 
30
30
  ### Hello-world Rack app
31
31
 
@@ -255,6 +255,17 @@ Three layers, in precedence order: explicit CLI flag > environment variable > `c
255
255
  | `--log-format FORMAT` | `auto` | `text` / `json` / `auto`. Auto: JSON when `RAILS_ENV`/`RACK_ENV` is `production`/`staging`, colored text on TTY, JSON otherwise. |
256
256
  | `--[no-]log-requests` | ON | Per-request access log. |
257
257
  | `--fiber-local-shim` | off | Patches `Thread#thread_variable_*` to fiber storage for older Rails idioms. |
258
+ | `--[no-]yjit` | auto | Force YJIT on/off. Default: auto-on under `RAILS_ENV`/`RACK_ENV` = `production`/`staging`. |
259
+ | `--[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. |
260
+ | `--max-body-bytes BYTES` | `16777216` (16 MiB) | Maximum request body size. |
261
+ | `--max-header-bytes BYTES` | `65536` (64 KiB) | Maximum total request-header size. |
262
+ | `--max-pending COUNT` | unbounded | Per-worker accept-queue cap before new connections are rejected with HTTP 503 + `Retry-After: 1`. |
263
+ | `--max-request-read-seconds SECONDS` | `60` | Total wallclock budget for reading request line + headers + body for ONE request. Slowloris defence. |
264
+ | `--admin-token TOKEN` | unset | Bearer token for `POST /-/quit` and `GET /-/metrics`. **Production: prefer `--admin-token-file` — argv is visible via `ps`.** |
265
+ | `--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`). |
266
+ | `--worker-max-rss-mb MB` | unset | Master gracefully recycles a worker once its RSS exceeds this many megabytes. nil = disabled. |
267
+ | `--idle-keepalive SECONDS` | `5` | Keep-alive idle timeout. Connection closes after this many seconds of inactivity. |
268
+ | `--graceful-timeout SECONDS` | `30` | Shutdown deadline before SIGKILL is delivered to a worker that hasn't drained. |
258
269
 
259
270
  ### Environment variables
260
271
 
@@ -363,6 +374,31 @@ Hyperion.stats
363
374
  # => {connections_accepted: 1234, connections_active: 7, requests_total: 8910, …}
364
375
  ```
365
376
 
377
+ ### Prometheus exporter
378
+
379
+ When `admin_token` is set in your config, Hyperion mounts a `/-/metrics` endpoint that emits Prometheus text-format v0.0.4. Same token guards both `/-/metrics` (GET) and `/-/quit` (POST); auth is via the `X-Hyperion-Admin-Token` header.
380
+
381
+ ```sh
382
+ $ curl -s -H 'X-Hyperion-Admin-Token: secret' http://127.0.0.1:9292/-/metrics
383
+ # HELP hyperion_requests_total Total HTTP requests handled
384
+ # TYPE hyperion_requests_total counter
385
+ hyperion_requests_total 8910
386
+ # HELP hyperion_bytes_written_total Total bytes written to response sockets
387
+ # TYPE hyperion_bytes_written_total counter
388
+ hyperion_bytes_written_total 2351023
389
+ # HELP hyperion_responses_status_total Responses by HTTP status code
390
+ # TYPE hyperion_responses_status_total counter
391
+ hyperion_responses_status_total{status="200"} 8521
392
+ hyperion_responses_status_total{status="404"} 12
393
+ hyperion_responses_status_total{status="500"} 3
394
+ # … and so on for sendfile_responses_total, rejected_connections_total,
395
+ # slow_request_aborts_total, requests_async_dispatched_total, etc.
396
+ ```
397
+
398
+ Any counter not in the known set (added by app middleware via `Hyperion.metrics.increment(:custom_thing)`) is auto-exported as `hyperion_custom_thing` with a generic HELP line — no Hyperion config change required.
399
+
400
+ Point your scraper at it: in Prometheus' `scrape_configs`, set `metrics_path: /-/metrics` and `bearer_token` (or use a custom header relabel — Prometheus 2.42+ supports `authorization.credentials_file` paired with a custom `header` block). Network-isolate the admin endpoints if the listener is internet-facing — see [docs/REVERSE_PROXY.md](docs/REVERSE_PROXY.md) for the nginx `location /-/ { return 404; }` recipe.
401
+
366
402
  ## TLS + HTTP/2
367
403
 
368
404
  Provide a PEM cert + key:
@@ -138,7 +138,17 @@ module Hyperion
138
138
 
139
139
  if host_header.start_with?('[')
140
140
  close = host_header.index(']')
141
- return [host_header, '80'] unless close
141
+ # Malformed bracketed IPv6 (no closing bracket): we used to return
142
+ # the raw garbage as SERVER_NAME, which then leaked into Rack env
143
+ # where downstream URL generators / loggers / SSRF allow-lists
144
+ # would trust attacker-controlled bytes. Fail closed to a safe
145
+ # default and bump a counter so operators can alert on volume.
146
+ # No raise — Rack apps don't expect Hyperion's adapter to throw
147
+ # on header-parse failures, so we degrade gracefully instead.
148
+ unless close
149
+ Hyperion.metrics.increment(:malformed_host_header)
150
+ return %w[localhost 80]
151
+ end
142
152
 
143
153
  name = host_header[0..close]
144
154
  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
- # Precedence: CLI > config file > built-in default. We auto-load
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.
@@ -150,7 +150,7 @@ module Hyperion
150
150
  build_access_text(ts, method, path, query, status, duration_ms, remote_addr, http_version)
151
151
  end
152
152
 
153
- buf = Thread.current[@buffer_key] || allocate_access_buffer
153
+ buf = Thread.current.thread_variable_get(@buffer_key) || allocate_access_buffer
154
154
  buf << line
155
155
  return if buf.bytesize < ACCESS_FLUSH_BYTES
156
156
 
@@ -164,7 +164,7 @@ module Hyperion
164
164
  # loop when a connection closes (so log lines from a closing keep-alive
165
165
  # session don't get stuck behind the buffer until the next connection).
166
166
  def flush_access_buffer
167
- buf = Thread.current[@buffer_key]
167
+ buf = Thread.current.thread_variable_get(@buffer_key)
168
168
  return if buf.nil? || buf.empty?
169
169
 
170
170
  @out.write(buf)
@@ -215,7 +215,7 @@ module Hyperion
215
215
  # Mutex is taken once per thread (not per request).
216
216
  def allocate_access_buffer
217
217
  buf = +''
218
- Thread.current[@buffer_key] = buf
218
+ Thread.current.thread_variable_set(@buffer_key, buf)
219
219
  @access_buffers_mutex.synchronize { @access_buffers << buf }
220
220
  buf
221
221
  end
@@ -229,11 +229,21 @@ module Hyperion
229
229
  end
230
230
 
231
231
  # Cached UTC iso8601(3) timestamp, refreshed at most once per millisecond
232
- # per thread. At 24k r/s with 16 threads we render ~1500 r/s/thread; only
233
- # ~1000 of those allocate a new String. The other 500 reuse the cached one.
232
+ # per OS thread. At 24k r/s with 16 threads we render ~1500 r/s/thread;
233
+ # only ~1000 of those allocate a new String. The other 500 reuse the
234
+ # cached one. Stored as a thread variable (truly thread-local across
235
+ # fibers) so under Async every fiber on this thread shares the same
236
+ # cache and the per-second amortisation actually fires; with the prior
237
+ # `Thread.current[:k]` storage each fiber would re-build the iso8601
238
+ # String on its first call after a millisecond tick.
234
239
  def cached_timestamp
235
240
  now_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
236
- cache = (Thread.current[:__hyperion_ts_cache__] ||= [-1, ''])
241
+ thread = Thread.current
242
+ cache = thread.thread_variable_get(:__hyperion_ts_cache__)
243
+ if cache.nil?
244
+ cache = [-1, '']
245
+ thread.thread_variable_set(:__hyperion_ts_cache__, cache)
246
+ end
237
247
  return cache[1] if cache[0] == now_ms
238
248
 
239
249
  cache[0] = now_ms
@@ -142,10 +142,19 @@ module Hyperion
142
142
 
143
143
  # Cached HTTP `Date:` header at second resolution. `Time.now.httpdate`
144
144
  # allocates several strings; at high r/s the cache reuses one String per
145
- # second per thread instead of allocating per response.
145
+ # second per OS thread instead of allocating per response. Stored as a
146
+ # thread variable (truly thread-local across fibers) so under Async
147
+ # every fiber on this thread shares the same cache — otherwise each
148
+ # fiber would rebuild the httpdate String on its first response after
149
+ # a second tick.
146
150
  def cached_date
147
151
  now_s = Process.clock_gettime(Process::CLOCK_REALTIME, :second)
148
- cache = (Thread.current[:__hyperion_date_cache__] ||= [-1, ''])
152
+ thread = Thread.current
153
+ cache = thread.thread_variable_get(:__hyperion_date_cache__)
154
+ if cache.nil?
155
+ cache = [-1, '']
156
+ thread.thread_variable_set(:__hyperion_date_cache__, cache)
157
+ end
149
158
  return cache[1] if cache[0] == now_s
150
159
 
151
160
  cache[0] = now_s
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '1.4.1'
4
+ VERSION = '1.5.0'
5
5
  end
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.4.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov