tina4ruby 3.13.36 → 3.13.38

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.
data/lib/tina4/session.rb CHANGED
@@ -21,7 +21,16 @@ module Tina4
21
21
  if !options.key?(:cookie_name) && env_name && !env_name.empty?
22
22
  @options[:cookie_name] = env_name
23
23
  end
24
- @options[:secret] ||= ENV["TINA4_SECRET"] || "tina4-default-secret"
24
+ # No guessable built-in secret. The session never signs with this value
25
+ # (IDs are SecureRandom.hex(32)), so we resolve it from TINA4_SECRET only
26
+ # — nil when unset. This honours the framework's blank-secret discipline
27
+ # (Auth.ensure_dev_secret never uses a guessable default); Python/Node
28
+ # sessions carry no secret field at all.
29
+ @options[:secret] ||= ENV["TINA4_SECRET"]
30
+ # Backend-failure policy strict flag (parity with Python's
31
+ # TINA4_SESSION_STRICT). When truthy, read/write/destroy/gc failures
32
+ # RE-RAISE instead of logging + degrading.
33
+ @strict = Tina4::Env.is_truthy(ENV["TINA4_SESSION_STRICT"])
25
34
  @handler = create_handler
26
35
  @id = extract_session_id(env) || SecureRandom.hex(32)
27
36
  @data = load_session
@@ -51,14 +60,23 @@ module Tina4
51
60
  @data.dup
52
61
  end
53
62
 
63
+ # Persist the session if dirty. On a backend write failure the error is
64
+ # logged and false is returned — the @modified (dirty) flag is RETAINED so
65
+ # a later save can retry. Returns true on a successful (or no-op) write.
54
66
  def save
55
- return unless @modified
56
- @handler.write(@id, @data)
57
- @modified = false
67
+ return true unless @modified
68
+ if safe_write(@id, @data)
69
+ @modified = false
70
+ true
71
+ else
72
+ false # dirty flag retained for retry
73
+ end
58
74
  end
59
75
 
76
+ # Destroy the current session. Should be called right after login or any
77
+ # privilege change to defend against session fixation (see #regenerate).
60
78
  def destroy
61
- @handler.destroy(@id)
79
+ safe_destroy(@id)
62
80
  @data = {}
63
81
  end
64
82
 
@@ -104,12 +122,17 @@ module Tina4
104
122
  result.nil? ? default : result
105
123
  end
106
124
 
107
- # Regenerate the session ID while preserving data — returns new ID
125
+ # Regenerate the session ID while preserving data — returns the new ID.
126
+ # Call this right after login or any privilege change to defend against
127
+ # session fixation (a pre-auth session ID must not survive into the
128
+ # authenticated session). Destroys the old backend record (best-effort)
129
+ # and persists under the new ID.
108
130
  def regenerate
109
131
  old_id = @id
110
132
  @id = SecureRandom.hex(32)
111
- @handler.destroy(old_id)
133
+ safe_destroy(old_id)
112
134
  @modified = true
135
+ save
113
136
  @id
114
137
  end
115
138
 
@@ -133,24 +156,27 @@ module Tina4
133
156
  end
134
157
 
135
158
  # Reads raw session data for a given session ID from backend storage.
136
- # Returns the data hash or nil.
159
+ # Returns the data hash, or {} on a backend failure (logged + degraded).
137
160
  def read(session_id)
138
- @handler.read(session_id)
161
+ safe_read(session_id)
139
162
  end
140
163
 
141
164
  # Writes raw session data for a given session ID to backend storage.
165
+ # Returns true on success, false on a backend failure (logged + degraded).
142
166
  def write(session_id, data, ttl = nil)
143
- if ttl
144
- @handler.write(session_id, data, ttl)
145
- else
146
- @handler.write(session_id, data)
147
- end
167
+ safe_write(session_id, data, ttl)
148
168
  end
149
169
 
150
- # Garbage collection: remove expired sessions from the handler
170
+ # Garbage collection: remove expired sessions from the handler.
171
+ # A backend failure is logged and swallowed (never crashes the request).
151
172
  def gc(max_lifetime = nil)
