cable_room 0.6.2 → 0.7.0.beta1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +1190 -0
  4. data/cable_room.gemspec +5 -2
  5. data/exe/cable_room +8 -0
  6. data/lib/cable_room/broadcaster.rb +116 -0
  7. data/lib/cable_room/bus.rb +372 -0
  8. data/lib/cable_room/cli.rb +237 -0
  9. data/lib/cable_room/config.rb +112 -0
  10. data/lib/cable_room/host/bus_inbound.rb +36 -0
  11. data/lib/cable_room/host/runner.rb +571 -0
  12. data/lib/cable_room/host/supervisor.rb +275 -0
  13. data/lib/cable_room/host/worker_pool.rb +37 -0
  14. data/lib/cable_room/host.rb +477 -0
  15. data/lib/cable_room/membership_store.rb +105 -0
  16. data/lib/cable_room/migration.rb +586 -0
  17. data/lib/cable_room/periodic_timer.rb +18 -0
  18. data/lib/cable_room/placement.rb +258 -0
  19. data/lib/cable_room/ports.rb +19 -11
  20. data/lib/cable_room/railtie.rb +3 -12
  21. data/lib/cable_room/room/base.rb +45 -39
  22. data/lib/cable_room/room/host_adapter.rb +52 -0
  23. data/lib/cable_room/room/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +57 -0
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +78 -0
  27. data/lib/cable_room/room/threading.rb +2 -2
  28. data/lib/cable_room/room/user_management.rb +27 -0
  29. data/lib/cable_room/room.rb +5 -2
  30. data/lib/cable_room/room_member.rb +260 -84
  31. data/lib/cable_room/room_proxy_channel.rb +13 -2
  32. data/lib/cable_room/snapshot.rb +136 -0
  33. data/lib/cable_room/version.rb +1 -1
  34. data/lib/cable_room.rb +57 -2
  35. metadata +25 -9
  36. data/lib/cable_room/channel_base.rb +0 -262
  37. data/lib/cable_room/channel_tracker.rb +0 -130
  38. data/lib/cable_room/room/channel_adapter.rb +0 -18
data/README.md CHANGED
@@ -1 +1,1191 @@
1
1
  # CableRoom
