hyperion-rb 1.6.2 → 2.11.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4768 -0
  3. data/README.md +222 -13
  4. data/ext/hyperion_h2_codec/Cargo.lock +7 -0
  5. data/ext/hyperion_h2_codec/Cargo.toml +33 -0
  6. data/ext/hyperion_h2_codec/extconf.rb +73 -0
  7. data/ext/hyperion_h2_codec/src/frames.rs +140 -0
  8. data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
  9. data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
  10. data/ext/hyperion_h2_codec/src/lib.rs +296 -0
  11. data/ext/hyperion_http/extconf.rb +28 -0
  12. data/ext/hyperion_http/h2_codec_glue.c +408 -0
  13. data/ext/hyperion_http/page_cache.c +1125 -0
  14. data/ext/hyperion_http/parser.c +473 -38
  15. data/ext/hyperion_http/sendfile.c +982 -0
  16. data/ext/hyperion_http/websocket.c +493 -0
  17. data/ext/hyperion_io_uring/Cargo.lock +33 -0
  18. data/ext/hyperion_io_uring/Cargo.toml +34 -0
  19. data/ext/hyperion_io_uring/extconf.rb +74 -0
  20. data/ext/hyperion_io_uring/src/lib.rs +316 -0
  21. data/lib/hyperion/adapter/rack.rb +370 -42
  22. data/lib/hyperion/admin_listener.rb +207 -0
  23. data/lib/hyperion/admin_middleware.rb +36 -7
  24. data/lib/hyperion/cli.rb +310 -11
  25. data/lib/hyperion/config.rb +440 -14
  26. data/lib/hyperion/connection.rb +679 -22
  27. data/lib/hyperion/deprecations.rb +81 -0
  28. data/lib/hyperion/dispatch_mode.rb +165 -0
  29. data/lib/hyperion/fiber_local.rb +75 -13
  30. data/lib/hyperion/h2_admission.rb +77 -0
  31. data/lib/hyperion/h2_codec.rb +499 -0
  32. data/lib/hyperion/http/page_cache.rb +122 -0
  33. data/lib/hyperion/http/sendfile.rb +696 -0
  34. data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
  35. data/lib/hyperion/http2_handler.rb +618 -19
  36. data/lib/hyperion/io_uring.rb +317 -0
  37. data/lib/hyperion/lint_wrapper_pool.rb +126 -0
  38. data/lib/hyperion/master.rb +96 -9
  39. data/lib/hyperion/metrics/path_templater.rb +68 -0
  40. data/lib/hyperion/metrics.rb +256 -0
  41. data/lib/hyperion/prometheus_exporter.rb +150 -0
  42. data/lib/hyperion/request.rb +13 -0
  43. data/lib/hyperion/response_writer.rb +477 -16
  44. data/lib/hyperion/runtime.rb +195 -0
  45. data/lib/hyperion/server/route_table.rb +179 -0
  46. data/lib/hyperion/server.rb +519 -55
  47. data/lib/hyperion/static_preload.rb +133 -0
  48. data/lib/hyperion/thread_pool.rb +61 -7
  49. data/lib/hyperion/tls.rb +343 -1
  50. data/lib/hyperion/version.rb +1 -1
  51. data/lib/hyperion/websocket/close_codes.rb +71 -0
  52. data/lib/hyperion/websocket/connection.rb +876 -0
  53. data/lib/hyperion/websocket/frame.rb +356 -0
  54. data/lib/hyperion/websocket/handshake.rb +525 -0
  55. data/lib/hyperion/worker.rb +111 -9
  56. data/lib/hyperion.rb +137 -3
  57. metadata +50 -1