173
+ return unless @handler.respond_to?(:gc)
152
174
  max_lifetime ||= @options[:max_age]
153
- @handler.gc(max_lifetime) if @handler.respond_to?(:gc)
175
+ @handler.gc(max_lifetime)
176
+ rescue StandardError => e
177
+ log_backend_error("gc", e)
178
+ raise if @strict
179
+ nil
154
180
  end
155
181
 
156
182
  def cookie_header(cookie_name = nil)
@@ -181,8 +207,59 @@ module Tina4
181
207
  end
182
208
 
183
209
  def load_session
184
- existing = @handler.read(@id)
210
+ safe_read(@id)
211
+ end
212
+
213
+ # ── Backend-failure policy (parity with Python's Session boundary) ──
214
+ #
215
+ # Centralised here, NOT in each handler, so every backend (file, redis,
216
+ # valkey, mongo, database) shares one policy. The rule:
217
+ # read failure → log + return {} (empty session, never a 500)
218
+ # write failure → log + return false (caller retains dirty for retry)
219
+ # destroy failure → log + swallow (return false)
220
+ # gc failure → log + swallow (see #gc)
221
+ # A genuinely-empty but HEALTHY backend (handler returns nil/{} WITHOUT
222
+ # raising) is NOT a failure and logs nothing. TINA4_SESSION_STRICT=true
223
+ # re-raises instead of degrading.
224
+
225
+ def safe_read(session_id)
226
+ existing = @handler.read(session_id)
185
227
  existing || {}
228
+ rescue StandardError => e
229
+ log_backend_error("read", e)
230
+ raise if @strict
231
+ {}
232
+ end
233
+
234
+ def safe_write(session_id, data, ttl = nil)
235
+ if ttl
236
+ @handler.write(session_id, data, ttl)
237
+ else
238
+ @handler.write(session_id, data)
239
+ end
240
+ true
241
+ rescue StandardError => e
242
+ log_backend_error("write", e)
243
+ raise if @strict
244
+ false
245
+ end
246
+
247
+ def safe_destroy(session_id)
248
+ @handler.destroy(session_id)
249
+ true
250
+ rescue StandardError => e
251
+ log_backend_error("destroy", e)
252
+ raise if @strict
253
+ false
254
+ end
255
+
256
+ # Single source of the backend-failure log line. Names the operation and
257
+ # the concrete handler class so ops can see WHICH backend failed.
258
+ def log_backend_error(operation, error)
259
+ handler_class = @handler.class.name
260
+ Tina4::Log.error("Session #{operation} failed (#{handler_class}): #{error.message}")
261
+ rescue StandardError
262
+ warn("Session #{operation} failed: #{error.message}")
186
263
  end
187
264
 
188
265
  def create_handler
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.36"
4
+ VERSION = "3.13.38"
5
5
  end
@@ -4,15 +4,44 @@ require "digest"
4
4
  require "base64"
5
5
  require "json"
6
6
  require "set"
7
+ require "securerandom"
7
8
 
8
9
  module Tina4
9
10
  WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
10
11
 
12
+ # Shared pub/sub channel name + envelope shape for the WebSocket backplane.
13
+ # MUST stay byte-identical across all four frameworks (Python/PHP/Ruby/Node)
14
+ # so a broadcast published by one framework's instance is relayed by another.
15
+ WEBSOCKET_BACKPLANE_CHANNEL = "tina4:ws"
16
+
11
17
  # Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
12
18
  def self.compute_accept_key(key)
13
19
  Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
14
20
  end
15
21
 
