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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4768 -0
- data/README.md +222 -13
- data/ext/hyperion_h2_codec/Cargo.lock +7 -0
- data/ext/hyperion_h2_codec/Cargo.toml +33 -0
- data/ext/hyperion_h2_codec/extconf.rb +73 -0
- data/ext/hyperion_h2_codec/src/frames.rs +140 -0
- data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
- data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
- data/ext/hyperion_h2_codec/src/lib.rs +296 -0
- data/ext/hyperion_http/extconf.rb +28 -0
- data/ext/hyperion_http/h2_codec_glue.c +408 -0
- data/ext/hyperion_http/page_cache.c +1125 -0
- data/ext/hyperion_http/parser.c +473 -38
- data/ext/hyperion_http/sendfile.c +982 -0
- data/ext/hyperion_http/websocket.c +493 -0
- data/ext/hyperion_io_uring/Cargo.lock +33 -0
- data/ext/hyperion_io_uring/Cargo.toml +34 -0
- data/ext/hyperion_io_uring/extconf.rb +74 -0
- data/ext/hyperion_io_uring/src/lib.rs +316 -0
- data/lib/hyperion/adapter/rack.rb +370 -42
- data/lib/hyperion/admin_listener.rb +207 -0
- data/lib/hyperion/admin_middleware.rb +36 -7
- data/lib/hyperion/cli.rb +310 -11
- data/lib/hyperion/config.rb +440 -14
- data/lib/hyperion/connection.rb +679 -22
- data/lib/hyperion/deprecations.rb +81 -0
- data/lib/hyperion/dispatch_mode.rb +165 -0
- data/lib/hyperion/fiber_local.rb +75 -13
- data/lib/hyperion/h2_admission.rb +77 -0
- data/lib/hyperion/h2_codec.rb +499 -0
- data/lib/hyperion/http/page_cache.rb +122 -0
- data/lib/hyperion/http/sendfile.rb +696 -0
- data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
- data/lib/hyperion/http2_handler.rb +618 -19
- data/lib/hyperion/io_uring.rb +317 -0
- data/lib/hyperion/lint_wrapper_pool.rb +126 -0
- data/lib/hyperion/master.rb +96 -9
- data/lib/hyperion/metrics/path_templater.rb +68 -0
- data/lib/hyperion/metrics.rb +256 -0
- data/lib/hyperion/prometheus_exporter.rb +150 -0
- data/lib/hyperion/request.rb +13 -0
- data/lib/hyperion/response_writer.rb +477 -16
- data/lib/hyperion/runtime.rb +195 -0
- data/lib/hyperion/server/route_table.rb +179 -0
- data/lib/hyperion/server.rb +519 -55
- data/lib/hyperion/static_preload.rb +133 -0
- data/lib/hyperion/thread_pool.rb +61 -7
- data/lib/hyperion/tls.rb +343 -1
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/websocket/close_codes.rb +71 -0
- data/lib/hyperion/websocket/connection.rb +876 -0
- data/lib/hyperion/websocket/frame.rb +356 -0
- data/lib/hyperion/websocket/handshake.rb +525 -0
- data/lib/hyperion/worker.rb +111 -9
- data/lib/hyperion.rb +137 -3
- 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
|