2
+
3
+ Build live Rooms on top of ActionCable.
4
+
5
+ A **Room** is a long-lived, server-side object that owns a piece of shared realtime state — a
6
+ quiz session, a collaborative document, a game lobby, a live dashboard. Exactly one instance of a
7
+ Room runs across your whole cluster at a time, it processes messages one at a time on its own
8
+ thread, and clients attach to it as **ports**.
9
+
10
+ ActionCable gives you channels, which are per-connection and stateless. CableRoom gives you the
11
+ thing on the other side of those channels: a single authoritative object that outlives any one
12
+ connection, holds state in memory, and shuts itself down when nobody needs it any more.
13
+
14
+ ## Contents.
15
+
16
+ - [How it works](#how-it-works)
17
+ - [Requirements](#requirements)
18
+ - [Installation](#installation)
19
+ - [Quick start](#quick-start)
20
+ - [Defining a Room](#defining-a-room)
21
+ - [Ports and messaging](#ports-and-messaging)
22
+ - [Users](#users)
23
+ - [Authorization](#authorization)
24
+ - [Reaping and the watchdog](#reaping-and-the-watchdog)
25
+ - [Joining a Room from a channel](#joining-a-room-from-a-channel)
26
+ - [Background work](#background-work)
27
+ - [Instrumentation and errors](#instrumentation-and-errors)
28
+ - [Configuration](#configuration)
29
+ - [Introspection](#introspection)
30
+ - [Subclassing](#subclassing)
31
+ - [Upgrading from 0.6 to 1.0](#upgrading-from-06-to-10)
32
+ - [Development](#development)
33
+
34
+ ## How it works.
35
+
36
+ Every Room runs inside a **Host**, one per process, on a shared pool of threads. The Host holds a
37
+ Redis lock on the Room's key, which is what guarantees a single instance cluster-wide, and renews
38
+ it every few seconds. Members don't talk to the Room directly. They send and receive on named
39
+ lanes called ports. Member→room traffic rides cable_room's own Redis bus (the `CABLEROOM_*`
40
+ connection) to whichever process hosts the Room; room→member traffic goes out through the
41
+ configured broadcaster (ActionCable or AnyCable) on pubsub streams the members listen to.
42
+
43
+ Where the Host lives is a deployment choice. By default it runs inside every web process
44
+ (`room_host = :inline`). With `room_host = :remote`, only `cable_room server` processes host
45
+ Rooms, and web processes just carry members. See [Configuration](#configuration).
46
+
47
+ ```
48
+ Browser Your ActionCable Channel The Room
49
+ | (include RoomMember) (Room::Base subclass)
50
+ | | |
51
+ | --- hello (action) -----> | --- to_room port --------------> | port_connected
52
+ | <-- port_acknowledged --- | <-- <token> port ------------- |
53
+ | | |
54
+ | --- websocket message --> | --- to_room port -------------> | handle_received_message
55
+ | | |
56
+ | <-- websocket message --- | <-- from_room port ----------- | broadcast / self <<
57
+ | | <-- <token> port ------------- | reply
58
+ | | <-- <user> / <tag> port ------ | broadcast(tag: :admin)
59
+ ```
60
+
61
+ Four things follow from that design:
62
+
63
+ - **One instance, many processes.** A `create: true` join asks the hosts to start the Room. Every
64
+ host races for the Redis lock; the one that wins runs the Room and the rest do nothing. Members
65
+ everywhere just publish to its ports.
66
+ - **No connection affinity.** Members can be spread across every app server, and the Room can run
67
+ on none of them. They only need Redis.
68
+ - **Single-threaded Room state.** Messages and timers run one at a time, so you can touch
69
+ instance variables without locks.
70
+ - **Rooms are disposable.** A Room is expected to die when it's idle and be re-created on demand.
71
+ Persist anything you can't lose.
72
+
73
+ ## Requirements.
74
+
75
+ - Ruby 3.4 (CI runs 3.4)
76
+ - Rails 7.2 through 8.x
77
+ - Redis, reachable from every web process and every rooms host, for the bus and the Room locks
78
+ (ActionCable's adapter or anycable-go need one too; it can be the same server)
79
+
80
+ ## Installation.
81
+
82
+ ```ruby
83
+ gem "cable_room"
84
+ ```
85
+
86
+ Then point it at Redis:
87
+
88
+ ```sh
89
+ export CABLEROOM_REDIS_URL=redis://localhost:6379/1
90
+ ```
91
+
92
+ See [Configuration](#configuration) for the full list of variables.
93
+
94
+ ## Quick start.
95
+
96
+ Define a Room:
97
+
98
+ ```ruby
99
+ class ChatRoom < CableRoom::Room::Base
100
+ # Shut down 30 seconds after the last member leaves
101
+ reap_when { connected_clients.empty? }
102
+
103
+ after_startup do
104
+ @history = []
105
+ end
106
+
107
+ # Handles { "type": "chat", "body": "..." } from any member
108
+ def on_chat(msg)
109
+ entry = { user: message_origin.user, body: msg["body"], at: Time.current }
110
+ @history << entry
111
+ broadcast({ type: "chat", **entry })
112
+ end
113
+
114
+ # Send the backlog only to the member who just connected
115
+ on_port_connected do
116
+ reply({ type: "history", entries: @history })
117
+ end
118
+ end
119
+ ```
120
+
121
+ Define a channel that joins it:
122
+
123
+ ```ruby
124
+ class ChatChannel < ApplicationCable::Channel
125
+ include CableRoom::RoomProxyChannel
126
+
127
+ subscribe_to_room do
128
+ join_room ChatRoom, params[:room_id], create: true, tags: params[:tags]
129
+ end
130
+ end
131
+ ```
132
+
133
+ Connect from the browser, and say `hello` once the subscription is confirmed:
134
+
135
+ ```js
136
+ consumer.subscriptions.create({ channel: "ChatChannel", room_id: 42 }, {
137
+ connected() { this.perform("hello") },
138
+ received(message) { console.log(message) },
139
+ })
140
+ ```
141
+
142
+ That's the whole loop. `RoomProxyChannel` forwards everything the client sends into the Room and
143
+ everything the Room broadcasts back out to the client. `hello` is what announces the member to the
144
+ Room; nothing is announced until the client sends it (see [The hello handshake](#the-hello-handshake)).
145
+ `create: true` means this channel will provision the Room if it isn't already running somewhere.
146
+
147
+ ## Defining a Room.
148
+
149
+ ### Lifecycle.
150
+
151
+ ```ruby
152
+ class MyRoom < CableRoom::Room::Base
153
+ before_startup { } # streams aren't open yet
154
+ after_startup { } # aliased as on_startup
155
+ before_shutdown { } # last chance to broadcast
156
+ after_shutdown { } # aliased as on_shutdown
157
+ end
158
+ ```
159
+
160
+ You can also just define `startup` and `shutdown` methods; they run inside the corresponding
161
+ callback chain.
162
+
163
+ Out of the box a Room broadcasts `{ type: "room_opened" }` after startup and
164
+ `{ type: "room_closed", reason: ... }` before shutdown.
165
+
166
+ To stop a Room from inside itself:
167
+
168
+ ```ruby
169
+ shutdown!("everyone left") # graceful: drains queued messages first
170
+ stop! # immediate: drops anything pending
171
+ ```
172
+
173
+ `lifecycle_state` returns `:initializing`, `:starting`, `:started`, `:shutting_down`, or `:dead`.
174
+ A Room being moved to another host passes through `:freezing` and `:frozen` on the way (see
175
+ [Migration hooks](#migration-hooks)).
176
+
177
+ ### Handling messages.
178
+
179
+ Inbound messages arrive on the `:to_room` port and dispatch by `type`. A message of type
180
+ `"start_quiz"` (or `"StartQuiz"`) calls `on_start_quiz`. Unknown types log a warning.
181
+
182
+ ```ruby
183
+ def on_start_quiz(msg)
184
+ logger.info "starting with #{msg['question_count']} questions"
185
+ end
186
+ ```
187
+
188
+ Inside a handler:
189
+
190
+ | Helper | What it gives you |
191
+ | ----------------- | ------------------------------------------------------- |
192
+ | `message` | The raw message hash |
193
+ | `message_origin` | The `PortClient` that sent it |
194
+ | `reply(data)` | Send back to that port alone |
195
+ | `broadcast(data)` | Send to every member |
196
+
197
+ Override `handle_received_message(message)` if you'd rather dispatch yourself. Call `super` for
198
+ anything you don't handle, so the built-in port and user bookkeeping keeps working.
199
+
200
+ Sending the string `"KILL"` to the `:to_room` port shuts the Room down. It's a blunt instrument,
201
+ useful in a console.
202
+
203
+ ### Timers.
204
+
205
+ ```ruby
206
+ class MyRoom < CableRoom::Room::Base
207
+ periodically :tick, every: 5.seconds
208
+ periodically -> { broadcast({ type: "still_here" }) }, every: 1.minute
209
+
210
+ def tick; end
211
+ end
212
+ ```
213
+
214
+ Timer bodies run on the Room's thread, so they're serialized against message handling.
215
+
216
+ ### Migration hooks.
217
+
218
+ When a host shuts down on purpose, it can move its Rooms to another host instead of closing them.
219
+ Members don't notice: their streams stay open, and the new host picks up where the old one left
220
+ off. The gem carries what it owns — every port with its token, tags, user, and last-seen time; the
221
+ user map; and each reaper's deadline. A Room's own state moves through two optional hooks:
222
+
223
+ ```ruby
224
+ class QuizRoom < CableRoom::Room::Base
225
+ def snapshot_state
226
+ { question_id: @question.id, answers: @answers } # JSON only; refer to records by id
227
+ end
228
+
229
+ def restore_state(state)
230
+ @question = Question.find(state[:question_id]) # state[:key] and state["key"] both work
231
+ @answers = state[:answers]
232
+ end
233
+ end
234
+ ```
235
+
236
+ `snapshot_state` runs once the Room is frozen (nothing else is touching its state) and must return
237
+ plain JSON: hashes, arrays, strings, numbers, booleans, and `nil`. The gem checks this when it
238
+ takes the snapshot and raises `CableRoom::Snapshot::NotSerializable`, naming the Room class and
239
+ key, if anything else is in there — a `Time`, a record, or a `Symbol` value, since those would
240
+ quietly come back as something different.
241
+
242
+ On the new host, a Room that defines `restore_state` gets it instead of `startup`, with whatever
243
+ `snapshot_state` returned. A Room without these hooks is restored with its ports and users intact
244
+ and runs `startup` again to rebuild its own state. Either way the `before_startup` and
245
+ `after_startup` callbacks run as usual, `restored?` is true inside them, and the gem doesn't
246
+ broadcast `room_opened` — the members were there the whole time.
247
+
248
+ ## Ports and messaging.
249
+
250
+ A port is a name derived from the Room class, the Room key, and a port name. Members send on it
251
+ over the Redis bus (`cr:RoomClass:key:in`, with a `:port` suffix for custom ports) and the Room
252
+ publishes on it as a broadcaster stream (`RoomClass:key:port`). Two are reserved:
253
+
254
+ - `:to_room` — many-to-one. Members publish here; the Room streams from it.
255
+ - `:from_room` — one-to-many. The Room publishes here; every member streams from it.
256
+
257
+ Every member also gets a private port named after its random token, plus a port for the user it
258
+ joined as and one for each tag it carries. That's how targeted delivery works without the Room
259
+ tracking connections.
260
+
261
+ ### Sending.
262
+
263
+ ```ruby
264
+ self << { type: "tick" } # to :from_room, i.e. everyone
265
+ broadcast({ type: "tick" }) # same thing
266
+ broadcast({ type: "secret" }, client_port: token) # one port
267
+ broadcast({ type: "hi" }, user: "user_42") # every port that user joined from
268
+ broadcast({ type: "tools" }, tag: :admin) # every port carrying the tag
269
+ reply({ type: "pong" }) # the port whose message you're handling
270
+ message_origin << { type: "pong" } # the same, spelled differently
271
+ ```
272
+
273
+ Combining a target with a tag makes the tag a filter, not a second audience.
274
+ `broadcast(msg, user: "user_42", tag: :admin)` reaches that user only if one of their ports is
275
+ tagged `admin`, and sends nothing otherwise.
276
+
277
+ ### Scoping.
278
+
279
+ `with_port_scope` sets an ambient target so nested code doesn't have to pass it around:
280
+
281
+ ```ruby
282
+ with_port_scope(tag: :admin) do
283
+ broadcast({ type: "diagnostics", data: expensive_report })
284
+ end
285
+ ```
286
+
287
+ Scopes merge when nested. `without_port_scope` clears them. `with_port_scope!` skips the block
288
+ entirely when nothing matches, which is the cheap way to avoid building a payload nobody will
289
+ receive. The block form of `reply` does the same for a single port:
290
+
291
+ ```ruby
292
+ reply do
293
+ broadcast({ type: "a" })
294
+ broadcast({ type: "b" })
295
+ end
296
+ ```
297
+
298
+ ### Custom ports.
299
+
300
+ Ports aren't limited to the built-ins. Open your own for a side channel:
301
+
302
+ ```ruby
303
+ ports[:telemetry] << { fps: 60 }
304
+
305
+ stream_port(:control) do |message|
306
+ logger.info "control: #{message.inspect}"
307
+ end
308
+ ```
309
+
310
+ Ports opened with `stream_port` close automatically at shutdown.
311
+
312
+ ### Port liveness.
313
+
314
+ Every message a member sends counts as activity. A member that has been silent for 15 seconds
315
+ (`RoomMember::PING_INTERVAL`) pings instead, so the room hears from a live member at least every
316
+ 30 seconds. A port that goes quiet for 45 seconds (`PortManagement::PORT_TIMEOUT`) is dropped,
317
+ and `on_port_disconnected` runs for it with `message_origin` still set, so cleanup can tell
318
+ which port went away. Under AnyCable the channel's timer never runs and the browser sends the
319
+ ping instead; see [Using AnyCable](#using-anycable).
320
+
321
+ ```ruby
322
+ on_port_connected { logger.info "port #{message_origin.token} joined" }
323
+ on_port_disconnected { logger.info "port #{message_origin.token} gone" }
324
+ ```
325
+
326
+ `connected_clients` returns the live `PortClient` objects. Each one carries a `token`, its `tags`,
327
+ its `user`, and any extra metadata the member passed in. Read and write metadata with `[]` and
328
+ `[]=`.
329
+
330
+ ## Users.
331
+
332
+ Members can join *as* a user. CableRoom then collapses that user's ports into a single identity,
333
+ so a person with three browser tabs joins once and leaves once.
334
+
335
+ ```ruby
336
+ class MyRoom < CableRoom::Room::Base
337
+ on_user_joined { broadcast({ type: "joined", user: message_origin.user }) }
338
+ on_user_left { broadcast({ type: "left", user: message_origin.user }) }
339
+ end
340
+ ```
341
+
342
+ `on_user_joined` fires on the first port for that user; `on_user_left` fires when the last one
343
+ goes away. `connected_users` lists them, and `all_user_tags(user)` unions the tags across every
344
+ port that user is connected from.
345
+
346
+ A `RoomMember` channel that defines `current_user` passes it automatically. Pass `as:` to override
347
+ it, or `as: nil` for an anonymous port. The value is serialized with ActiveJob's argument
348
+ serializer, so an ActiveRecord object survives the trip and arrives as the same record.
349
+
350
+ ## Authorization.
351
+
352
+ Two layers, and they compose. Use guards for anything that depends on the message; use tag
353
+ policies for anything that depends on who's asking.
354
+
355
+ ### Guards.
356
+
357
+ ```ruby
358
+ class MyRoom < CableRoom::Room::Base
359
+ # Block, symbol, or proc. Return false to drop the message.
360
+ authorize_inbound { |message| message["body"].to_s.length < 1_000 }
361
+ authorize_inbound :quiz_running?, only: [:answer, :skip]
362
+ authorize_inbound :not_locked?, except: :leave
363
+
364
+ protected
365
+
366
+ # Zero-arity guards read `message` themselves
367
+ def quiz_running? = @state == :running
368
+ def not_locked?(msg) = !@locked
369
+ end
370
+ ```
371
+
372
+ A dropped message logs a warning and never reaches a handler.
373
+
374
+ ### Tag policies.
375
+
376
+ Members join with tags (`join_room MyRoom, key, tags: [:admin]`). Policies then say which tags
377
+ may trigger which handlers.
378
+
379
+ ```ruby
380
+ class MyRoom < CableRoom::Room::Base
381
+ inbound_tag_policy do
382
+ deny :muted, :chat # muted members can't chat...
383
+ allow :*, :chat # ...but everyone else can
384
+ allow :admin, [:kick, :ban] # admins get the moderation verbs
385
+ end
386
+ end
387
+ ```
388
+
389
+ Two rules govern how this resolves:
390
+
391
+ 1. **Declaring any policy flips the default to deny.** Before you write one, everything is
392
+ allowed. After, only what you allow is allowed. The built-in connection and user messages stay
393
+ permitted, so members can still join and leave.
394
+ 2. **Highest priority wins.** Rules default to priority 10. Pass `priority:` to layer a base
395
+ policy under, or an override over, another. `inbound_tag_policy(priority: -10)` adds
396
+ permissions without flipping the default.
397
+
398
+ Within a priority, the first matching rule decides, and rules match in declaration order. That
399
+ means a `deny` exception has to come **before** the broad `allow` it carves out of — write
400
+ `allow :*, :chat` first and it swallows every member, muted ones included. When the ordering
401
+ matters a lot, give the two rules different priorities instead of relying on where they sit in
402
+ the block:
403
+
404
+ ```ruby
405
+ inbound_tag_policy(priority: 20) { deny :muted, :chat }
406
+ inbound_tag_policy(priority: 10) { allow :*, :chat }
407
+ ```
408
+
409
+ Group related handlers behind one name with `define_tag_alias`. A rule written against the alias
410
+ covers everything it implies:
411
+
412
+ ```ruby
413
+ class MyRoom < CableRoom::Room::Base
414
+ define_tag_alias :moderation, [:kick, :ban, :mute]
415
+
416
+ inbound_tag_policy do
417
+ allow :admin, :moderation
418
+ end
419
+ end
420
+ ```
421
+
422
+ Aliases are per Room class and inherited by subclasses, so one Room's vocabulary can't change how
423
+ another Room reads its policies.
424
+
425
+ ### System message types.
426
+
427
+ Some message types are the framework's, not the client's. Members can't forge them:
428
+
429
+ ```ruby
430
+ class MyRoom < CableRoom::Room::Base
431
+ system_message_types :score_awarded, :quiz_finished
432
+ end
433
+ ```
434
+
435
+ Attempts to send one from a member are dropped with a warning at the sender. `port_connected`,
436
+ `port_disconnected`, `port_ping`, `user_joined`, and `user_left` are already protected.
437
+
438
+ ## Reaping and the watchdog.
439
+
440
+ Rooms hold memory and a Redis lock, so they need to know when to quit. `reap_when` declares a
441
+ check that runs on a timer:
442
+
443
+ ```ruby
444
+ class MyRoom < CableRoom::Room::Base
445
+ # Idle for 30 seconds with nobody connected -> shut down
446
+ reap_when { connected_clients.empty? }
447
+
448
+ # Tighter window, and named so the shutdown reason says which check fired
449
+ reap_when(key: :abandoned, grace: 5.minutes, interval: 30.seconds) do
450
+ connected_users.empty?
451
+ end
452
+
453
+ # Return :reap to skip the grace period entirely
454
+ reap_when(grace: 1.hour) { @cancelled ? :reap : false }
455
+ end
456
+ ```
457
+
458
+ - **Truthy** starts the grace clock. Once the condition has held for `grace:` (default 30
459
+ seconds), the Room shuts down.
460
+ - **Falsey** resets the clock and pings the watchdog.
461
+ - **`:reap`** shuts down now, whatever the grace period says.
462
+
463
+ Declare as many checks as you like; each gets its own timer and its own grace clock. Call
464
+ `check_reapers_now!` to run them all immediately instead of waiting for the next tick.
465
+
466
+ ### The watchdog.
467
+
468
+ Separately, every Room is supervised. Every five seconds its channel extends the Redis lock and
469
+ confirms the Room has pinged its watchdog within the last 15 seconds
470
+ (`Room::Base::WATCH_DOG_INTERVAL`). Lose the lock and the Room stops, since another process may
471
+ now own the key. Miss the ping and it shuts down as wedged.
472
+
473
+ **Reaper checks are what ping the watchdog.** A Room that declares no `reap_when` has nothing
474
+ pinging it, so the watchdog will shut it down about 15 seconds after startup. Every long-lived
475
+ Room needs at least one `reap_when` — or its own timer calling `ping_watchdog` — to stay up.
476
+
477
+ ## Joining a Room from a channel.
478
+
479
+ ### The proxy shortcut.
480
+
481
+ When the client only needs a pipe to the Room, `RoomProxyChannel` is the whole channel:
482
+
483
+ ```ruby
484
+ class QuizChannel < ApplicationCable::Channel
485
+ include CableRoom::RoomProxyChannel
486
+
487
+ subscribe_to_room do
488
+ join_room QuizRoom, params[:quiz_id], create: true
489
+ end
490
+ end
491
+ ```
492
+
493
+ It wires up `subscribed`, `receive`, `unsubscribed`, and the `hello` action, and forwards messages
494
+ both ways.
495
+
496
+ ### Full control.
497
+
498
+ `RoomMember` gives you the membership without the forwarding, so the channel can filter,
499
+ transform, or fan out:
500
+
501
+ ```ruby
502
+ class QuizChannel < ApplicationCable::Channel
503
+ include CableRoom::RoomMember
504
+
505
+ def subscribed
506
+ @membership = join_room(
507
+ QuizRoom,
508
+ params[:quiz_id],
509
+ create: true,
510
+ tags: current_user.teacher? ? [:admin] : [:student],
511
+ extra: { device: params[:device] },
512
+ on_joined: ->(m) { transmit(type: "ready") },
513
+ on_message: ->(msg) { transmit(msg) if msg["type"] != "internal" },
514
+ on_room_closed: ->(m) { transmit(type: "over") },
515
+ on_left: ->(m) { logger.info "left #{m.key}" }
516
+ )
517
+ end
518
+
519
+ def answer(data)
520
+ @membership << { type: "answer", choice: data["choice"] }
521
+ end
522
+
523
+ def unsubscribed
524
+ @membership&.leave!
525
+ end
526
+ end
527
+ ```
528
+
529
+ `join_room` options:
530
+
531
+ | Option | Meaning |
532
+ | ------------------------------- | -------------------------------------------------------------------- |
533
+ | `create:` | Provision the Room if it isn't running. Defaults to `false`. |
534
+ | `as:` | The user identity. Defaults to `current_user` when the channel has one. |
535
+ | `tags:` | Tags this port carries, for policies and targeted broadcasts. |
536
+ | `extra:` | Extra metadata, readable on the Room's `PortClient`. |
537
+ | `forward:` | Pipe every Room message straight to the websocket. |
538
+ | `on_joined:` | The Room acknowledged this port. |
539
+ | `on_message:` | Any message from the Room. |
540
+ | `on_room_opened:` | The Room opened while we were connecting. Not guaranteed. |
541
+ | `on_room_closed:` | The Room closed while we were connected. Not guaranteed. |
542
+ | `on_left:` | This membership ended. |
543
+
544
+ The returned membership responds to `<<`, `connected?`, `accepts_input?`, `hello!`,
545
+ `hello_received?`, `left?`, `key`, `ping!`, `leave!`, and `rejoin!`.
546
+
547
+ Under AnyCable an instance variable set in `subscribed` is gone by the next call, because
548
+ anycable-rails builds a new channel object for every one (see [Using AnyCable](#using-anycable)).
549
+ Read `room_memberships` in your actions instead of `@membership`; it holds the memberships
550
+ `join_room` created, rebuilt from the channel state when needed.
551
+
552
+ With `create: true`, the membership doesn't start the Room itself. It publishes a provision
553
+ request on the bus, and one of the Rooms hosts starts the Room (see
554
+ [Provisioning](#provisioning)). The request goes out at join and again on every ping until the
555
+ Room acknowledges the port, so a lost request costs at most one ping interval, and if the Room's
556
+ host process dies the next ping from any member brings it back somewhere else.
557
+
558
+ ### The hello handshake.
559
+
560
+ A membership doesn't announce itself to the Room when the channel subscribes. Stream subscriptions
561
+ are asynchronous, so the Room's acknowledgement could land on a stream nobody is listening to yet,
562
+ and the member would stay deaf on the inbound side. Instead, the client performs `hello` once
563
+ ActionCable confirms the subscription (`connected()` in the JS client), and the membership sends
564
+ `port_connected` then. `RoomMember` defines `hello` as a public channel action, so both
565
+ `RoomProxyChannel` and `RoomMember` channels like the `QuizChannel` above accept
566
+ `perform("hello")` with no extra wiring. A channel that learns the client is ready some other way
567
+ can call `hello_room_memberships` itself.
568
+
569
+ If the announcement or the acknowledgement is lost, or the Room isn't running yet, the membership
570
+ re-announces on every ping until the Room acknowledges it, so a lost message costs at most one
571
+ ping interval. That only starts after `hello`. `hello_received.cable_room` fires each time a
572
+ membership hears it. Under AnyCable the server never sees the acknowledgement, so the browser
573
+ does the retrying; see [Using AnyCable](#using-anycable).
574
+
575
+ **Breaking change in 1.0.** Clients must send `hello`, and there is no fallback. A client that
576
+ only subscribes still receives broadcasts on the shared stream, but the Room never sees it and
577
+ everything it sends is dropped. Browser tabs open across the deploy speak the old handshake and
578
+ need a reload.
579
+
580
+ ### From outside a channel.
581
+
582
+ ```ruby
583
+ QuizRoom.ensure("quiz_9") # => true if this process now runs it
584
+ QuizRoom.send_message("quiz_9", { type: "extend", by: 60 }) # publish to :to_room over the bus
585
+ QuizRoom.room_port_key("quiz_9", :from_room) # the raw stream name members listen on
586
+ QuizRoom.inbound_channel("quiz_9") # the raw bus channel the Room listens on
587
+ ```
588
+
589
+ `ensure` only works in a process that hosts Rooms. In `remote` mode that's the `cable_room server`
590
+ process; a web process raises `CableRoom::Host::NotHosting` (see [Remote rooms](#remote-rooms)).
591
+
592
+ ## Background work.
593
+
594
+ A Room is single-threaded on purpose. Slow work belongs off its thread:
595
+
596
+ ```ruby
597
+ def on_export(msg)
598
+ token = message_origin.token # capture before leaving the Room's thread
599
+
600
+ async do
601
+ report = build_expensive_report
602
+
603
+ on_room_thread do
604
+ broadcast({ type: "export_ready", url: report.url }, client_port: token)
605
+ end
606
+ end
607
+ end
608
+ ```
609
+
610
+ `async` borrows a thread from the pool shared by every Room in the process and runs concurrently
611
+ with the Room, so **the block must not touch Room state.** Capture what it needs first. Inside
612
+ it, `message` is nil, and `message_origin` and `reply` point at whatever the Room is handling
613
+ *now* rather than what it was handling when you called `async`.
614
+
615
+ `on_room_thread` queues work back onto the Room's thread, where state is safe again. Prefer
616
+ handing results back that way over blocking on `async` work, since a blocked Room thread can
617
+ starve its neighbours.
618
+
619
+ ## Instrumentation and errors.
620
+
621
+ Rooms swallow exceptions so one bad message can't take the Room down. That makes the error
622
+ handler the only place you'll hear about it:
623
+
624
+ ```ruby
625
+ CableRoom.error_handler = ->(error, context) do
626
+ Sentry.capture_exception(error, extra: context)
627
+ end
628
+ ```
629
+
630
+ For an error inside a Room the context has `room`, `room_class`, `room_key`, and `runner` (the
631
+ `Host::Runner` driving it; 0.6 called this key `channel`). Errors from the other moving parts
632
+ name themselves instead: `bus`, `host`, `placement`, `migration`, or `adoption`, with
633
+ `room_class` and `room_key` where they apply. An `error.cable_room` notification fires either way.
634
+
635
+ ActiveSupport notifications, every one the gem emits:
636
+
637
+ | Event | Fires | Payload |
638
+ | -------------------------------- | ------------------------------------ | -------------------------- |
639
+ | `room_opened.cable_room` | Around a Room's startup | `room` |
640
+ | `room_restored.cable_room` | Around a restore, in place of `room_opened` | `room` |
641
+ | `room_closed.cable_room` | Around a Room's shutdown | `room`, `reason` |
642
+ | `room_snapshotted.cable_room` | When a frozen Room is snapshotted | `room` |
643
+ | `room_migrated.cable_room` | On the old host, once a peer has the Room | `room`, `room_class`, `room_key`, `duration`, `relayed_messages`, `to_host`, `from_host`, `reason` |
644
+ | `host_draining.cable_room` | Around a whole `drain!` | `host`, `rooms`, `reason`; on finish also `migrated`, `closed` |
645
+ | `provision_requested.cable_room` | Member side, each `provision` request | `membership`, `room_class`, `room_key`, `channel`, `request` |
646
+ | `provision_claimed.cable_room` | Host side, each lock attempt | `host`, `room_class`, `room_key`, `delay`, `open_rooms`, `request`; on finish also `handoff`, `claimed` |
647
+ | `hello_received.cable_room` | Member side, each `hello` | `membership`, `room_class`, `room_key`, `channel` |
648
+ | `message_received.cable_room` | Each inbound message a Room handles | `room`, `message` |
649
+ | `port_connected.cable_room` | A port joins | `room`, `message` |
650
+ | `port_disconnected.cable_room` | A port leaves or times out | `room`, `reason`, `message` |
651
+ | `user_joined.cable_room` | A user's first port joins | `room`, `user` |
652
+ | `user_left.cable_room` | A user's last port leaves | `room`, `user` |
653
+ | `error.cable_room` | Any reported error | `error`, plus context |
654
+
655
+ `port_disconnected` reports a `reason` of `:left` for a clean departure and `:timeout` for a port
656
+ that stopped pinging (no `message` in that case). `room_closed` always has a `reason`: the string
657
+ passed to `shutdown!`, the reaper that fired, `"Server shutting down"`, `"Watchdog timeout"`, or
658
+ the signal that started a drain.
659
+
660
+ Every Room also gets a tagged logger, so `logger.info` from inside a Room is prefixed with the
661
+ Room class and a short UUID. That UUID is how you follow one instance through the logs.
662
+
663
+ ## Configuration.
664
+
665
+ ### Deployment knobs.
666
+
667
+ CableRoom works with no setup. Rooms run inside the web process and broadcast through
668
+ ActionCable. To change that, add an initializer:
669
+
670
+ ```ruby
671
+ CableRoom.configure do |c|
672
+ c.room_host = :inline # :inline | :remote
673
+ c.broadcaster = :action_cable # :action_cable | :anycable
674
+ c.provision_delay_ms = 20
675
+ c.drain_timeout = 10.minutes
676
+ c.handoff_timeout = 10.seconds
677
+ end
678
+ ```
679
+
680
+ The env vars `CABLE_ROOM_HOST` and `CABLE_ROOM_BROADCASTER` override `room_host` and
681
+ `broadcaster`, even when the block sets them. An unknown value raises at boot with the list
682
+ of valid options. Read the current settings with `CableRoom.config`.
683
+
684
+ ### Deployment modes.
685
+
686
+ The two knobs combine freely. Pick where rooms run and what serves the websockets:
687
+
688
+ | `room_host` | `broadcaster` | Where rooms run | Sockets served by | Notes |
689
+ | ----------- | -------------- | -------------------- | ---------------------- | --------------------------------------- |
690
+ | `inline` | `action_cable` | web process (thread) | Passenger + ActionCable | The default. Today's behavior. |
691
+ | `remote` | `action_cable` | rooms pool | Passenger + ActionCable | Valid. Covered by the remote-host integration spec. Measure this step first. |
692
+ | `remote` | `anycable` | rooms pool | anycable-go + RPC | The target for large installs. |
693
+ | `inline` | `anycable` | web process | anycable-go + RPC | Valid. Suits small installs. |
694
+
695
+ `broadcaster` only changes how a Room pushes messages *out* to members. Stream names stay the
696
+ same in both modes, so a member on ActionCable and a member on anycable-go would see the same
697
+ traffic — but a Room publishes to one broadcaster, so members on the other stack hear nothing.
698
+ Running both stacks at once is covered in [Dual-stack](#dual-stack-actioncable-and-anycable-side-by-side).
699
+
700
+ ### Remote rooms.
701
+
702
+ With `room_host = :remote`, a web process never hosts a Room. Channels still join, say `hello`,
703
+ send on the bus, and receive on their streams exactly as in `inline`; the only difference is where
704
+ the Room lives. Two guards keep it that way:
705
+
706
+ - `join_room(..., create: true)` starts nothing in the web process. It publishes a provision
707
+ request, and a `cable_room server` process starts the Room (see [Provisioning](#provisioning)).
708
+ - `CableRoom::Host.instance` raises `CableRoom::Host::NotHosting` in a web process, so
709
+ `MyRoom.ensure(key)` (and anything else that would start a Room) fails loudly instead of
710
+ quietly hosting a Room in the web tier. `CableRoom::Host.current` answers `nil` there, and
711
+ `CableRoom::Room.locally_open_rooms` is empty.
712
+
713
+ Rooms run in a separate pool of processes started with the gem's command:
714
+
715
+ ```sh
716
+ CABLE_ROOM_HOST=remote bundle exec cable_room server
717
+ ```
718
+
719
+ `cable_room server` boots the Rails app in the current directory (its `config/environment.rb`),
720
+ refuses to run unless `room_host` is `remote`, starts a Host, and runs until SIGTERM or SIGINT.
721
+ On exit it asks every Room to finish its queued work and shut down, which sends `room_closed` to
722
+ members. Pass `--require PATH` to boot from another app directory or a boot file.
723
+
724
+ `--workers N` sets how many processes host Rooms. It defaults to the machine's core count. With
725
+ `--workers 1` the command hosts Rooms in its own process. With more, it boots the app once and
726
+ then forks `N` workers, each with its own Host, bus subscription, and Redis connections; the
727
+ parent hosts nothing and only supervises. A worker that dies is replaced (with a growing delay
728
+ if it keeps dying at boot, so a broken app can't fork-bomb the box). SIGTERM or SIGINT to the
729
+ parent is relayed to every worker, and the parent waits for all of them before it exits `0`,
730
+ so wrap it in the container's own deadline (`timeout -k 2m 24h bundle exec cable_room server`).
731
+ Each worker tags what its Rooms log (the ActionCable logger) with `cable_room worker N` and shows
732
+ up in `ps` as `cable_room server: worker N`.
733
+
734
+ The rooms host exposes its load as `CableRoom::Host.current.open_ports`, the number of member
735
+ ports attached across every Room it runs. Publish that as a gauge to scale the pool on.
736
+
737
+ ### Provisioning.
738
+
739
+ A `create: true` join publishes a `provision` request on the bus (`cr:provision`) naming the
740
+ Room class and key. Every host hears it, in `inline` and `remote` alike: in `inline` that's the
741
+ web process's own host, in `remote` it's every `cable_room server`. Each host waits a delay
742
+ proportional to how many Rooms it already runs, then races for the Room's Redis lock. Exactly one
743
+ wins and starts the Room, and it's usually the least loaded one; the others find the lock held and
744
+ do nothing. A host that is draining never claims. The member keeps re-publishing the request on
745
+ every ping until the Room acknowledges it, so a lost request costs at most one ping interval, and
746
+ a member that joined before the Room existed completes its join as soon as the Room comes up.
747
+
748
+ The delay is `provision_delay_ms × (open_rooms + jitter)`, with `jitter` a random number in
749
+ `0..1` and `provision_delay_ms` defaulting to 20. A host running nothing waits at most one
750
+ `provision_delay_ms`; a host with more Rooms always waits longer than one with fewer, so the
751
+ jitter only breaks ties. `provision_requested.cable_room` fires on the member side for each request
752
+ and `provision_claimed.cable_room` on the host side for each lock attempt (with `room_class`,
753
+ `room_key`, `delay`, `open_rooms`, and `claimed`).
754
+
755
+ `MyRoom.ensure(key)` still works inside a process that hosts Rooms, for starting a Room from a
756
+ console or an initializer without any member asking.
757
+
758
+ ### Using AnyCable.
759
+
760
+ `broadcaster = :anycable` sends room broadcasts through `AnyCable.broadcast`, so anycable-go
761
+ fans them out to sockets. The gem doesn't depend on AnyCable; add it yourself:
762
+
763
+ ```ruby
764
+ gem "anycable-rails"
765
+ ```
766
+
767
+ Boot with `:anycable` set and the gem missing, and CableRoom raises at startup naming the gem to
768
+ add. AnyCable reads its own settings from the environment. Point it at the same Redis as
769
+ anycable-go:
770
+
771
+ ```sh
772
+ export ANYCABLE_BROADCAST_ADAPTER=redis
773
+ export ANYCABLE_REDIS_URL=redis://localhost:6379/1
774
+ ```
775
+
776
+ Room broadcasts are JSON-encoded with the same coder ActionCable uses, so a channel decodes them
777
+ the same way whichever broadcaster sent them.
778
+
779
+ #### What AnyCable buys, and what it doesn't.
780
+
781
+ AnyCable moves two things out of Ruby: holding sockets, and fanning a broadcast out to them.
782
+ It does not move the inbound path. Know which side of that line your traffic is on before you
783
+ adopt it.
784
+
785
+ **Outbound gets cheap.** Under ActionCable a Room broadcast to *N* members is *N* Redis
786
+ deliveries, each decoded, re-encoded, and written by a Ruby worker thread; plus ActionCable's
787
+ own 3-second heartbeat to every socket, also written by Ruby. Under AnyCable the Room publishes
788
+ once and anycable-go writes *N* frames; heartbeats never touch Ruby. In a quiz deployment
789
+ measured in August 2026 a 30-student room produced ~250 inbound messages, ~1,400 outbound socket
790
+ deliveries, and ~700 heartbeats over its life — Ruby's share fell from all ~2,400 to the ~250
791
+ inbound plus a few dozen publishes.
792
+
793
+ **Inbound does not get cheaper.** Every client message is an RPC round trip from anycable-go to
794
+ Ruby. On each one anycable-rails rebuilds the connection from its serialized identifiers,
795
+ builds a new channel object, restores channel state, runs `receive`, and serializes state back.
796
+ The socket read and JSON decode leave Ruby; the object rebuild and state round-trip arrive; the
797
+ Room still processes the message once, as before. Per inbound message it is a wash, plus roughly
798
+ half a millisecond to a millisecond of RPC latency on the reply. A protocol that is mostly
799
+ inbound — cursor positions, keystroke sync, anything chatty from the client — gains little from
800
+ AnyCable and should batch on the client instead. (AnyCable's "whispers" carry client-to-client
801
+ messages without Ruby, but a whisper never reaches a Room.)
802
+
803
+ **Two things make inbound worse than a wash if you let them:**
804
+
805
+ - **Identifiers are deserialized on every RPC.** `identified_by :current_user` holding a record
806
+ is serialized as a GlobalID and located again on each call — a database read per inbound
807
+ message. Identify the connection by a primitive (a user id, a session key) and resolve the
808
+ record lazily in the channel or the Room. Room→member scoping by user (`notify: user`,
809
+ `broadcast(user: ...)`) works on whatever `as:` you pass to `join_room`; pass the same primitive.
810
+ - **The gRPC server is one Ruby process.** `bundle exec anycable` serves every RPC from one
811
+ interpreter with a thread pool (30 threads by default; anycable-go's concurrency limit defaults
812
+ to 28), so a receive-heavy load hits one GVL. HTTP RPC mode (`--rpc_host http://...`) sends each
813
+ command to the Rails app as a short HTTP request, which an app server spreads across its process
814
+ pool; prefer it when inbound volume matters, and protect the endpoint with the bearer token.
815
+
816
+ Everything else AnyCable changes is structural rather than per-message: sockets live in Go, so
817
+ socket memory, `worker_connections`-style limits, and app-server routing rules for long-lived
818
+ connections stop being Ruby's problem.
819
+
820
+ #### What changes for the channel.
821
+
822
+ anycable-rails builds a fresh channel object for every call the socket makes (subscribe, each
823
+ message, unsubscribe, disconnect) and never re-runs `subscribed`. Three things follow:
824
+
825
+ - **Memberships live in the channel state.** `join_room` records each membership's identity
826
+ (token, Room, key, tags, `extra`, `create`, whether `hello` happened) with anycable-rails'
827
+ `state_attr_accessor`, and `hello`, `receive`, `ping`, and `unsubscribed` rebuild it from there
828
+ (`CableRoom::MembershipStore`). A rebuilt membership keeps the token subscribe created, opens
829
+ no streams (anycable-go still holds them), and announces nothing until `hello`. Under plain
830
+ ActionCable the channel object lives for the socket and none of this runs.
831
+ - **No `on_*` callbacks fire.** Room→member traffic goes anycable-go → socket and never passes
832
+ through this process, so `on_joined`, `on_message`, `on_room_opened`, and `on_room_closed` have
833
+ nothing to fire on, and a rebuilt membership carries no procs, so `on_left` doesn't either.
834
+ `RoomProxyChannel` needs none of them: the forwarding it does under ActionCable is what
835
+ anycable-go does natively. `connected?` stays `false` on the server for the same reason;
836
+ input is forwarded once `hello` has gone out (`accepts_input?`). The `port_connected` from
837
+ `hello` and the input travel the same Bus channel from the same process, so the Room sees them
838
+ in that order.
839
+ - **Channel timers don't run.** `periodically` is disabled by anycable-rails, so the member-side
840
+ ping and re-announce never happen on their own. The browser has to drive them.
841
+ - **Unsupported `join_room` features raise.** `forward: false`, any `on_*:` callback, and a
842
+ preconfigure block all need a message to pass through this process, so on an AnyCable-backed
843
+ channel `join_room` raises `CableRoom::AnyCableUnsupported` at subscribe time naming the feature.
844
+ `RoomProxyChannel`'s default join (`forward: true`, no callbacks) is the supported shape.
845
+
846
+ #### What the browser must send.
847
+
848
+ 1. Subscribe, and wait for `confirm_subscription`.
849
+ 2. `perform("hello")`. Expect a `port_acknowledged` message within a few seconds. If it doesn't
850
+ arrive, `perform("hello")` again: the server can't see that the acknowledgement was lost, so
851
+ the retry that `ping!` does under ActionCable is the browser's job here. `port_connected` is
852
+ idempotent Room-side, so a repeat is harmless.
853
+ 3. `perform("ping")` every 15 seconds (`RoomMember::PING_INTERVAL`) for as long as the
854
+ subscription is open. Each ping becomes a `port_ping` for the membership, so the Room keeps the
855
+ port past `PORT_TIMEOUT`, and retries provisioning for a `create: true` join whose Room went
856
+ away. A browser that stops pinging loses its port after 45 seconds.
857
+ 4. On `room_closed`, leave: stop pinging and sending, and unsubscribe the channel. That is what
858
+ the membership does by itself under ActionCable (`leave!`, which sends `port_disconnected` —
859
+ the same thing unsubscribing does here). Under AnyCable the server never sees the broadcast,
860
+ so a tab that keeps pinging would keep the port alive and, with `create: true`, re-provision
861
+ the Room on its next ping. To rejoin, resubscribe — as under ActionCable.
862
+
863
+ `KILL` is an administrative break-glass message. Under ActionCable the membership leaves when it
864
+ sees one; under AnyCable the bare string reaches the socket and nothing else happens, which is
865
+ fine — use `room_closed` (`shutdown!`) to end a Room for its members.
866
+
867
+ Unsubscribing or closing the socket sends `port_disconnected` as before: anycable-go turns both
868
+ into an RPC call that rebuilds the channel with its state. Both actions exist under plain
869
+ ActionCable too, where `ping` is harmless and unnecessary.
870
+
871
+ #### Dual-stack: ActionCable and AnyCable side by side.
872
+
873
+ You may want some channels on AnyCable and others on ActionCable — high-fan-out Rooms on
874
+ anycable-go, a channel that still needs `on_*` callbacks or `forward: false` on ActionCable, or a
875
+ migration where the two overlap. Half of that works today and half needs one addition.
876
+
877
+ **What already works: the member side is decided per connection, not per process.** The gem
878
+ checks `connection.anycabled?` on the channel it is given. A channel reached through anycable-go
879
+ gets the AnyCable behavior above (state store, `hello`/`ping` actions, no callbacks); the same
880
+ channel class reached through a Passenger-served ActionCable socket gets the classic behavior.
881
+ Both can run in one Rails process at once, provided both socket servers are up and routed:
882
+
883
+ ```nginx
884
+ location /cable { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1;
885
+ proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }
886
+ location /cable-classic { passenger_app_group_name myapp_action_cable;
887
+ passenger_force_max_concurrent_requests_per_process 0; }
888
+ ```
889
+
890
+ Which stack a channel uses is then the browser's choice of consumer, per feature:
891
+
892
+ ```js
893
+ const fast = createConsumer("/cable") // anycable-go
894
+ const classic = createConsumer("/cable-classic") // ActionCable
895
+ fast.subscriptions.create({ channel: "QuizChannel", ... }, { connected() { this.perform("hello") }, ... })
896
+ classic.subscriptions.create({ channel: "LegacyDashboardChannel" }, { ... })
897
+ ```
898
+
899
+ `config/cable.yml` keeps its `redis` adapter for the classic sockets; anycable-go and
900
+ `AnyCable.broadcast` use `ANYCABLE_*`. Nothing in the gem needs to know which channel classes go
901
+ where. If you want to enforce it server-side, a channel can `reject` in `subscribed` when
902
+ `anycable_channel?` disagrees with where it belongs.
903
+
904
+ **What does not work yet: a Room broadcasts to one stack.** `broadcaster` is process-wide and
905
+ chooses *either* `ActionCable.server.broadcast` *or* `AnyCable.broadcast`, so with
906
+ `broadcaster = :anycable` a Room's messages reach anycable-go sockets only, and members joined
907
+ through `/cable-classic` hear nothing (their `hello` and input still reach the Room; the Room's
908
+ replies don't come back). Dual-stack therefore needs a `:both` broadcaster that publishes every
909
+ Room message through both adapters. It is one class and one allowed value in
910
+ `CableRoom::Config::BROADCASTERS`; the cost is a second publish per Room broadcast (per fan-out,
911
+ not per socket — the ActionCable adapter then does the classic *N* deliveries for the classic
912
+ members, as it does today). Until it exists, run one stack at a time, or make sure every member
913
+ of a given Room joins through the same stack.
914
+
915
+ Rooms themselves never know which stack a member is on: `port_connected`, `port_ping`, input,
916
+ and `port_disconnected` arrive on the same Bus channel from either path, and stream names are
917
+ identical.
918
+
919
+ ### Redis.
920
+
921
+ Three things need a Redis, and each reads its own variables. They can all point at one server
922
+ to start; splitting them later is a config change.
923
+
924
+ | Variable | Read by | Purpose |
925
+ | ----------------------------------------------- | ---------------------- | -------------------------------------------------------- |
926
+ | `CABLEROOM_REDIS_URL` | cable_room | The bus (member→room traffic, provisioning, handoff) and the Room locks. Must be reachable from every web process and every rooms host. |
927
+ | `REDIS_URL` | ActionCable's adapter | Room→member streams under `broadcaster = :action_cable` (`config/cable.yml` decides; this is the usual name). |
928
+ | `ANYCABLE_BROADCAST_ADAPTER` / `ANYCABLE_REDIS_URL` | anycable-go and `AnyCable.broadcast` | Room→member streams under `broadcaster = :anycable`. Set the adapter to `redis` and point both the Go process and the Rails process at the same URL. |
929
+
930
+ CableRoom keeps its own pool for the bus and the locks, separate from the ActionCable adapter's:
931
+
932
+ | Variable | Purpose |
933
+ | ---------------------------- | -------------------------------------------------------- |
934
+ | `CABLEROOM_REDIS_URL` | The connection URL. |
935
+ | `CABLEROOM_REDIS_PROVIDER` | Name of another variable holding the URL. |
936
+ | `CABLEROOM_REDIS_POOL_SIZE` | Pool size. Defaults to `RAILS_MAX_THREADS`, then five. |
937
+
938
+ Without a prefixed variable it falls back to `REDIS_PROVIDER` and `REDIS_URL`, so a single-Redis
939
+ app needs no CableRoom-specific configuration at all. Reach the pool directly with
940
+ `CableRoom.redis { |conn| ... }`, the lock manager with `CableRoom.lock_manager`, and the bus with
941
+ `CableRoom.bus`.
942
+
943
+ Room threads come from a pool sized by ActionCable's own `worker_pool_size`.
944
+
945
+ Timings live in constants:
946
+
947
+ | Constant | Default | What it controls |
948
+ | --------------------------------- | -------------- | --------------------------------------- |
949
+ | `Room::Base::LOCK_DURATION` | `15.seconds` | Redis lock TTL, extended on every beat. |
950
+ | `Room::Base::WATCH_DOG_INTERVAL` | `15.seconds` | How stale a watchdog ping may get. |
951
+ | `PortManagement::PORT_TIMEOUT` | `45.seconds` | How long a silent port survives. |
952
+ | `Host::BEAT_INTERVAL` | `5.seconds` | Lock extension and watchdog sweep. |
953
+
954
+ The first two are read as `self::CONSTANT`, so a Room subclass can redefine them. The other two
955
+ are module constants that apply process-wide.
956
+
957
+ ### Shutdown.
958
+
959
+ On process exit, CableRoom asks every local Room to shut down gracefully and waits up to 15
960
+ seconds for them to drain. It also hooks ActionCable's `restart`, so a code reload in development
961
+ stops Rooms instead of orphaning their locks. `cable_room server` with no Rooms open does the
962
+ same when it gets SIGTERM or SIGINT; with Rooms open it migrates them first (next section).
963
+
964
+ ### Planned shutdown and migration.
965
+
966
+ A `cable_room server` that gets SIGTERM or SIGINT with Rooms open doesn't close them. It hands
967
+ them to its peers, one Redis round trip at a time, and members never notice: their streams stay
968
+ open, their messages keep arriving in order, and the Room's broadcasts resume from the new host.
969
+ The same thing is available from code as `CableRoom::Host.current.drain!(reason: "...")`.
970
+
971
+ What moves is what the gem owns — every port with its token, tags, user, and last-seen time, the
972
+ user map, and each reaper's deadline — plus whatever the Room returns from `snapshot_state`
973
+ (see [Migration hooks](#migration-hooks)). A Room without `snapshot_state` still moves; it runs
974
+ `startup` again on the new host to rebuild its own state.
975
+
976
+ Per Room, in order:
977
+
978
+ 1. **Freeze.** The Room finishes what it has queued and stops. Its inbound subscription stays up;
979
+ from here on every message a member sends is pushed to a Redis list instead of handled.
980
+ 2. **Snapshot.** The gem takes the snapshot. A Room whose `snapshot_state` isn't JSON, or raises,
981
+ can't move: it closes with `room_closed`, the error is reported, and the drain moves on.
982
+ 3. **Offer.** The snapshot is parked in Redis with a 60 second TTL, the Room's lock is released,
983
+ and a provision request flagged `handoff` goes out. Every peer hears it and races for the lock
984
+ the same load-weighted way it does for a new Room; the draining host never claims.
985
+ 4. **Adopt.** The winner rebuilds the Room from the snapshot, replays the relayed messages, and
986
+ takes over the inbound subscription. A short exchange of markers between the two hosts decides,
987
+ message by message, which of them runs each one, so nothing is lost, doubled, or reordered.
988
+ 5. **Let go.** The old host drops its copy without a word to the members and reports
989
+ `room_migrated.cable_room`.
990
+
991
+ Nobody adopts within `handoff_timeout` (default 10 seconds) — a fleet of one, say — and the Room
992
+ takes its lock back and closes with `room_closed`; members re-provision on their next ping and get
993
+ a fresh Room somewhere. Rooms go oldest first, four at a time. When `drain_timeout` (default 10
994
+ minutes) passes with Rooms still waiting, all of them are offered at once, so the drain ends within
995
+ about one more `handoff_timeout` whatever the fleet does. Then the host shuts down.
996
+
997
+ Both timeouts are config knobs (`c.handoff_timeout`, `c.drain_timeout`). To know when a drain has
998
+ finished — to complete an ASG termination lifecycle action, say — register a callback:
999
+
1000
+ ```ruby
1001
+ CableRoom::Host.after_drain do |host, result|
1002
+ result.migrated # the Rooms that moved (CableRoom::Migration objects)
1003
+ result.closed # the Rooms that closed instead
1004
+ result.duration # seconds
1005
+ Aws::AutoScaling::Client.new.complete_lifecycle_action(...)
1006
+ end
1007
+ ```
1008
+
1009
+ The container has to give the process that long. With CodeDeploy, set the `ApplicationStop` hook
1010
+ timeout to `drain_timeout` plus two minutes; on an ASG, put a termination lifecycle hook in front of
1011
+ the instance with a heartbeat timeout at least as long, and complete it from `after_drain`. The
1012
+ `--workers N` supervisor relays the signal to every worker and waits for all of them, without a
1013
+ deadline of its own.
1014
+
1015
+ In `inline` mode the gem doesn't migrate on exit: there's no fleet of Room hosts by design, and a
1016
+ web process that exits closes its Rooms the way it always has. An app that runs several inline
1017
+ processes can still call `drain!` from its own signal handling; other inline processes will adopt.
1018
+
1019
+ ## Introspection.
1020
+
1021
+ ```ruby
1022
+ CableRoom::Room.locally_open_rooms # every Room running in this process
1023
+ QuizRoom.locally_running_instances # just the QuizRooms
1024
+ CableRoom::Host.current # this process's Host, or nil if it hosts no Rooms
1025
+ CableRoom::Host.current&.open_ports # member ports attached across every local Room
1026
+ ```
1027
+
1028
+ All of these are process-local. There's no cluster-wide registry — the Redis lock is the only
1029
+ source of truth about who owns a key.
1030
+
1031
+ ## Subclassing.
1032
+
1033
+ Room classes build a private `PortClient` for each subclass, chained to the parent's.
1034
+ Periodic timers, callbacks, policies, and tag aliases all inherit correctly through
1035
+ however many levels you need:
1036
+
1037
+ ```ruby
1038
+ class BaseGameRoom < CableRoom::Room::Base
1039
+ periodically :tick, every: 1.second
1040
+ reap_when { connected_users.empty? }
1041
+ end
1042
+
1043
+ class TriviaRoom < BaseGameRoom
1044
+ # keeps tick and the reaper, adds its own
1045
+ periodically :rotate_question, every: 30.seconds
1046
+ end
1047
+ ```
1048
+
1049
+ Note that a Room's pubsub keys derive from its class name, so anonymous Room classes won't work.
1050
+
1051
+ ## Upgrading from 0.6 to 1.0.
1052
+
1053
+ 1.0 is one gem release, and an app flips every knob in one deploy. Room definitions don't change.
1054
+ The channel contract and the deployment do. `CHANGELOG.md` lists every breaking change; this is
1055
+ the order to work through them.
1056
+
1057
+ ### Before the deploy.
1058
+
1059
+ 1. **Send `hello` from every client.** Add `connected() { this.perform("hello") }` to each
1060
+ subscription that joins a Room. Nothing is announced until it arrives, and there's no
1061
+ fallback. Shipping it early is harmless: a 0.6 server logs an unknown action and carries on.
1062
+ Shipping it late isn't: a 1.0 server never hears a member that doesn't say it.
1063
+ 2. **Plan for open tabs.** A browser tab open across the deploy speaks the old handshake. It
1064
+ still receives the shared stream, but the Room never sees it and drops everything it sends.
1065
+ Deploy when few people are in a Room, and have the client show "reload to continue" when it
1066
+ gets `room_closed` after the deploy.
1067
+ 3. **Find internal names you reached for.** Inside a Room, `@cable_channel` is `@runner`; an
1068
+ error handler reading `context[:channel]` should read `context[:runner]`; `Room#params` is
1069
+ gone; `ChannelTracker::BEAT_INTERVAL` is `Host::BEAT_INTERVAL`. Anything that called
1070
+ `MyRoom.ensure` from a web process needs a look: with `room_host = :remote` it raises
1071
+ `CableRoom::Host::NotHosting`, and a `create: true` join provisions the Room for you.
1072
+ 4. **Give the bus a Redis every process can reach.** `CABLEROOM_REDIS_URL` (or its `REDIS_URL`
1073
+ fallback) now carries member→room traffic, not just locks. A web process and a rooms host
1074
+ that can't both see it can't talk. Same server as ActionCable is fine.
1075
+ 5. **Check `PORT_TIMEOUT`.** It's 45 seconds now, not 30. If you tuned client ping intervals or
1076
+ tests around the old number, adjust them.
1077
+ 6. **Check `room_closed` handling.** The `reason` is never `nil` any more. A client that
1078
+ treated `reason: nil` as "server restart" should look for `"Server shutting down"` or the
1079
+ signal name instead.
1080
+ 7. **Add `snapshot_state` and `restore_state`** to any Room whose in-memory state should
1081
+ survive a host moving it. Rooms without them still move; they run `startup` again. Do this
1082
+ before turning on `:remote`, or the first drain rebuilds every Room from scratch.
1083
+ 8. **Decide the deployment mode.** Stay on `inline` + `action_cable` and nothing else changes.
1084
+ For `remote`:
1085
+ - run `bundle exec cable_room server --workers N` on the rooms hosts under a deadline the
1086
+ container owns (`timeout -k 2m 24h ...`);
1087
+ - set `CABLE_ROOM_HOST=remote` on web and rooms hosts alike;
1088
+ - give the container `drain_timeout` plus two minutes to stop, and complete any ASG
1089
+ termination lifecycle hook from `CableRoom::Host.after_drain`;
1090
+ - publish `CableRoom::Host.current.open_ports` as the gauge to scale on.
1091
+
1092
+ For `anycable`, also add `gem "anycable-rails"`, run anycable-go and the `anycable` RPC next
1093
+ to each web process, set `CABLE_ROOM_BROADCASTER=anycable` on web and rooms hosts, set
1094
+ `ANYCABLE_BROADCAST_ADAPTER=redis` and `ANYCABLE_REDIS_URL`, and make the browser drive
1095
+ liveness: `perform("ping")` every 15 seconds and a second `hello` if `port_acknowledged`
1096
+ doesn't arrive (see [What the browser must send](#what-the-browser-must-send)).
1097
+
1098
+ ### After the deploy.
1099
+
1100
+ - Watch `hello_received.cable_room` and `port_connected.cable_room`. Members that subscribe but
1101
+ never `hello` are the old tabs.
1102
+ - In `remote`, watch `provision_claimed.cable_room` with `claimed: true` on the rooms hosts and
1103
+ `CableRoom::Room.locally_open_rooms` staying empty on web.
1104
+ - Trigger one planned restart of a rooms host and confirm `room_migrated.cable_room` fires for
1105
+ each open Room with `closed: 0` in `host_draining.cable_room`.
1106
+
1107
+ ### Attribution runbook.
1108
+
1109
+ If load or latency regresses after the all-knobs deploy, several things changed at once. Flip
1110
+ them back one at a time, in this order, and re-measure after each:
1111
+
1112
+ 1. **`broadcaster` back to `action_cable`** (`CABLE_ROOM_BROADCASTER=action_cable` on web and
1113
+ rooms hosts, and route `/cable` back to ActionCable; one deploy). Rooms stay remote. If this
1114
+ fixes it, the problem is in the anycable-go path.
1115
+ 2. **`room_host` back to `inline`** (`CABLE_ROOM_HOST=inline`, one deploy; stop the
1116
+ `cable_room server` pool). If this fixes it, the problem is in the remote hosting path: bus
1117
+ latency, provisioning, or the rooms pool's sizing.
1118
+
1119
+ Both are env changes, so neither needs a code change or a gem downgrade. `hello` stays either
1120
+ way; it's part of 1.0 in every mode.
1121
+
1122
+ ## Development.
1123
+
1124
+ Rooms need Redis and, for the test suite, Postgres:
1125
+
1126
+ ```sh
1127
+ bundle install
1128
+ bundle exec rspec
1129
+ ```
1130
+
1131
+ To run against every supported Rails version:
1132
+
1133
+ ```sh
1134
+ bundle exec appraisal install
1135
+ bundle exec appraisal rspec
1136
+ ```
1137
+
1138
+ The suite has two halves. Unit specs use `RoomHarness#build_room`, which runs a Room against a
1139
+ stub channel with no Redis and no pubsub, so logic is testable synchronously. End-to-end specs
1140
+ run the async ActionCable adapter and real message delivery, and wait on observable conditions
1141
+ with `wait_until` rather than sleeping.
1142
+
1143
+ `spec/internal` holds a Combustion app, so `rackup` boots a minimal Rails host if you want to
1144
+ poke at Rooms by hand.
1145
+
1146
+ ### The anycable-go E2E.
1147
+
1148
+ `spec/e2e` proves the member protocol on a real wire: a WebSocket client subscribes through
1149
+ anycable-go, says `hello`, gets `port_acknowledged`, sends a message and gets the Room's reply,
1150
+ pings, and unsubscribes or closes, with anycable-rails rebuilding the channel from its state on
1151
+ every call. It's excluded from `bundle exec rspec` and runs only when `CABLE_ROOM_E2E=1` is set.
1152
+ CI runs it in its own job. `spec/cable_room/anycable_channel_state_spec.rb` drives the same
1153
+ anycable-rails RPC handler in-process, without anycable-go, and is part of the default suite.
1154
+
1155
+ Locally it needs Redis and anycable-go. Start anycable-go from Docker, pointed at the RPC server
1156
+ the spec starts inside its own process:
1157
+
1158
+ ```sh
1159
+ docker run --rm -p 8080:8080 \
1160
+ -e ANYCABLE_HOST=0.0.0.0 \
1161
+ -e ANYCABLE_RPC_HOST=host.docker.internal:50051 \
1162
+ -e ANYCABLE_BROADCAST_ADAPTER=redis \
1163
+ -e ANYCABLE_REDIS_URL=redis://host.docker.internal:6379/0 \
1164
+ anycable/anycable-go:1.6
1165
+ ```
1166
+
1167
+ Then, in another shell:
1168
+
1169
+ ```sh
1170
+ CABLE_ROOM_E2E=1 ANYCABLE_REDIS_URL=redis://localhost:6379/0 bundle exec rspec spec/e2e
1171
+ ```
1172
+
1173
+ The spec process is the Rails app, the AnyCable RPC server (listening on `0.0.0.0:50051`; set
1174
+ `ANYCABLE_RPC_HOST` to change it), and the room host. Set `CABLE_ROOM_E2E_WS_URL` if anycable-go
1175
+ isn't at `ws://127.0.0.1:8080/cable`. If anycable-go isn't reachable the spec fails; it never
1176
+ skips.
1177
+
1178
+ Things that bite on a laptop:
1179
+
1180
+ - A Homebrew Redis listens on `127.0.0.1` only, so the container can't reach it. Run one from
1181
+ Docker instead (`docker run -d -p 6380:6379 redis:7.2`) and point both `ANYCABLE_REDIS_URL`s at
1182
+ port 6380.
1183
+ - On plain Docker Engine (Linux) add `--add-host host.docker.internal:host-gateway`. Don't on
1184
+ Docker Desktop or Rancher Desktop; there it overrides the built-in name with the VM's bridge
1185
+ address and the RPC becomes unreachable.
1186
+ - If something else already has port 8080 (an ssh tunnel, VS Code's port forwarding), publish a
1187
+ different one (`-p 18080:8080`) and set `CABLE_ROOM_E2E_WS_URL=ws://127.0.0.1:18080/cable`. If a
1188
+ tool grabs the loopback port after Docker does, use your machine's LAN address in the URL.
1189
+ - Restart anycable-go between runs. Each rspec process stops its RPC on exit, and anycable-go's
1190
+ gRPC client then backs off for minutes; until it reconnects every socket is closed with "Auth
1191
+ Error". CI gets a fresh container per run for the same reason.