22
+ # Return true if the request's Origin is permitted to upgrade to a WebSocket.
23
+ #
24
+ # Controlled by TINA4_WS_ALLOWED_ORIGINS (comma-separated exact origins, e.g.
25
+ # "https://app.example.com,https://admin.example.com").
26
+ #
27
+ # Empty/unset = allow ALL origins (current behaviour, non-breaking). When set,
28
+ # only requests whose Origin exactly matches a listed value are allowed; a
29
+ # missing Origin header is rejected once the allow-list is active.
30
+ #
31
+ # +headers+ is a Hash. The Origin is looked up case-insensitively across both
32
+ # the Rack-style "HTTP_ORIGIN" key and a plain "origin"/"Origin" key so the
33
+ # same helper serves the rack_app upgrade path and direct callers/tests.
34
+ def self.websocket_origin_allowed?(headers)
35
+ raw = (ENV["TINA4_WS_ALLOWED_ORIGINS"] || "").strip
36
+ return true if raw.empty? # No allow-list configured — permit everything.
37
+
38
+ allowed = raw.split(",").map(&:strip).reject(&:empty?)
39
+ return true if allowed.empty?
40
+
41
+ origin = headers["HTTP_ORIGIN"] || headers["origin"] || headers["Origin"]
42
+ allowed.include?(origin)
43
+ end
44
+
16
45
  # Build a WebSocket frame (server→client, never masked).
17
46
  def self.build_frame(opcode, data, fin: true)
18
47
  first_byte = (fin ? 0x80 : 0x00) | opcode
@@ -44,8 +73,24 @@ module Tina4
44
73
  error: []
45
74
  }
46
75
  @rooms = {} # room_name => Set of conn_ids
76
+
77
+ # ── Backplane (multi-instance scaling) ──────────────────────
78
+ # Lazily wired on first broadcast (see ensure_backplane). Each instance
79
+ # owns a stable id so it can ignore its own echoes coming back over the
80
+ # shared pub/sub channel (the origin guard). The backplane listener runs
81
+ # in its own Ruby thread, so the connections structure is guarded by a
82
+ # mutex shared with the broadcast path.
83
+ @backplane = nil
84
+ @backplane_started = false
85
+ @instance_id = SecureRandom.hex(8)
86
+ @backplane_channel = Tina4::WEBSOCKET_BACKPLANE_CHANNEL
87
+ @conn_mutex = Mutex.new
47
88
  end
48
89
 
90
+ # Stable per-process id used as the backplane envelope "src" so an instance
91
+ # drops its own echoes. Exposed for tests / introspection.
92
+ attr_reader :instance_id, :backplane_channel
93
+
49
94
  def on(event, &block)
50
95
  @handlers[event.to_sym] << block if @handlers.key?(event.to_sym)
51
96
  end
@@ -86,16 +131,31 @@ module Tina4
86
131
  end
87
132
 
88
133
  def broadcast(message, exclude: nil, path: nil)
89
- @connections.each do |id, conn|
90
- next if exclude && id == exclude
91
- next if path && conn.path != path
92
- conn.send_text(message)
134
+ ensure_backplane
135
+ targets = @conn_mutex.synchronize do
136
+ @connections.select do |id, conn|
137
+ !(exclude && id == exclude) && !(path && conn.path != path)
138
+ end.values
93
139
  end
140
+ deliver_resilient(targets, message)
141
+ publish_envelope(path ? "path" : "all", message, path: path, exclude: exclude)
142
+ end
143
+
144
+ # Send to ALL connections (no path filter). Resilient + backplane-fanned.
145
+ def broadcast_all(message, exclude: nil)
146
+ ensure_backplane
147
+ targets = @conn_mutex.synchronize do
148
+ @connections.reject { |id, _| exclude && id == exclude }.values
149
+ end
150
+ deliver_resilient(targets, message)
151
+ publish_envelope("all", message, exclude: exclude)
94
152
  end
95
153
 
96
154
  def send_to(conn_id, message)
97
- conn = @connections[conn_id]
98
- conn&.send_text(message)
155
+ conn = @conn_mutex.synchronize { @connections[conn_id] }
156
+ return unless conn
157
+
158
+ prune(conn) unless safe_send(conn, message)
99
159
  end
100
160
 
101
161
  def close(conn_id, code: 1000, reason: "")
@@ -136,12 +196,250 @@ module Tina4
136
196
  end
137
197
 
138
198
  def broadcast_to_room(room_name, message, exclude: nil)