@@ -0,0 +1,876 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require_relative 'frame'
5
+ require_relative 'close_codes'
6
+
7
+ module Hyperion
8
+ # WS-4 (2.1.0) — per-connection WebSocket wrapper.
9
+ #
10
+ # Sits on top of WS-1 (the hijacked socket), WS-2 (the validated handshake),
11
+ # and WS-3 (frame ser/de) and exposes a simple message-oriented API:
12
+ #
13
+ # ws = Hyperion::WebSocket::Connection.new(socket,
14
+ # buffered: env['hyperion.hijack_buffered'],
15
+ # subprotocol: env['hyperion.websocket.handshake'][2])
16
+ #
17
+ # loop do
18
+ # type, payload = ws.recv
19
+ # break if type == :close || type.nil?
20
+ # ws.send(payload, opcode: type) # echo
21
+ # end
22
+ #
23
+ # Responsibilities:
24
+ #
25
+ # * Continuation reassembly. The peer can split a single application
26
+ # message across many frames (`text` + `continuation`* + final `FIN=1`);
27
+ # `recv` only returns when the message is complete. Control frames
28
+ # (`ping`, `pong`, `close`) MAY be interleaved between fragments per
29
+ # RFC 6455 §5.4 — we handle them inline without disrupting the
30
+ # reassembly buffer.
31
+ #
32
+ # * Auto-pong. RFC 6455 §5.5.2 — server SHOULD reply to a ping with a
33
+ # pong carrying the same payload. The default behaviour fires the
34
+ # pong before returning control to the caller; `on_ping` lets the app
35
+ # observe the event but does NOT replace the auto-response (the
36
+ # server stays compliant even if the app's hook does nothing).
37
+ #
38
+ # * Close handshake. Either side initiating a close gets the
39
+ # bidirectional shutdown right: an inbound close triggers an outbound
40
+ # close echo (RFC 6455 §5.5.1) and `recv` returns
41
+ # `[:close, code, reason]`; calling `close(code: 1000)` writes our
42
+ # close frame and waits up to `drain_timeout` seconds for the peer's
43
+ # matching close before tearing down the socket.
44
+ #
45
+ # * Per-message size cap. `max_message_bytes` (default 1 MiB) bounds
46
+ # the reassembly buffer; the moment a continuation frame would push
47
+ # the running total past the cap we send close 1009 (Message Too Big)
48
+ # and surface the close to the caller.
49
+ #
50
+ # * UTF-8 validation. Text frames whose payload isn't valid UTF-8 trip
51
+ # close 1007 (Invalid Frame Payload Data) per RFC 6455 §8.1.
52
+ #
53
+ # Things deliberately NOT in this class (deferred to 2.1.x):
54
+ #
55
+ # * permessage-deflate (RFC 7692). The handshake-time negotiation
56
+ # would live in WS-2 and the per-frame compression here; out of scope
57
+ # for 2.1.0.
58
+ # * Send-side fragmentation. `send` writes a single FIN=1 frame
59
+ # regardless of payload size. Browsers / well-behaved clients have
60
+ # no trouble with multi-MB single frames; if a use case shows up we
61
+ # can add an opt-in `fragment_threshold:` later.
62
+ # * Backpressure / outbound queueing. Writes are synchronous on the
63
+ # caller's thread; `socket.write` blocks if the kernel buffer is full.
64
+ # The `IO.select`-based read loop already cooperates with async-io
65
+ # when a fiber scheduler is installed (Ruby 3.3 redirects `select`
66
+ # automatically), so the recv side is fiber-friendly out of the box.
67
+ module WebSocket
68
+ # Raised when the peer (or our own code) does something the protocol
69
+ # forbids. Translated to a close frame with the right RFC 6455 §7.4
70
+ # code before the recv loop tears the connection down.
71
+ class StateError < StandardError; end
72
+
73
+ # The 16 KB read chunk size matches what Hyperion::Connection uses
74
+ # for HTTP/1.1 — small enough to keep memory pressure low under
75
+ # many idle WS connections, big enough that a 1 MiB message
76
+ # arrives in ~64 syscalls.
77
+ READ_CHUNK_BYTES = 16 * 1024
78
+
79
+ # 2.3-C — RFC 7692 §7.2.1 sync trailer. The 4-byte deflate-block
80
+ # terminator that the deflater emits between messages and the
81
+ # inflater needs prepended back. Frozen so the per-frame strip /
82
+ # append paths share one constant rather than allocating a fresh
83
+ # Array of bytes each time.
84
+ DEFLATE_SYNC_TRAILER = "\x00\x00\xff\xff".b.freeze
85
+
86
+ # RFC 6455 §7.4.1 close codes we emit. The peer is free to send any
87
+ # registered code; we surface their integer verbatim in `recv`.
88
+ CLOSE_NORMAL = 1000
89
+ CLOSE_GOING_AWAY = 1001
90
+ CLOSE_PROTOCOL_ERROR = 1002
91
+ CLOSE_UNSUPPORTED = 1003
92
+ CLOSE_INVALID_PAYLOAD = 1007
93
+ CLOSE_POLICY = 1008
94
+ CLOSE_MESSAGE_TOO_BIG = 1009
95
+ CLOSE_INTERNAL_ERROR = 1011
96
+
97
+ class Connection
98
+ attr_reader :subprotocol, :max_message_bytes, :state, :close_code, :close_reason
99
+ # 2.9-C — count of route-label resolutions for this Connection.
100
+ # Bumped once at construction; per-message observation paths must
101
+ # never touch this. Specs use it to guard against per-msg leaks.
102
+ attr_reader :route_resolutions
103
+
104
+ # socket — IO returned by env['rack.hijack'].call. The
105
+ # connection assumes ownership; closing the
106
+ # Hyperion::WebSocket::Connection closes the
107
+ # underlying socket.
108
+ # buffered — bytes already pulled off the socket by the
109
+ # HTTP parser past the request boundary
110
+ # (env['hyperion.hijack_buffered']). Prepended
111
+ # to the read buffer before the first syscall.
112
+ # subprotocol — the negotiated subprotocol from the handshake
113
+ # (slot 2 of the [:ok, accept, sub] tuple) or nil.
114
+ # max_message_bytes — cap on a single reassembled message. Default 1 MiB.
115
+ # For permessage-deflate the cap is applied to the
116
+ # DECOMPRESSED size — a tiny compressed payload that
117
+ # inflates beyond the cap closes 1009 (compression
118
+ # bomb defense, RFC 7692 §8.1).
119
+ # ping_interval — seconds between proactive server pings. nil = off.
120
+ # idle_timeout — seconds of no traffic before we send a close.
121
+ # nil = off. Defaults to 60s; set higher for
122
+ # long-lived idle clients (chat presence, etc.).
123
+ # extensions — Hash from `Handshake.validate`'s 4th slot. When
124
+ # `permessage_deflate:` is present the connection
125
+ # instantiates a per-conn Zlib::Deflate / Inflate
126
+ # pair sized to the negotiated window bits, and
127
+ # sets RSV1 on outbound text/binary frames. `{}`
128
+ # (default) means no compression.
129
+ # env — the Rack env at handshake time. Used by 2.9-C to
130
+ # derive a low-cardinality `route` label for the
131
+ # permessage-deflate ratio histogram. Resolution
132
+ # order: explicit `env['hyperion.websocket.route']`
133
+ # (operator-named channel) → templated PATH_INFO
134
+ # via `Hyperion::Metrics.default_path_templater`.
135
+ # Resolved exactly once at construction; cached as
136
+ # a frozen one-element labels Array so the per-
137
+ # message observation is allocation-free.
138
+ # route — explicit route override, mostly for unit tests
139
+ # that don't have an `env`. Wins over `env`.
140
+ # path_templater — per-conn templater override (specs); falls back
141
+ # to `Hyperion::Metrics.default_path_templater`.
142
+ def initialize(socket, buffered: '', subprotocol: nil,
143
+ max_message_bytes: 1_048_576,
144
+ ping_interval: 30, idle_timeout: 60,
145
+ extensions: {}, env: nil, route: nil,
146
+ path_templater: nil)
147
+ @socket = socket
148
+ @subprotocol = subprotocol
149
+ @max_message_bytes = max_message_bytes
150
+ @ping_interval = ping_interval
151
+ @idle_timeout = idle_timeout
152
+
153
+ # 2.9-C — resolve the route label exactly once, here on the
154
+ # cold path; cache the frozen labels tuple so every subsequent
155
+ # `observe_deflate_ratio` reuses it. Reading PATH_INFO + running
156
+ # the templater per outbound message would add a Hash lookup, a
157
+ # mutex acquire, and a regex chain to a path that fires once per
158
+ # frame on chat-shape workloads.
159
+ @route_resolutions = 0
160
+ @deflate_ratio_labels = resolve_route_labels(env: env, route: route,
161
+ path_templater: path_templater)
162
+
163
+ configure_permessage_deflate(extensions[:permessage_deflate])
164
+
165
+ @inbuf = String.new(capacity: READ_CHUNK_BYTES, encoding: Encoding::ASCII_8BIT)
166
+ @inbuf << buffered.to_s.b unless buffered.nil? || buffered.empty?
167
+ @offset = 0
168
+
169
+ # Reassembly state. @msg_opcode is the first frame's opcode (text
170
+ # or binary); @msg_buffer accumulates payload bytes across
171
+ # continuation frames until FIN=1.
172
+ @msg_opcode = nil
173
+ @msg_buffer = nil
174
+
175
+ @state = :open
176
+ @close_code = nil
177
+ @close_reason = nil
178
+
179
+ @on_ping = nil
180
+ @on_pong = nil
181
+ @on_close = nil
182
+
183
+ @last_traffic_at = monotonic_now
184
+ end
185
+
186
+ # Block until the next complete *application* message arrives.
187
+ # Returns:
188
+ # [:text, String] — opcode 0x1, UTF-8 validated
189
+ # [:binary, String] — opcode 0x2, binary
190
+ # [:close, Integer|nil, String|nil] — peer initiated close
191
+ # nil — socket EOF before a frame
192
+ #
193
+ # Raises StateError if called after a close has already been
194
+ # observed (the connection is single-shot for close-detection).
195
+ def recv
196
+ raise StateError, 'connection is closed' if @state == :closed
197
+ # If we've already observed a close frame, the next recv must
198
+ # raise — callers that want to clean up should check the
199
+ # previous return value.
200
+ raise StateError, 'close already received' if @state == :closing && @close_observed_by_caller
201
+
202
+ loop do
203
+ frame = next_frame
204
+ if frame.nil?
205
+ # Socket EOF without a clean close — treat as best-effort
206
+ # disconnect. The caller sees nil and stops looping.
207
+ mark_closed
208
+ return nil
209
+ end
210
+
211
+ # RFC 7692 §6.1: control frames MUST NOT have RSV1 set. The
212
+ # parser already errored on this case, but defense-in-depth
213
+ # — keeps us safe if someone hands us a custom frame source.
214
+ if frame.rsv1 && %i[ping pong close].include?(frame.opcode)
215
+ fail_close(CLOSE_PROTOCOL_ERROR, 'RSV1 set on control frame')
216
+ raise StateError, 'RSV1 set on control frame'
217
+ end
218
+
219
+ # RFC 7692 §6: RSV1 only allowed on data frames when the
220
+ # extension was negotiated. Without negotiation, any RSV1 is
221
+ # a protocol error — close 1002 and bail.
222
+ if frame.rsv1 && @inflater.nil?
223
+ fail_close(CLOSE_PROTOCOL_ERROR, 'RSV1 set without negotiated extension')
224
+ raise StateError, 'RSV1 set without negotiated extension'
225
+ end
226
+
227
+ case frame.opcode
228
+ when :ping
229
+ handle_ping(frame)
230
+ next
231
+ when :pong
232
+ handle_pong(frame)
233
+ next
234
+ when :close
235
+ return handle_close_frame(frame)
236
+ when :text, :binary
237
+ return nil if (msg = collect_data_frame(frame))&.then { return msg }
238
+ when :continuation
239
+ return nil if (msg = collect_data_frame(frame))&.then { return msg }
240
+ end
241
+ end
242
+ end
243
+
244
+ # Send an application message. opcode: :text (default) or :binary.
245
+ # Single-frame, FIN=1, server-side (unmasked). When permessage-
246
+ # deflate is active the payload is DEFLATE-compressed inline and
247
+ # the RSV1 bit is set on the frame; control frames (close/ping/
248
+ # pong) are NEVER compressed per RFC 7692 §6.1, even when the
249
+ # extension is active.
250
+ def send(payload, opcode: :text)
251
+ raise StateError, 'connection is closed' if @state == :closed
252
+ raise StateError, "cannot send while #{@state}" if @state != :open
253
+ unless %i[text binary].include?(opcode)
254
+ raise ArgumentError, "send opcode must be :text or :binary (got #{opcode.inspect})"
255
+ end
256
+
257
+ bin = opcode == :text ? payload.to_s.encode(Encoding::UTF_8).b : payload.to_s.b
258
+ rsv1 = false
259
+ if @deflater
260
+ bin = deflate_message(bin)
261
+ rsv1 = true
262
+ end
263
+ wire = Hyperion::WebSocket::Builder.build(opcode: opcode, payload: bin, rsv1: rsv1)
264
+ write_wire(wire)
265
+ @last_traffic_at = monotonic_now
266
+ true
267
+ end
268
+
269
+ # Hooks fired AFTER the built-in protocol behaviour. Auto-pong
270
+ # still happens regardless of whether on_ping is registered;
271
+ # close-frame echo still happens regardless of on_close. The hooks
272
+ # are observation points, not behaviour overrides.
273
+ def on_ping(&block) = @on_ping = block
274
+ def on_pong(&block) = @on_pong = block
275
+ def on_close(&block) = @on_close = block
276
+
277
+ # Initiate a graceful close. Sends a close frame with the given
278
+ # code (default 1000) and reason, then drains until either the
279
+ # peer's close arrives or `drain_timeout` seconds pass. Closes
280
+ # the socket either way. Idempotent — calling close twice is a
281
+ # no-op on the second call.
282
+ def close(code: CLOSE_NORMAL, reason: '', drain_timeout: 5)
283
+ return if @state == :closed
284
+
285
+ if @state == :open
286
+ send_close_frame(code, reason)
287
+ @state = :closing
288
+ end
289
+
290
+ # Drain inbound until we see the peer's close (or timeout).
291
+ drain_for_close(drain_timeout)
292
+ mark_closed
293
+ end
294
+
295
+ def open? = @state == :open
296
+ def closing? = @state == :closing
297
+ def closed? = @state == :closed
298
+
299
+ private
300
+
301
+ def monotonic_now
302
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
303
+ end
304
+
305
+ # Return a parsed Hyperion::WebSocket::Frame, or nil on socket EOF.
306
+ # Reads from the socket into @inbuf as needed; the frame parser
307
+ # advances @offset by frame_total_len when a complete frame is
308
+ # available. Compacts @inbuf when @offset gets large to keep
309
+ # memory bounded under long-lived connections.
310
+ def next_frame
311
+ loop do
312
+ if @offset >= @inbuf.bytesize
313
+ # Cheap path — buffer fully consumed, no need to keep the
314
+ # spent prefix around.
315
+ @inbuf.clear
316
+ @offset = 0
317
+ end
318
+
319
+ if @inbuf.bytesize > @offset
320
+ begin
321
+ result = Hyperion::WebSocket::Parser.parse_with_cursor(@inbuf, @offset)
322
+ rescue Hyperion::WebSocket::ProtocolError => e
323
+ # RFC 6455 §7.4.1 — 1002 covers protocol-level errors
324
+ # (bad opcode, RSV bits set, fragmented control, etc.).
325
+ fail_close(CLOSE_PROTOCOL_ERROR, e.message)
326
+ raise StateError, "protocol error: #{e.message}"
327
+ end
328
+
329
+ if result != :incomplete
330
+ frame, advance = result
331
+ @offset += advance
332
+ @last_traffic_at = monotonic_now
333
+ # Compact the buffer if we've accumulated a lot of
334
+ # consumed bytes. Threshold: half the message cap is a
335
+ # reasonable wash between "compact too often" and
336
+ # "carry too much".
337
+ compact_inbuf_if_needed
338
+ return frame
339
+ end
340
+ end
341
+
342
+ # Need more bytes. Block on the socket with idle/ping
343
+ # supervision.
344
+ got = read_more
345
+ return nil if got.nil?
346
+ end
347
+ end
348
+
349
+ def compact_inbuf_if_needed
350
+ return if @offset.zero?
351
+ return if @offset < (@max_message_bytes / 2) && @offset < (READ_CHUNK_BYTES * 4)
352
+
353
+ @inbuf = @inbuf.byteslice(@offset, @inbuf.bytesize - @offset).b
354
+ @offset = 0
355
+ end
356
+
357
+ # Read up to READ_CHUNK_BYTES more bytes into @inbuf. Returns the
358
+ # number of bytes appended, or nil on EOF/idle-timeout. Blocks
359
+ # cooperatively (IO.select redirects to the fiber scheduler under
360
+ # async-io).
361
+ def read_more
362
+ timeout = next_read_timeout
363
+ ready, = IO.select([@socket], nil, nil, timeout)
364
+ if ready.nil?
365
+ # Timeout fired. Decide whether to ping or to close-1001.
366
+ handle_idle_timeout
367
+ return 0 if @state == :open
368
+
369
+ return nil
370
+ end
371
+
372
+ chunk =
373
+ begin
374
+ @socket.read_nonblock(READ_CHUNK_BYTES, exception: false)
375
+ rescue EOFError, Errno::ECONNRESET, IOError
376
+ nil
377
+ end
378
+
379
+ case chunk
380
+ when nil
381
+ nil
382
+ when :wait_readable
383
+ # Spurious wakeup from select — try again next loop.
384
+ 0
385
+ else
386
+ @inbuf << chunk.b
387
+ chunk.bytesize
388
+ end
389
+ end
390
+
391
+ # Smaller of (idle_timeout - elapsed) and (ping_interval - elapsed).
392
+ # Returns nil to mean "no timeout" when both are nil.
393
+ def next_read_timeout
394
+ candidates = []
395
+ candidates << [@idle_timeout - (monotonic_now - @last_traffic_at), 0].max if @idle_timeout
396
+ if @ping_interval
397
+ @last_proactive_ping_at ||= @last_traffic_at
398
+ candidates << [@ping_interval - (monotonic_now - @last_proactive_ping_at), 0].max
399
+ end
400
+ return nil if candidates.empty?
401
+
402
+ candidates.min
403
+ end
404
+
405
+ def handle_idle_timeout
406
+ return unless @state == :open
407
+
408
+ elapsed = monotonic_now - @last_traffic_at
409
+ if @idle_timeout && elapsed >= @idle_timeout
410
+ # No traffic in idle_timeout seconds — close 1001 (going
411
+ # away) and let the recv loop unwind.
412
+ send_close_frame(CLOSE_GOING_AWAY, 'idle timeout')
413
+ @state = :closing
414
+ @close_code = CLOSE_GOING_AWAY
415
+ @close_reason = 'idle timeout'
416
+ return
417
+ end
418
+
419
+ return unless @ping_interval && (monotonic_now - (@last_proactive_ping_at || 0)) >= @ping_interval
420
+
421
+ # Proactive keep-alive ping. The peer's pong refreshes
422
+ # @last_traffic_at and resets the idle countdown.
423
+ send_ping_frame('hyperion-keepalive'.b)
424
+ @last_proactive_ping_at = monotonic_now
425
+ end
426
+
427
+ def handle_ping(frame)
428
+ # RFC 6455 §5.5.2 — server SHOULD reply with pong carrying the
429
+ # ping's payload (control frame, ≤125 bytes by §5.5).
430
+ wire = Hyperion::WebSocket::Builder.build(opcode: :pong, payload: frame.payload)
431
+ write_wire(wire)
432
+ @on_ping&.call(frame.payload)
433
+ end
434
+
435
+ def handle_pong(frame)
436
+ @on_pong&.call(frame.payload)
437
+ end
438
+
439
+ # Decode the close frame body — RFC 6455 §5.5.1 + §7.4.1 — and
440
+ # return `[:close, code, reason]`. The peer's code is validated
441
+ # against the IANA close-code registry; an invalid code (out of
442
+ # range, reserved, or synthetic-only) gets a 1002 (Protocol
443
+ # Error) response back, NOT an echo. An invalid-UTF-8 reason
444
+ # gets a 1007 (Invalid Frame Payload Data) response. A 1-byte
445
+ # payload (status code can't fit) gets a 1002.
446
+ #
447
+ # Either way the socket moves to :closing and `recv` returns
448
+ # the [:close, ...] tuple; the caller's next recv raises StateError.
449
+ def handle_close_frame(frame)
450
+ verdict, code, reason = parse_and_validate_close(frame.payload)
451
+ @close_code = code
452
+ @close_reason = reason
453
+
454
+ if @state == :open
455
+ response_code, response_reason = close_response_for(verdict, code, reason)
456
+ send_close_frame(response_code, response_reason)
457
+ @state = :closing
458
+ end
459
+ @on_close&.call(code, reason)
460
+
461
+ @close_observed_by_caller = true
462
+ [:close, code, reason]
463
+ end
464
+
465
+ # Returns [verdict, code, reason] where verdict is one of:
466
+ # :ok — clean close, echo peer's code back
467
+ # :payload_too_short — 1-byte payload (status code can't fit) → 1002
468
+ # :invalid_utf8 — reason bytes are not valid UTF-8 → 1007
469
+ # :invalid_code — close code outside any defined range → 1002
470
+ # :reserved — close code in IETF reserved range → 1002
471
+ # :no_status_on_wire — close code is 1005 / 1006 → 1002
472
+ #
473
+ # `code` is the peer-supplied integer (or nil for an empty
474
+ # payload, which is the "no status, no reason" graceful close
475
+ # explicitly permitted by RFC 6455 §5.5.1) and `reason` is the
476
+ # decoded text (scrubbed if it wasn't valid UTF-8).
477
+ def parse_and_validate_close(payload)
478
+ bin = payload.b
479
+ return [:ok, nil, nil] if bin.bytesize.zero?
480
+ return [:payload_too_short, nil, ''] if bin.bytesize == 1
481
+
482
+ code = (bin.getbyte(0) << 8) | bin.getbyte(1)
483
+ reason_bytes = bin.bytesize > 2 ? bin.byteslice(2, bin.bytesize - 2) : ''.b
484
+ reason = reason_bytes.dup.force_encoding(Encoding::UTF_8)
485
+
486
+ unless reason.valid_encoding?
487
+ # RFC 6455 §8.1 — reason text must be valid UTF-8. Surface
488
+ # the scrubbed reason for `@close_reason`/`on_close` so the
489
+ # operator can still read it; the on-wire response is 1007.
490
+ return [:invalid_utf8, code, reason.scrub('?')]
491
+ end
492
+
493
+ case Hyperion::WebSocket::CloseCodes.validate(code)
494
+ when :ok then [:ok, code, reason]
495
+ when :no_status_on_wire then [:no_status_on_wire, code, reason]
496
+ when :reserved then [:reserved, code, reason]
497
+ else [:invalid_code, code, reason]
498
+ end
499
+ end
500
+
501
+ # Map a parse-and-validate verdict onto the close-frame we send back.
502
+ # For :ok we echo the peer's code (and an empty reason — the spec
503
+ # doesn't require us to echo their reason text); everything else
504
+ # is a protocol-violation response.
505
+ def close_response_for(verdict, code, _reason)
506
+ case verdict
507
+ when :ok then [code || CLOSE_NORMAL, '']
508
+ when :invalid_utf8 then [CLOSE_INVALID_PAYLOAD, 'invalid utf-8 in close reason']
509
+ when :invalid_code then [CLOSE_PROTOCOL_ERROR, 'invalid close code']
510
+ when :reserved then [CLOSE_PROTOCOL_ERROR, 'reserved close code']
511
+ when :no_status_on_wire then [CLOSE_PROTOCOL_ERROR, 'close code MUST NOT appear on wire']
512
+ when :payload_too_short then [CLOSE_PROTOCOL_ERROR, 'close payload < 2 bytes']
513
+ else [CLOSE_PROTOCOL_ERROR, 'invalid close frame']
514
+ end
515
+ end
516
+
517
+ # Legacy shim retained for `drain_for_close` — the drain path only
518
+ # cares about the (code, reason) pair so it can stash them on
519
+ # `@close_code` / `@close_reason`. Validation is irrelevant during
520
+ # drain since we're already mid-close.
521
+ def parse_close_payload(payload)
522
+ _, code, reason = parse_and_validate_close(payload)
523
+ [code, reason]
524
+ end
525
+
526
+ # Accumulate a data frame into @msg_buffer. Returns the
527
+ # `[type, payload]` 2-tuple when the message completes (FIN=1),
528
+ # otherwise returns `nil` so the recv loop continues.
529
+ def collect_data_frame(frame)
530
+ if frame.opcode == :continuation
531
+ if @msg_opcode.nil?
532
+ fail_close(CLOSE_PROTOCOL_ERROR, 'continuation without start')
533
+ raise StateError, 'continuation without start'
534
+ end
535
+ # RFC 7692 §6: RSV1 must be 0 on continuation frames; the
536
+ # compressed marker only sits on the first fragment.
537
+ if frame.rsv1
538
+ fail_close(CLOSE_PROTOCOL_ERROR, 'RSV1 set on continuation frame')
539
+ raise StateError, 'RSV1 set on continuation frame'
540
+ end
541
+ else
542
+ if @msg_opcode
543
+ fail_close(CLOSE_PROTOCOL_ERROR, 'new data frame mid-message')
544
+ raise StateError, 'new data frame mid-message'
545
+ end
546
+ @msg_opcode = frame.opcode
547
+ @msg_compressed = frame.rsv1
548
+ @msg_buffer = String.new(capacity: frame.payload.bytesize, encoding: Encoding::ASCII_8BIT)
549
+ end
550
+
551
+ # Wire-side cap. For uncompressed messages the wire size IS the
552
+ # message size; the same cap applies. For compressed messages we
553
+ # apply a generous separate cap (8× max_message_bytes) so a
554
+ # legitimate compressible message still squeezes through; the
555
+ # post-decompress cap below is the real defense.
556
+ wire_cap = @msg_compressed ? @max_message_bytes * 8 : @max_message_bytes
557
+ new_total = @msg_buffer.bytesize + frame.payload.bytesize
558
+ if new_total > wire_cap
559
+ fail_close(CLOSE_MESSAGE_TOO_BIG, "message exceeds #{@max_message_bytes} bytes")
560
+ @close_code = CLOSE_MESSAGE_TOO_BIG
561
+ @close_reason = 'message too big'
562
+ @close_observed_by_caller = true
563
+ return [:close, CLOSE_MESSAGE_TOO_BIG, 'message too big']
564
+ end
565
+
566
+ @msg_buffer << frame.payload.b
567
+
568
+ return nil unless frame.fin
569
+
570
+ type = @msg_opcode
571
+ payload = @msg_buffer
572
+ compressed = @msg_compressed
573
+ @msg_opcode = nil
574
+ @msg_compressed = false
575
+ @msg_buffer = nil
576
+
577
+ if compressed
578
+ payload = inflate_message(payload)
579
+ # Compression-bomb defense: the inflated size is the actual
580
+ # application payload size, and that's what `max_message_bytes`
581
+ # bounds. RFC 7692 §8.1 — implementations MUST defend against
582
+ # malicious senders that compress to a tiny wire payload that
583
+ # explodes on decompression.
584
+ if payload.is_a?(Symbol) && payload == :too_big
585
+ fail_close(CLOSE_MESSAGE_TOO_BIG,
586
+ "decompressed message exceeds #{@max_message_bytes} bytes")
587
+ @close_code = CLOSE_MESSAGE_TOO_BIG
588
+ @close_reason = 'compressed bomb'
589
+ @close_observed_by_caller = true
590
+ return [:close, CLOSE_MESSAGE_TOO_BIG, 'compressed bomb']
591
+ end
592
+ if payload.is_a?(Symbol) && payload == :inflate_error
593
+ fail_close(CLOSE_INVALID_PAYLOAD, 'invalid deflate payload')
594
+ @close_code = CLOSE_INVALID_PAYLOAD
595
+ @close_reason = 'inflate error'
596
+ @close_observed_by_caller = true
597
+ return [:close, CLOSE_INVALID_PAYLOAD, 'inflate error']
598
+ end
599
+ end
600
+
601
+ if type == :text
602
+ payload.force_encoding(Encoding::UTF_8)
603
+ unless payload.valid_encoding?
604
+ fail_close(CLOSE_INVALID_PAYLOAD, 'invalid UTF-8 in text frame')
605
+ @close_code = CLOSE_INVALID_PAYLOAD
606
+ @close_reason = 'invalid utf-8'
607
+ @close_observed_by_caller = true
608
+ return [:close, CLOSE_INVALID_PAYLOAD, 'invalid utf-8']
609
+ end
610
+ end
611
+
612
+ [type, payload]
613
+ end
614
+
615
+ def send_close_frame(code, reason)
616
+ body = String.new(encoding: Encoding::ASCII_8BIT)
617
+ if code
618
+ body << ((code >> 8) & 0xFF).chr
619
+ body << (code & 0xFF).chr
620
+ body << reason.to_s.b unless reason.nil? || reason.empty?
621
+ end
622
+ # Control-frame cap: 125 bytes. Truncate the reason rather than
623
+ # raise — operators don't want a long error message to take
624
+ # down a close path.
625
+ body = body.byteslice(0, 125) if body.bytesize > 125
626
+ wire = Hyperion::WebSocket::Builder.build(opcode: :close, payload: body)
627
+ write_wire(wire)
628
+ end
629
+
630
+ def send_ping_frame(payload)
631
+ wire = Hyperion::WebSocket::Builder.build(opcode: :ping, payload: payload)
632
+ write_wire(wire)
633
+ end
634
+
635
+ # Used after we detected a fatal protocol error — write a close
636
+ # frame, mark the connection :closing, but DON'T tear down the
637
+ # socket here. The recv loop already raises StateError; the
638
+ # caller's ensure block can `close` to flush.
639
+ def fail_close(code, reason)
640
+ send_close_frame(code, reason) if @state == :open
641
+ @state = :closing
642
+ @close_code = code
643
+ @close_reason = reason
644
+ end
645
+
646
+ def drain_for_close(drain_timeout)
647
+ deadline = monotonic_now + (drain_timeout || 0)
648
+ while @state == :closing && (drain_timeout.nil? || monotonic_now < deadline)
649
+ remaining = drain_timeout ? [deadline - monotonic_now, 0].max : nil
650
+ ready, = IO.select([@socket], nil, nil, remaining)
651
+ break unless ready
652
+
653
+ chunk =
654
+ begin
655
+ @socket.read_nonblock(READ_CHUNK_BYTES, exception: false)
656
+ rescue EOFError, Errno::ECONNRESET, IOError
657
+ nil
658
+ end
659
+ break if chunk.nil?
660
+ next if chunk == :wait_readable
661
+
662
+ @inbuf << chunk.b
663
+ # Drain any whole frames available; we're looking for the
664
+ # peer's close ack.
665
+ loop do
666
+ break if @offset >= @inbuf.bytesize
667
+
668
+ result =
669
+ begin
670
+ Hyperion::WebSocket::Parser.parse_with_cursor(@inbuf, @offset)
671
+ rescue Hyperion::WebSocket::ProtocolError
672
+ # Bail on protocol error during drain — we're closing anyway.
673
+ @offset = @inbuf.bytesize
674
+ break
675
+ end
676
+ break if result == :incomplete
677
+
678
+ frame, advance = result
679
+ @offset += advance
680
+ next unless frame.opcode == :close
681
+
682
+ code, reason = parse_close_payload(frame.payload)
683
+ @close_code ||= code
684
+ @close_reason ||= reason
685
+ return
686
+ end
687
+ end
688
+ end
689
+
690
+ def write_wire(wire)
691
+ @socket.write(wire)
692
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
693
+ mark_closed
694
+ end
695
+
696
+ def mark_closed
697
+ return if @state == :closed
698
+
699
+ begin
700
+ @socket.close
701
+ rescue IOError, Errno::EBADF
702
+ # Already closed; that's fine.
703
+ end
704
+ @state = :closed
705
+ end
706
+
707
+ # ---- 2.3-C permessage-deflate helpers ---------------------------
708
+
709
+ # Set up the per-connection deflater + inflater pair when the
710
+ # handshake negotiated permessage-deflate. With no params hash
711
+ # (extension not negotiated) this is a no-op and the connection
712
+ # behaves identically to 2.2.0.
713
+ def configure_permessage_deflate(params)
714
+ @deflater = nil
715
+ @inflater = nil
716
+ @server_no_takeover = false
717
+ @client_no_takeover = false
718
+ return if params.nil?
719
+
720
+ # RFC 7692 §7.1.2 — server-side deflater uses
721
+ # server_max_window_bits; inflater (decompressing client→server)
722
+ # uses client_max_window_bits.
723
+ server_bits = params[:server_max_window_bits] || 15
724
+ client_bits = params[:client_max_window_bits] || 15
725
+ @server_no_takeover = !!params[:server_no_context_takeover]
726
+ @client_no_takeover = !!params[:client_no_context_takeover]
727
+
728
+ # Negative window_bits → raw deflate (no zlib header). RFC 7692
729
+ # is built on raw DEFLATE per §7.2.1.
730
+ @deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -server_bits)
731
+ @inflater = Zlib::Inflate.new(-client_bits)
732
+ end
733
+
734
+ # Compress one message with SYNC_FLUSH and strip the trailing
735
+ # `\x00\x00\xff\xff` per RFC 7692 §7.2.1. Resets the deflater
736
+ # context if `server_no_context_takeover` was negotiated.
737
+ def deflate_message(bin)
738
+ original_size = bin.bytesize
739
+ compressed = @deflater.deflate(bin, Zlib::SYNC_FLUSH)
740
+ # The 4-byte trailer is the last 4 bytes of every SYNC_FLUSH
741
+ # output; strip exactly once. If the deflater somehow produced
742
+ # output shorter than 4 bytes (degenerate empty-message case),
743
+ # leave it alone — the inflater appends the trailer back so a
744
+ # missing one would just be re-added.
745
+ if compressed.bytesize >= 4 && compressed.byteslice(-4, 4) == DEFLATE_SYNC_TRAILER
746
+ compressed = compressed.byteslice(0, compressed.bytesize - 4)
747
+ end
748
+ @deflater.reset if @server_no_takeover
749
+ # 2.4-C: observe the compression ratio so operators can confirm
750
+ # permessage-deflate is worth its CPU cost on real chat traffic.
751
+ # Skip the observation when the compressed payload is too small
752
+ # to give a meaningful ratio (degenerate empty-message case) —
753
+ # the histogram bucket layout starts at 1.5×, anything below
754
+ # that is noise.
755
+ observe_deflate_ratio(original_size, compressed.bytesize)
756
+ compressed
757
+ end
758
+
759
+ DEFLATE_RATIO_HISTOGRAM = :hyperion_websocket_deflate_ratio
760
+ DEFLATE_RATIO_BUCKETS = [1.5, 2.0, 5.0, 10.0, 20.0, 50.0].freeze
761
+ # 2.9-C — `route` label split. Existing aggregate dashboards keep
762
+ # working: `sum without (route) (rate(...))` recombines the buckets.
763
+ DEFLATE_RATIO_LABEL_KEYS = %w[route].freeze
764
+ # 2.9-C — default label tuple for connections that didn't supply an
765
+ # `env` / `route` at construction (specs, library users that build
766
+ # a Connection by hand). Frozen so every such conn shares one ref
767
+ # and the observation path stays allocation-free.
768
+ UNROUTED_LABELS = ['unrouted'].freeze
769
+
770
+ def observe_deflate_ratio(original_size, compressed_size)
771
+ return if compressed_size <= 0 || original_size <= 0
772
+
773
+ # Lazy-register the family on the active runtime's metrics sink.
774
+ # Idempotent — re-registration with the same shape is a no-op.
775
+ metrics = Hyperion.metrics
776
+ metrics.register_histogram(DEFLATE_RATIO_HISTOGRAM,
777
+ buckets: DEFLATE_RATIO_BUCKETS,
778
+ label_keys: DEFLATE_RATIO_LABEL_KEYS)
779
+ ratio = original_size.to_f / compressed_size
780
+ # @deflate_ratio_labels is the per-conn cached frozen labels
781
+ # tuple — observe with it directly; no per-message allocation.
782
+ metrics.observe_histogram(DEFLATE_RATIO_HISTOGRAM, ratio,
783
+ @deflate_ratio_labels)
784
+ rescue StandardError
785
+ nil
786
+ end
787
+
788
+ # 2.9-C — resolve the route label for this connection, exactly
789
+ # once at construction. Returns a frozen one-element Array so the
790
+ # per-message observation is allocation-free. Resolution order:
791
+ #
792
+ # 1. Explicit `route:` kwarg (test / library users)
793
+ # 2. `env['hyperion.websocket.route']` (operator-named channel)
794
+ # 3. `PathTemplater#template(env['PATH_INFO'])` (auto)
795
+ # 4. `'unrouted'` fallback
796
+ #
797
+ # Templater errors degrade silently to `'unrouted'` — observability
798
+ # must never block a Connection from booting.
799
+ def resolve_route_labels(env:, route:, path_templater:)
800
+ @route_resolutions += 1
801
+ resolved =
802
+ if route && !route.to_s.empty?
803
+ route.to_s
804
+ elsif env.is_a?(Hash) && (explicit = env['hyperion.websocket.route']) && !explicit.to_s.empty?
805
+ explicit.to_s
806
+ elsif env.is_a?(Hash) && (path = env['PATH_INFO']) && !path.to_s.empty?
807
+ templater = path_templater || Hyperion::Metrics.default_path_templater
808
+ templater.template(path.to_s)
809
+ else
810
+ 'unrouted'
811
+ end
812
+ [resolved.dup.freeze].freeze
813
+ rescue StandardError
814
+ UNROUTED_LABELS
815
+ end
816
+
817
+ # Inflate a compressed message. Appends the 4-byte sync trailer
818
+ # back per RFC 7692 §7.2.1 then runs `Zlib::Inflate#inflate`.
819
+ # Streams output in chunks bounded by `@max_message_bytes` so a
820
+ # 1 KB compressed payload that decompresses to 100 MB stops at
821
+ # the cap and returns `:too_big`. On Zlib::DataError returns
822
+ # `:inflate_error`.
823
+ def inflate_message(payload)
824
+ framed = String.new(capacity: payload.bytesize + 4, encoding: Encoding::ASCII_8BIT)
825
+ framed << payload.b
826
+ framed << DEFLATE_SYNC_TRAILER
827
+
828
+ out = String.new(encoding: Encoding::ASCII_8BIT)
829
+ cap = @max_message_bytes
830
+ too_big = false
831
+
832
+ begin
833
+ # Stream in 16 KB chunks so we can short-circuit a compression
834
+ # bomb without materializing the full inflated buffer first.
835
+ # Zlib::Inflate#inflate accepts a single full input; we feed
836
+ # the input in slices and read back after each — same effect.
837
+ offset = 0
838
+ chunk_size = 16 * 1024
839
+ while offset < framed.bytesize
840
+ slice = framed.byteslice(offset, chunk_size)
841
+ offset += slice.bytesize
842
+ piece = @inflater.inflate(slice)
843
+ next if piece.empty?
844
+
845
+ if out.bytesize + piece.bytesize > cap
846
+ too_big = true
847
+ break
848
+ end
849
+ out << piece
850
+ end
851
+
852
+ # Drain any remaining output Zlib has buffered.
853
+ unless too_big
854
+ tail = @inflater.flush_next_out
855
+ unless tail.nil? || tail.empty?
856
+ if out.bytesize + tail.bytesize > cap
857
+ too_big = true
858
+ else
859
+ out << tail
860
+ end
861
+ end
862
+ end
863
+ rescue Zlib::DataError, Zlib::BufError
864
+ @inflater.reset if @client_no_takeover
865
+ return :inflate_error
866
+ end
867
+
868
+ @inflater.reset if @client_no_takeover
869
+
870
+ return :too_big if too_big
871
+
872
+ out
873
+ end
874
+ end
875
+ end
876
+ end