139
- (get_room_connections(room_name)).each do |conn|
140
- next if exclude && conn.id == exclude
141
- conn.send_text(message)
199
+ ensure_backplane
200
+ targets = get_room_connections(room_name).reject { |conn| exclude && conn.id == exclude }
201
+ deliver_resilient(targets, message)
202
+ publish_envelope("room", message, room: room_name, exclude: exclude)
203
+ end
204
+
205
+ # Register an open connection on this manager (thread-safe).
206
+ def register_connection(connection)
207
+ @conn_mutex.synchronize { @connections[connection.id] = connection }
208
+ end
209
+
210
+ # Drop a connection on close (thread-safe) and remove it from all rooms.
211
+ def unregister_connection(conn_id)
212
+ @conn_mutex.synchronize { @connections.delete(conn_id) }
213
+ remove_from_all_rooms(conn_id)
214
+ end
215
+
216
+ # ── Broadcast resilience ──────────────────────────────────
217
+ #
218
+ # One dead/slow client must never abort delivery to the rest. deliver_resilient
219
+ # walks the target list, wraps each send, logs+prunes anything that throws,
220
+ # and keeps going.
221
+
222
+ # Send to ONE connection without letting a single dead client abort a
223
+ # broadcast loop. Returns true if delivered, false if the connection looks
224
+ # dead (the caller then prunes it). A failed send is logged, never silent.
225
+ # A connection whose own #send swallowed a write error and flipped #closed?
226
+ # is treated as dead too (mirrors Python's `return not ws._closed`).
227
+ def safe_send(conn, message)
228
+ conn.send_text(message)
229
+ # If the connection's own #send swallowed a write error it flips #closed?;
230
+ # treat that as dead. Probe defensively so a stub/double that doesn't
231
+ # define #closed? is simply treated as alive.
232
+ dead = begin
233
+ conn.respond_to?(:closed?) && conn.closed?
234
+ rescue StandardError
235
+ false
236
+ end
237
+ !dead
238
+ rescue StandardError => e
239
+ Tina4::Log.warning("WebSocket send to #{conn.id} failed, pruning: #{e.message}") if defined?(Tina4::Log)
240
+ false
241
+ end
242
+
243
+ # Deliver +message+ to every connection in +targets+, pruning any that fail.
244
+ def deliver_resilient(targets, message)
245
+ dead = targets.reject { |conn| safe_send(conn, message) }
246
+ dead.each { |conn| prune(conn) }
247
+ end
248
+
249
+ # Remove a (presumed dead) connection from the manager + all rooms.
250
+ def prune(conn)
251
+ id = conn.respond_to?(:id) ? conn.id : nil
252
+ return unless id
253
+
254
+ @conn_mutex.synchronize { @connections.delete(id) }
255
+ remove_from_all_rooms(id)
256
+ end
257
+
258
+ # ── Idle reaper ───────────────────────────────────────────
259
+ #
260
+ # Opt-in via TINA4_WS_IDLE_TIMEOUT (seconds; 0/unset = disabled = current
261
+ # behaviour). Tracks last_activity per connection (bumped on every inbound
262
+ # frame in handle_upgrade); reap_idle closes/prunes anything idle past the
263
+ # timeout.
264
+
265
+ # Close connections whose last inbound frame is older than +timeout+ seconds.
266
+ # Returns the number reaped. timeout <= 0 is a no-op. Connections without a
267
+ # last_activity are skipped.
268
+ def reap_idle(timeout)
269
+ return 0 if timeout.to_f <= 0
270
+
271
+ now = Time.now.to_f
272
+ stale = @conn_mutex.synchronize do
273
+ @connections.values.select do |conn|
274
+ la = conn.respond_to?(:last_activity) ? conn.last_activity : nil
275
+ la && (now - la) > timeout
276
+ end
277
+ end
278
+ stale.each do |conn|
279
+ conn.close(code: 1001, reason: "idle timeout") rescue nil
280
+ prune(conn)
281
+ end
282
+ Tina4::Log.info("WebSocket idle reaper closed #{stale.size} connection(s)") if stale.any? && defined?(Tina4::Log)
283
+ stale.size
284
+ end
285
+
286
+ # Read the configured idle timeout (seconds). 0 = disabled.
287
+ def idle_timeout
288
+ Float(ENV["TINA4_WS_IDLE_TIMEOUT"] || "0")
289
+ rescue ArgumentError, TypeError
290
+ 0.0
291
+ end
292
+
293
+ # Spin up a background reaper thread when an idle timeout is configured.
294
+ # Opt-in and non-breaking — unset/0 means no thread is created.
295
+ def start_idle_reaper
296
+ timeout = idle_timeout
297
+ return if timeout <= 0 || @reaper_thread&.alive?
298
+
299
+ interval = [1.0, timeout / 2.0].max
300
+ @reaper_running = true
301
+ @reaper_thread = Thread.new do
302
+ while @reaper_running
303
+ sleep interval
304
+ begin
305
+ reap_idle(timeout)
306
+ rescue StandardError => e
307
+ Tina4::Log.error("WebSocket idle reaper sweep failed: #{e.message}") if defined?(Tina4::Log)
308
+ end
309
+ end
310
+ end
311
+ end
312
+
313
+ def stop_idle_reaper
314
+ @reaper_running = false
315
+ @reaper_thread&.kill
316
+ @reaper_thread = nil
317
+ end
318
+
319
+ # ── Backplane (multi-instance scaling) ────────────────────
320
+ #
321
+ # When TINA4_WS_BACKPLANE is configured, every broadcast is ALSO published
322
+ # to a shared pub/sub channel so sibling instances relay it to their own
323
+ # local connections. Flow:
324
+ #
325
+ # instance A: broadcast → deliver locally → publish_envelope → channel
326
+ # │
327
+ # instance B: backplane bg-THREAD → on_backplane_message ─────────┘
328
+ # │ (origin guard drops A's echoes by src id)
329
+ # └→ relay_local → B's LOCAL connections only (no re-publish)
330
+ #
331
+ # The subscribe callback runs in the backplane's background Ruby thread, so
332
+ # it touches @connections under @conn_mutex (shared with the broadcast path).
333
+ # The relay NEVER re-publishes (that would loop the cluster).
334
+
335
+ # Lazily wire the configured backplane. Idempotent and best-effort — a
336
+ # failure logs and leaves the manager local-only; it must NEVER crash a
337
+ # broadcast.
338
+ def ensure_backplane
339
+ return if @backplane_started
340
+
341
+ # Set immediately so we only ever attempt the wiring once, even on failure
342
+ # (no retry storm on every broadcast).
343
+ @backplane_started = true
344
+ begin
345
+ backplane = Tina4::WebSocketBackplane.create_backplane
346
+ return if backplane.nil? # No backplane configured — stay local-only.
347
+
348
+ @backplane = backplane
349
+ @backplane.subscribe(@backplane_channel) { |raw| on_backplane_message(raw) }
350
+ Tina4::Log.info("WebSocket backplane active (instance #{@instance_id}, channel '#{@backplane_channel}')") if defined?(Tina4::Log)
351
+ rescue StandardError => e
352
+ @backplane = nil
353
+ Tina4::Log.error("WebSocket backplane wiring failed, continuing local-only: #{e.message}") if defined?(Tina4::Log)
354
+ end
355
+ end
356
+
357
+ # Receive a raw envelope from the backplane. Runs in the backplane's
358
+ # BACKGROUND THREAD.
359
+ def on_backplane_message(raw)
360
+ env = begin
361
+ JSON.parse(raw)
362
+ rescue JSON::ParserError, TypeError
363
+ return
364
+ end
365
+ return unless env.is_a?(Hash)
366
+
367
+ # Origin guard: ignore our own broadcasts echoed back over the channel.
368
+ # We already delivered them locally; relaying again would double-send.
369
+ return if env["src"] == @instance_id
370
+
371
+ relay_local(env)
372
+ end
373
+
374
+ # Deliver a remote-originated envelope to LOCAL connections only. NEVER
375
+ # re-publishes (that would loop the message around the cluster). Dispatches
376
+ # by "kind": room / path / all.
377
+ def relay_local(env)
378
+ message = decode_envelope_message(env)
379
+ return if message.nil?
380
+
381
+ exclude = env["exclude"]
382
+ targets =
383
+ case env["kind"]
384
+ when "room"
385
+ room = env["room"]
386
+ room ? get_room_connections(room) : []
387
+ when "path"
388
+ path = env["path"]
389
+ path ? @conn_mutex.synchronize { @connections.values.select { |c| c.path == path } } : []
390
+ else # "all" (and anything unknown) → every local connection
391
+ @conn_mutex.synchronize { @connections.values }
392
+ end
393
+ targets = targets.reject { |conn| exclude && conn.id == exclude }
394
+ deliver_resilient(targets, message)
395
+ end
396
+
397
+ # Publish a broadcast to the shared channel for sibling instances. No-op
398
+ # when no backplane is configured. Best-effort — a publish failure logs and
399
+ # is swallowed so the local broadcast that already happened is never undone
400
+ # by a flaky message bus.
401
+ def publish_envelope(kind, message, room: nil, path: nil, exclude: nil)
402
+ return unless @backplane
403
+
404
+ envelope = {
405
+ "src" => @instance_id,
406
+ "kind" => kind,
407
+ "exclude" => exclude,
408
+ "room" => room,
409
+ "path" => path
410
+ }
411
+ # JSON can't carry raw bytes — encode binary as base64, text as text.
412
+ if binary_payload?(message)
413
+ envelope["b64"] = Base64.strict_encode64(message.b)
414
+ else
415
+ envelope["text"] = message
416
+ end
417
+ begin
418
+ @backplane.publish(@backplane_channel, JSON.generate(envelope))
419
+ rescue StandardError => e
420
+ Tina4::Log.warning("WebSocket backplane publish failed: #{e.message}") if defined?(Tina4::Log)
142
421
  end
143
422
  end
144
423
 
424
+ # Reconstruct the original str/bytes message from an envelope. JSON can't
425
+ # carry bytes, so text → {"text": ...} and bytes → {"b64": base64(...)}.
426
+ def decode_envelope_message(env)
427
+ return env["text"] if env.key?("text")
428
+ return Base64.strict_decode64(env["b64"]).b if env.key?("b64")
429
+
430
+ nil
431
+ end
432
+
433
+ # A message is "binary" when it is an ASCII-8BIT (BINARY-encoded) string.
434
+ # Ruby has no separate bytes type, so a caller's choice of the BINARY
435
+ # encoding is the signal to treat the payload as bytes: it goes through
436
+ # base64 so it survives the JSON envelope AND arrives with its binary
437
+ # encoding intact on the relaying instance (a JSON "text" value would come
438
+ # back as UTF-8, silently re-encoding binary data).
439
+ def binary_payload?(message)
440
+ message.is_a?(String) && message.encoding == Encoding::ASCII_8BIT
441
+ end
442
+
145
443
  # Register a WebSocket handler for a path (class method, matching Python's
146
444
  # WebSocketServer.route). The block receives a WebSocketConnection and should
147
445
  # call conn.on_message / conn.on_close to wire up event callbacks.
@@ -172,10 +470,26 @@ module Tina4
172
470
  Tina4::Router.websocket(path, &adapter)
173
471
  end
174
472
 
175
- def handle_upgrade(env, socket)
473
+ # Upgrade a raw socket to a WebSocket connection and run its frame loop.
474
+ #
475
+ # +manager+ is the engine that should OWN the connection (its @connections /
476
+ # rooms / backplane / idle reaper). It defaults to +self+. In integrated
477
+ # (Rack) mode the rack_app passes a process-wide shared engine here so that
478
+ # broadcasts, rooms and the backplane span every route's connections even
479
+ # though each upgrade keeps its own isolated event handlers on +self+.
480
+ def handle_upgrade(env, socket, manager: self)
176
481
  key = env["HTTP_SEC_WEBSOCKET_KEY"]
177
482
  return unless key
178
483
 
484
+ # Origin allow-list (opt-in via TINA4_WS_ALLOWED_ORIGINS). Unset = allow
485
+ # all, so this never breaks an existing deployment. When set, an upgrade
486
+ # from a non-listed Origin is refused with a 403 before the handshake.
487
+ unless Tina4.websocket_origin_allowed?(env)
488
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n") rescue nil
489
+ socket.close rescue nil
490
+ return
491
+ end
492
+
179
493
  accept = Tina4.compute_accept_key(key)
180
494
 
181
495
  response = "HTTP/1.1 101 Switching Protocols\r\n" \
@@ -187,8 +501,12 @@ module Tina4
187
501
 
188
502
  conn_id = SecureRandom.hex(16)
189
503
  ws_path = env["REQUEST_PATH"] || env["PATH_INFO"] || "/"
190
- connection = WebSocketConnection.new(conn_id, socket, ws_server: self, path: ws_path)
191
- @connections[conn_id] = connection
504
+ connection = WebSocketConnection.new(conn_id, socket, ws_server: manager, path: ws_path)
505
+ manager.register_connection(connection)
506
+
507
+ # Start the idle reaper lazily once we actually have a connection (opt-in
508
+ # via TINA4_WS_IDLE_TIMEOUT; a no-op when unset/0).
509
+ manager.start_idle_reaper
192
510
 
193
511
  emit(:open, connection)
194
512
 
@@ -198,6 +516,8 @@ module Tina4
198
516
  frame = connection.read_frame
199
517
  break unless frame
200
518
 
519
+ connection.touch # mark activity for the idle reaper
520
+
201
521
  case frame[:opcode]
202
522
  when 0x1 # Text
203
523
  emit(:message, connection, frame[:data])
@@ -210,8 +530,7 @@ module Tina4
210
530
  rescue => e
211
531
  emit(:error, connection, e)
212
532
  ensure
213
- @connections.delete(conn_id)
214
- remove_from_all_rooms(conn_id)
533
+ manager.unregister_connection(conn_id)
215
534
  emit(:close, connection)
216
535
  socket.close rescue nil
217
536
  end
@@ -228,7 +547,7 @@ module Tina4
228
547
  end
229
548
 
230
549
  class WebSocketConnection
231
- attr_reader :id, :rooms
550
+ attr_reader :id, :rooms, :last_activity
232
551
  attr_accessor :params, :path, :on_message_handler, :on_close_handler, :on_error_handler
233
552
 
234
553
  def initialize(id, socket, ws_server: nil, path: "/")
@@ -241,6 +560,14 @@ module Tina4
241
560
  @on_message_handler = nil
242
561
  @on_close_handler = nil
243
562
  @on_error_handler = nil
563
+ # Updated on every inbound frame; the idle reaper closes connections that
564
+ # have been silent longer than TINA4_WS_IDLE_TIMEOUT (opt-in).
565
+ @last_activity = Time.now.to_f
566
+ end
567
+
568
+ # Mark inbound activity for the idle reaper.
569
+ def touch
570
+ @last_activity = Time.now.to_f
244
571
  end
245
572
 
246
573
  # Register a message handler (decorator style, matching Python).
@@ -281,12 +608,21 @@ module Tina4
281
608
  end
282
609
  end
283
610
 
611
+ # True once a write has failed (broken pipe / closed socket). The manager's
612
+ # resilient broadcast path uses this to prune dead connections.
613
+ def closed?
614
+ @closed == true
615
+ end
616
+
284
617
  def send(message)
285
- data = message.encode("UTF-8")
618
+ data = message.is_a?(String) ? message : message.to_s
619
+ # Text frames must be valid UTF-8; binary payloads are sent verbatim.
620
+ data = data.encode("UTF-8") if data.encoding != Encoding::ASCII_8BIT
286
621
  frame = build_frame(0x1, data)
287
622
  @socket.write(frame)
288
623
  rescue IOError
289
- # Connection closed
624
+ # Connection closed — mark dead so the broadcast path prunes it.
625
+ @closed = true
290
626
  end
291
627
 
292
628
  alias_method :send_text, :send
@@ -301,7 +637,7 @@ module Tina4
301
637
  frame = build_frame(0xA, data || "")
302
638
  @socket.write(frame)
303
639
  rescue IOError
304
- # Connection closed
640
+ @closed = true
305
641
  end
306
642
 
307
643
  def close(code: 1000, reason: "")