cable_room 0.6.2.beta1 → 0.6.2.beta3
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/README.md +0 -642
- data/cable_room.gemspec +0 -0
- data/lib/cable_room/ports.rb +2 -40
- data/lib/cable_room/room/port_management.rb +17 -3
- data/lib/cable_room/room_member.rb +27 -63
- data/lib/cable_room/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5984faba13ce4bcbfc7cdcde1eee9a268ca8ea59227efd2cbe41532d67892782
|
|
4
|
+
data.tar.gz: 651af8589e9709f27e3cfee877fe2359c931919d3c853c74d097147b245d37f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f85aa97a935a9cc11049b5ce408f06cacca2ff1309800e13b4b4a9b750c68612d3991410f081e5c99a43b0282bbc42071418d856d728c3bda03381ec48ba85c
|
|
7
|
+
data.tar.gz: f94b0ac04d23f0bbb411b7f1e9432b09f778756046bcc6b6f3ed02557ca1020f97dc62e6c238b4259d581a92647cba726251037d2b59fd2c93c884c139a59ed4
|
data/README.md
CHANGED
|
@@ -1,643 +1 @@
|
|
|
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
|
-
- [Development](#development)
|
|
32
|
-
|
|
33
|
-
## How it works.
|
|
34
|
-
|
|
35
|
-
Every Room is backed by a synthetic ActionCable channel that has no browser connection behind it.
|
|
36
|
-
That channel holds a Redis lock on the Room's key, which is what guarantees a single instance
|
|
37
|
-
cluster-wide. Members don't talk to the Room directly. They publish to, and subscribe from, Redis
|
|
38
|
-
pubsub keys called ports.
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
Browser Your ActionCable Channel The Room
|
|
42
|
-
| (include RoomMember) (Room::Base subclass)
|
|
43
|
-
| | |
|
|
44
|
-
| --- websocket message --> | --- to_room port -------------> | handle_received_message
|
|
45
|
-
| | |
|
|
46
|
-
| <-- websocket message --- | <-- from_room port ----------- | broadcast / self <<
|
|
47
|
-
| | <-- <token> port ------------- | reply
|
|
48
|
-
| | <-- <user> / <tag> port ------ | broadcast(tag: :admin)
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Four things follow from that design:
|
|
52
|
-
|
|
53
|
-
- **One instance, many processes.** Any process can call `MyRoom.ensure(key)`. The first one to
|
|
54
|
-
win the Redis lock runs the Room; the rest get `false` and just publish to its ports.
|
|
55
|
-
- **No connection affinity.** Members can be spread across every app server. They only need Redis.
|
|
56
|
-
- **Single-threaded Room state.** Messages and timers run one at a time, so you can touch
|
|
57
|
-
instance variables without locks.
|
|
58
|
-
- **Rooms are disposable.** A Room is expected to die when it's idle and be re-created on demand.
|
|
59
|
-
Persist anything you can't lose.
|
|
60
|
-
|
|
61
|
-
## Requirements.
|
|
62
|
-
|
|
63
|
-
- Ruby 3.4 (CI runs 3.4)
|
|
64
|
-
- Rails 7.2 through 8.x
|
|
65
|
-
- Redis, for both the ActionCable adapter and the Room locks
|
|
66
|
-
|
|
67
|
-
## Installation.
|
|
68
|
-
|
|
69
|
-
```ruby
|
|
70
|
-
gem "cable_room"
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Then point it at Redis:
|
|
74
|
-
|
|
75
|
-
```sh
|
|
76
|
-
export CABLEROOM_REDIS_URL=redis://localhost:6379/1
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
See [Configuration](#configuration) for the full list of variables.
|
|
80
|
-
|
|
81
|
-
## Quick start.
|
|
82
|
-
|
|
83
|
-
Define a Room:
|
|
84
|
-
|
|
85
|
-
```ruby
|
|
86
|
-
class ChatRoom < CableRoom::Room::Base
|
|
87
|
-
# Shut down 30 seconds after the last member leaves
|
|
88
|
-
reap_when { connected_clients.empty? }
|
|
89
|
-
|
|
90
|
-
after_startup do
|
|
91
|
-
@history = []
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Handles { "type": "chat", "body": "..." } from any member
|
|
95
|
-
def on_chat(msg)
|
|
96
|
-
entry = { user: message_origin.user, body: msg["body"], at: Time.current }
|
|
97
|
-
@history << entry
|
|
98
|
-
broadcast({ type: "chat", **entry })
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Send the backlog only to the member who just connected
|
|
102
|
-
on_port_connected do
|
|
103
|
-
reply({ type: "history", entries: @history })
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
Define a channel that joins it:
|
|
109
|
-
|
|
110
|
-
```ruby
|
|
111
|
-
class ChatChannel < ApplicationCable::Channel
|
|
112
|
-
include CableRoom::RoomProxyChannel
|
|
113
|
-
|
|
114
|
-
subscribe_to_room do
|
|
115
|
-
join_room ChatRoom, params[:room_id], create: true, tags: params[:tags]
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
That's the whole loop. `RoomProxyChannel` forwards everything the client sends into the Room and
|
|
121
|
-
everything the Room broadcasts back out to the client. `create: true` means this channel will
|
|
122
|
-
provision the Room if it isn't already running somewhere.
|
|
123
|
-
|
|
124
|
-
## Defining a Room.
|
|
125
|
-
|
|
126
|
-
### Lifecycle.
|
|
127
|
-
|
|
128
|
-
```ruby
|
|
129
|
-
class MyRoom < CableRoom::Room::Base
|
|
130
|
-
before_startup { } # streams aren't open yet
|
|
131
|
-
after_startup { } # aliased as on_startup
|
|
132
|
-
before_shutdown { } # last chance to broadcast
|
|
133
|
-
after_shutdown { } # aliased as on_shutdown
|
|
134
|
-
end
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
You can also just define `startup` and `shutdown` methods; they run inside the corresponding
|
|
138
|
-
callback chain.
|
|
139
|
-
|
|
140
|
-
Out of the box a Room broadcasts `{ type: "room_opened" }` after startup and
|
|
141
|
-
`{ type: "room_closed", reason: ... }` before shutdown.
|
|
142
|
-
|
|
143
|
-
To stop a Room from inside itself:
|
|
144
|
-
|
|
145
|
-
```ruby
|
|
146
|
-
shutdown!("everyone left") # graceful: drains queued messages first
|
|
147
|
-
stop! # immediate: drops anything pending
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
`lifecycle_state` returns `:initializing`, `:starting`, `:started`, `:shutting_down`, or `:dead`.
|
|
151
|
-
|
|
152
|
-
### Handling messages.
|
|
153
|
-
|
|
154
|
-
Inbound messages arrive on the `:to_room` port and dispatch by `type`. A message of type
|
|
155
|
-
`"start_quiz"` (or `"StartQuiz"`) calls `on_start_quiz`. Unknown types log a warning.
|
|
156
|
-
|
|
157
|
-
```ruby
|
|
158
|
-
def on_start_quiz(msg)
|
|
159
|
-
logger.info "starting with #{msg['question_count']} questions"
|
|
160
|
-
end
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Inside a handler:
|
|
164
|
-
|
|
165
|
-
| Helper | What it gives you |
|
|
166
|
-
| ----------------- | ------------------------------------------------------- |
|
|
167
|
-
| `message` | The raw message hash |
|
|
168
|
-
| `message_origin` | The `PortClient` that sent it |
|
|
169
|
-
| `reply(data)` | Send back to that port alone |
|
|
170
|
-
| `broadcast(data)` | Send to every member |
|
|
171
|
-
|
|
172
|
-
Override `handle_received_message(message)` if you'd rather dispatch yourself. Call `super` for
|
|
173
|
-
anything you don't handle, so the built-in port and user bookkeeping keeps working.
|
|
174
|
-
|
|
175
|
-
Sending the string `"KILL"` to the `:to_room` port shuts the Room down. It's a blunt instrument,
|
|
176
|
-
useful in a console.
|
|
177
|
-
|
|
178
|
-
### Timers.
|
|
179
|
-
|
|
180
|
-
```ruby
|
|
181
|
-
class MyRoom < CableRoom::Room::Base
|
|
182
|
-
periodically :tick, every: 5.seconds
|
|
183
|
-
periodically -> { broadcast({ type: "still_here" }) }, every: 1.minute
|
|
184
|
-
|
|
185
|
-
def tick; end
|
|
186
|
-
end
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
Timer bodies run on the Room's thread, so they're serialized against message handling.
|
|
190
|
-
|
|
191
|
-
## Ports and messaging.
|
|
192
|
-
|
|
193
|
-
A port is one Redis pubsub key derived from the Room class, the Room key, and a port name. Two
|
|
194
|
-
are reserved:
|
|
195
|
-
|
|
196
|
-
- `:to_room` — many-to-one. Members publish here; the Room streams from it.
|
|
197
|
-
- `:from_room` — one-to-many. The Room publishes here; every member streams from it.
|
|
198
|
-
|
|
199
|
-
Every member also gets a private port named after its random token, plus a port for the user it
|
|
200
|
-
joined as and one for each tag it carries. That's how targeted delivery works without the Room
|
|
201
|
-
tracking connections.
|
|
202
|
-
|
|
203
|
-
### Sending.
|
|
204
|
-
|
|
205
|
-
```ruby
|
|
206
|
-
self << { type: "tick" } # to :from_room, i.e. everyone
|
|
207
|
-
broadcast({ type: "tick" }) # same thing
|
|
208
|
-
broadcast({ type: "secret" }, client_port: token) # one port
|
|
209
|
-
broadcast({ type: "hi" }, user: "user_42") # every port that user joined from
|
|
210
|
-
broadcast({ type: "tools" }, tag: :admin) # every port carrying the tag
|
|
211
|
-
reply({ type: "pong" }) # the port whose message you're handling
|
|
212
|
-
message_origin << { type: "pong" } # the same, spelled differently
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
Combining a target with a tag makes the tag a filter, not a second audience.
|
|
216
|
-
`broadcast(msg, user: "user_42", tag: :admin)` reaches that user only if one of their ports is
|
|
217
|
-
tagged `admin`, and sends nothing otherwise.
|
|
218
|
-
|
|
219
|
-
### Scoping.
|
|
220
|
-
|
|
221
|
-
`with_port_scope` sets an ambient target so nested code doesn't have to pass it around:
|
|
222
|
-
|
|
223
|
-
```ruby
|
|
224
|
-
with_port_scope(tag: :admin) do
|
|
225
|
-
broadcast({ type: "diagnostics", data: expensive_report })
|
|
226
|
-
end
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
Scopes merge when nested. `without_port_scope` clears them. `with_port_scope!` skips the block
|
|
230
|
-
entirely when nothing matches, which is the cheap way to avoid building a payload nobody will
|
|
231
|
-
receive. The block form of `reply` does the same for a single port:
|
|
232
|
-
|
|
233
|
-
```ruby
|
|
234
|
-
reply do
|
|
235
|
-
broadcast({ type: "a" })
|
|
236
|
-
broadcast({ type: "b" })
|
|
237
|
-
end
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
### Custom ports.
|
|
241
|
-
|
|
242
|
-
Ports aren't limited to the built-ins. Open your own for a side channel:
|
|
243
|
-
|
|
244
|
-
```ruby
|
|
245
|
-
ports[:telemetry] << { fps: 60 }
|
|
246
|
-
|
|
247
|
-
stream_port(:control) do |message|
|
|
248
|
-
logger.info "control: #{message.inspect}"
|
|
249
|
-
end
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
Ports opened with `stream_port` close automatically at shutdown.
|
|
253
|
-
|
|
254
|
-
### Port liveness.
|
|
255
|
-
|
|
256
|
-
Members ping every 10 seconds. A port that goes quiet for 30 seconds
|
|
257
|
-
(`PortManagement::PORT_TIMEOUT`) is dropped, and `on_port_disconnected` runs for it with
|
|
258
|
-
`message_origin` still set, so cleanup can tell which port went away.
|
|
259
|
-
|
|
260
|
-
```ruby
|
|
261
|
-
on_port_connected { logger.info "port #{message_origin.token} joined" }
|
|
262
|
-
on_port_disconnected { logger.info "port #{message_origin.token} gone" }
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
`connected_clients` returns the live `PortClient` objects. Each one carries a `token`, its `tags`,
|
|
266
|
-
its `user`, and any extra metadata the member passed in. Read and write metadata with `[]` and
|
|
267
|
-
`[]=`.
|
|
268
|
-
|
|
269
|
-
## Users.
|
|
270
|
-
|
|
271
|
-
Members can join *as* a user. CableRoom then collapses that user's ports into a single identity,
|
|
272
|
-
so a person with three browser tabs joins once and leaves once.
|
|
273
|
-
|
|
274
|
-
```ruby
|
|
275
|
-
class MyRoom < CableRoom::Room::Base
|
|
276
|
-
on_user_joined { broadcast({ type: "joined", user: message_origin.user }) }
|
|
277
|
-
on_user_left { broadcast({ type: "left", user: message_origin.user }) }
|
|
278
|
-
end
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
`on_user_joined` fires on the first port for that user; `on_user_left` fires when the last one
|
|
282
|
-
goes away. `connected_users` lists them, and `all_user_tags(user)` unions the tags across every
|
|
283
|
-
port that user is connected from.
|
|
284
|
-
|
|
285
|
-
A `RoomMember` channel that defines `current_user` passes it automatically. Pass `as:` to override
|
|
286
|
-
it, or `as: nil` for an anonymous port. The value is serialized with ActiveJob's argument
|
|
287
|
-
serializer, so an ActiveRecord object survives the trip and arrives as the same record.
|
|
288
|
-
|
|
289
|
-
## Authorization.
|
|
290
|
-
|
|
291
|
-
Two layers, and they compose. Use guards for anything that depends on the message; use tag
|
|
292
|
-
policies for anything that depends on who's asking.
|
|
293
|
-
|
|
294
|
-
### Guards.
|
|
295
|
-
|
|
296
|
-
```ruby
|
|
297
|
-
class MyRoom < CableRoom::Room::Base
|
|
298
|
-
# Block, symbol, or proc. Return false to drop the message.
|
|
299
|
-
authorize_inbound { |message| message["body"].to_s.length < 1_000 }
|
|
300
|
-
authorize_inbound :quiz_running?, only: [:answer, :skip]
|
|
301
|
-
authorize_inbound :not_locked?, except: :leave
|
|
302
|
-
|
|
303
|
-
protected
|
|
304
|
-
|
|
305
|
-
# Zero-arity guards read `message` themselves
|
|
306
|
-
def quiz_running? = @state == :running
|
|
307
|
-
def not_locked?(msg) = !@locked
|
|
308
|
-
end
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
A dropped message logs a warning and never reaches a handler.
|
|
312
|
-
|
|
313
|
-
### Tag policies.
|
|
314
|
-
|
|
315
|
-
Members join with tags (`join_room MyRoom, key, tags: [:admin]`). Policies then say which tags
|
|
316
|
-
may trigger which handlers.
|
|
317
|
-
|
|
318
|
-
```ruby
|
|
319
|
-
class MyRoom < CableRoom::Room::Base
|
|
320
|
-
inbound_tag_policy do
|
|
321
|
-
deny :muted, :chat # muted members can't chat...
|
|
322
|
-
allow :*, :chat # ...but everyone else can
|
|
323
|
-
allow :admin, [:kick, :ban] # admins get the moderation verbs
|
|
324
|
-
end
|
|
325
|
-
end
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
Two rules govern how this resolves:
|
|
329
|
-
|
|
330
|
-
1. **Declaring any policy flips the default to deny.** Before you write one, everything is
|
|
331
|
-
allowed. After, only what you allow is allowed. The built-in connection and user messages stay
|
|
332
|
-
permitted, so members can still join and leave.
|
|
333
|
-
2. **Highest priority wins.** Rules default to priority 10. Pass `priority:` to layer a base
|
|
334
|
-
policy under, or an override over, another. `inbound_tag_policy(priority: -10)` adds
|
|
335
|
-
permissions without flipping the default.
|
|
336
|
-
|
|
337
|
-
Within a priority, the first matching rule decides, and rules match in declaration order. That
|
|
338
|
-
means a `deny` exception has to come **before** the broad `allow` it carves out of — write
|
|
339
|
-
`allow :*, :chat` first and it swallows every member, muted ones included. When the ordering
|
|
340
|
-
matters a lot, give the two rules different priorities instead of relying on where they sit in
|
|
341
|
-
the block:
|
|
342
|
-
|
|
343
|
-
```ruby
|
|
344
|
-
inbound_tag_policy(priority: 20) { deny :muted, :chat }
|
|
345
|
-
inbound_tag_policy(priority: 10) { allow :*, :chat }
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
Group related handlers behind one name with `define_tag_alias`. A rule written against the alias
|
|
349
|
-
covers everything it implies:
|
|
350
|
-
|
|
351
|
-
```ruby
|
|
352
|
-
class MyRoom < CableRoom::Room::Base
|
|
353
|
-
define_tag_alias :moderation, [:kick, :ban, :mute]
|
|
354
|
-
|
|
355
|
-
inbound_tag_policy do
|
|
356
|
-
allow :admin, :moderation
|
|
357
|
-
end
|
|
358
|
-
end
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
Aliases are per Room class and inherited by subclasses, so one Room's vocabulary can't change how
|
|
362
|
-
another Room reads its policies.
|
|
363
|
-
|
|
364
|
-
### System message types.
|
|
365
|
-
|
|
366
|
-
Some message types are the framework's, not the client's. Members can't forge them:
|
|
367
|
-
|
|
368
|
-
```ruby
|
|
369
|
-
class MyRoom < CableRoom::Room::Base
|
|
370
|
-
system_message_types :score_awarded, :quiz_finished
|
|
371
|
-
end
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
Attempts to send one from a member are dropped with a warning at the sender. `port_connected`,
|
|
375
|
-
`port_disconnected`, `port_ping`, `user_joined`, and `user_left` are already protected.
|
|
376
|
-
|
|
377
|
-
## Reaping and the watchdog.
|
|
378
|
-
|
|
379
|
-
Rooms hold memory and a Redis lock, so they need to know when to quit. `reap_when` declares a
|
|
380
|
-
check that runs on a timer:
|
|
381
|
-
|
|
382
|
-
```ruby
|
|
383
|
-
class MyRoom < CableRoom::Room::Base
|
|
384
|
-
# Idle for 30 seconds with nobody connected -> shut down
|
|
385
|
-
reap_when { connected_clients.empty? }
|
|
386
|
-
|
|
387
|
-
# Tighter window, and named so the shutdown reason says which check fired
|
|
388
|
-
reap_when(key: :abandoned, grace: 5.minutes, interval: 30.seconds) do
|
|
389
|
-
connected_users.empty?
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
# Return :reap to skip the grace period entirely
|
|
393
|
-
reap_when(grace: 1.hour) { @cancelled ? :reap : false }
|
|
394
|
-
end
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
- **Truthy** starts the grace clock. Once the condition has held for `grace:` (default 30
|
|
398
|
-
seconds), the Room shuts down.
|
|
399
|
-
- **Falsey** resets the clock and pings the watchdog.
|
|
400
|
-
- **`:reap`** shuts down now, whatever the grace period says.
|
|
401
|
-
|
|
402
|
-
Declare as many checks as you like; each gets its own timer and its own grace clock. Call
|
|
403
|
-
`check_reapers_now!` to run them all immediately instead of waiting for the next tick.
|
|
404
|
-
|
|
405
|
-
### The watchdog.
|
|
406
|
-
|
|
407
|
-
Separately, every Room is supervised. Every five seconds its channel extends the Redis lock and
|
|
408
|
-
confirms the Room has pinged its watchdog within the last 15 seconds
|
|
409
|
-
(`Room::Base::WATCH_DOG_INTERVAL`). Lose the lock and the Room stops, since another process may
|
|
410
|
-
now own the key. Miss the ping and it shuts down as wedged.
|
|
411
|
-
|
|
412
|
-
**Reaper checks are what ping the watchdog.** A Room that declares no `reap_when` has nothing
|
|
413
|
-
pinging it, so the watchdog will shut it down about 15 seconds after startup. Every long-lived
|
|
414
|
-
Room needs at least one `reap_when` — or its own timer calling `ping_watchdog` — to stay up.
|
|
415
|
-
|
|
416
|
-
## Joining a Room from a channel.
|
|
417
|
-
|
|
418
|
-
### The proxy shortcut.
|
|
419
|
-
|
|
420
|
-
When the client only needs a pipe to the Room, `RoomProxyChannel` is the whole channel:
|
|
421
|
-
|
|
422
|
-
```ruby
|
|
423
|
-
class QuizChannel < ApplicationCable::Channel
|
|
424
|
-
include CableRoom::RoomProxyChannel
|
|
425
|
-
|
|
426
|
-
subscribe_to_room do
|
|
427
|
-
join_room QuizRoom, params[:quiz_id], create: true
|
|
428
|
-
end
|
|
429
|
-
end
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
It wires up `subscribed`, `receive`, and `unsubscribed`, and forwards messages both ways.
|
|
433
|
-
|
|
434
|
-
### Full control.
|
|
435
|
-
|
|
436
|
-
`RoomMember` gives you the membership without the forwarding, so the channel can filter,
|
|
437
|
-
transform, or fan out:
|
|
438
|
-
|
|
439
|
-
```ruby
|
|
440
|
-
class QuizChannel < ApplicationCable::Channel
|
|
441
|
-
include CableRoom::RoomMember
|
|
442
|
-
|
|
443
|
-
def subscribed
|
|
444
|
-
@membership = join_room(
|
|
445
|
-
QuizRoom,
|
|
446
|
-
params[:quiz_id],
|
|
447
|
-
create: true,
|
|
448
|
-
tags: current_user.teacher? ? [:admin] : [:student],
|
|
449
|
-
extra: { device: params[:device] },
|
|
450
|
-
on_joined: ->(m) { transmit(type: "ready") },
|
|
451
|
-
on_message: ->(msg) { transmit(msg) if msg["type"] != "internal" },
|
|
452
|
-
on_room_closed: ->(m) { transmit(type: "over") },
|
|
453
|
-
on_left: ->(m) { logger.info "left #{m.key}" }
|
|
454
|
-
)
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
def answer(data)
|
|
458
|
-
@membership << { type: "answer", choice: data["choice"] }
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
def unsubscribed
|
|
462
|
-
@membership&.leave!
|
|
463
|
-
end
|
|
464
|
-
end
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
`join_room` options:
|
|
468
|
-
|
|
469
|
-
| Option | Meaning |
|
|
470
|
-
| ------------------------------- | -------------------------------------------------------------------- |
|
|
471
|
-
| `create:` | Provision the Room if it isn't running. Defaults to `false`. |
|
|
472
|
-
| `as:` | The user identity. Defaults to `current_user` when the channel has one. |
|
|
473
|
-
| `tags:` | Tags this port carries, for policies and targeted broadcasts. |
|
|
474
|
-
| `extra:` | Extra metadata, readable on the Room's `PortClient`. |
|
|
475
|
-
| `forward:` | Pipe every Room message straight to the websocket. |
|
|
476
|
-
| `on_joined:` | The Room acknowledged this port. |
|
|
477
|
-
| `on_message:` | Any message from the Room. |
|
|
478
|
-
| `on_room_opened:` | The Room opened while we were connecting. Not guaranteed. |
|
|
479
|
-
| `on_room_closed:` | The Room closed while we were connected. Not guaranteed. |
|
|
480
|
-
| `on_left:` | This membership ended. |
|
|
481
|
-
|
|
482
|
-
The returned membership responds to `<<`, `connected?`, `left?`, `key`, `ping!`, `leave!`, and
|
|
483
|
-
`rejoin!`.
|
|
484
|
-
|
|
485
|
-
With `create: true`, provisioning is retried on every ping, not just at join. If the Room's host
|
|
486
|
-
process dies, the next ping from any member brings it back somewhere else.
|
|
487
|
-
|
|
488
|
-
### From outside a channel.
|
|
489
|
-
|
|
490
|
-
```ruby
|
|
491
|
-
QuizRoom.ensure("quiz_9") # => true if this process now runs it
|
|
492
|
-
QuizRoom.send_message("quiz_9", { type: "extend", by: 60 }) # publish to :to_room
|
|
493
|
-
QuizRoom.room_port_key("quiz_9", :from_room) # the raw pubsub key
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
## Background work.
|
|
497
|
-
|
|
498
|
-
A Room is single-threaded on purpose. Slow work belongs off its thread:
|
|
499
|
-
|
|
500
|
-
```ruby
|
|
501
|
-
def on_export(msg)
|
|
502
|
-
token = message_origin.token # capture before leaving the Room's thread
|
|
503
|
-
|
|
504
|
-
async do
|
|
505
|
-
report = build_expensive_report
|
|
506
|
-
|
|
507
|
-
on_room_thread do
|
|
508
|
-
broadcast({ type: "export_ready", url: report.url }, client_port: token)
|
|
509
|
-
end
|
|
510
|
-
end
|
|
511
|
-
end
|
|
512
|
-
```
|
|
513
|
-
|
|
514
|
-
`async` borrows a thread from the pool shared by every Room in the process and runs concurrently
|
|
515
|
-
with the Room, so **the block must not touch Room state.** Capture what it needs first. Inside
|
|
516
|
-
it, `message` is nil, and `message_origin` and `reply` point at whatever the Room is handling
|
|
517
|
-
*now* rather than what it was handling when you called `async`.
|
|
518
|
-
|
|
519
|
-
`on_room_thread` queues work back onto the Room's thread, where state is safe again. Prefer
|
|
520
|
-
handing results back that way over blocking on `async` work, since a blocked Room thread can
|
|
521
|
-
starve its neighbours.
|
|
522
|
-
|
|
523
|
-
## Instrumentation and errors.
|
|
524
|
-
|
|
525
|
-
Rooms swallow exceptions so one bad message can't take the Room down. That makes the error
|
|
526
|
-
handler the only place you'll hear about it:
|
|
527
|
-
|
|
528
|
-
```ruby
|
|
529
|
-
CableRoom.error_handler = ->(error, context) do
|
|
530
|
-
Sentry.capture_exception(error, extra: context)
|
|
531
|
-
end
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
The context includes the Room, its class, its key, and the channel. An
|
|
535
|
-
`error.cable_room` notification fires either way.
|
|
536
|
-
|
|
537
|
-
ActiveSupport notifications:
|
|
538
|
-
|
|
539
|
-
| Event | Payload |
|
|
540
|
-
| -------------------------------- | -------------------------- |
|
|
541
|
-
| `room_opened.cable_room` | `room` |
|
|
542
|
-
| `room_closed.cable_room` | `room`, `reason` |
|
|
543
|
-
| `message_received.cable_room` | `room`, `message` |
|
|
544
|
-
| `port_connected.cable_room` | `room`, `message` |
|
|
545
|
-
| `port_disconnected.cable_room` | `room`, `reason`, `message` |
|
|
546
|
-
| `user_joined.cable_room` | `room`, `user` |
|
|
547
|
-
| `user_left.cable_room` | `room`, `user` |
|
|
548
|
-
| `error.cable_room` | `error`, plus context |
|
|
549
|
-
|
|
550
|
-
`port_disconnected` reports a `reason` of `:left` for a clean departure and `:timeout` for a port
|
|
551
|
-
that stopped pinging.
|
|
552
|
-
|
|
553
|
-
Every Room also gets a tagged logger, so `logger.info` from inside a Room is prefixed with the
|
|
554
|
-
Room class and a short UUID. That UUID is how you follow one instance through the logs.
|
|
555
|
-
|
|
556
|
-
## Configuration.
|
|
557
|
-
|
|
558
|
-
CableRoom keeps its own Redis pool, separate from the ActionCable adapter's, for locking.
|
|
559
|
-
Configure it with these variables:
|
|
560
|
-
|
|
561
|
-
| Variable | Purpose |
|
|
562
|
-
| ---------------------------- | -------------------------------------------------------- |
|
|
563
|
-
| `CABLEROOM_REDIS_URL` | The connection URL. |
|
|
564
|
-
| `CABLEROOM_REDIS_PROVIDER` | Name of another variable holding the URL. |
|
|
565
|
-
| `CABLEROOM_REDIS_POOL_SIZE` | Pool size. Defaults to `RAILS_MAX_THREADS`, then five. |
|
|
566
|
-
|
|
567
|
-
Without a prefixed variable it falls back to `REDIS_PROVIDER` and `REDIS_URL`, so a single-Redis
|
|
568
|
-
app needs no CableRoom-specific configuration at all. Reach the pool directly with
|
|
569
|
-
`CableRoom.redis { |conn| ... }` and the lock manager with `CableRoom.lock_manager`.
|
|
570
|
-
|
|
571
|
-
Room threads come from a pool sized by ActionCable's own `worker_pool_size`.
|
|
572
|
-
|
|
573
|
-
Timings live in constants:
|
|
574
|
-
|
|
575
|
-
| Constant | Default | What it controls |
|
|
576
|
-
| --------------------------------- | -------------- | --------------------------------------- |
|
|
577
|
-
| `Room::Base::LOCK_DURATION` | `15.seconds` | Redis lock TTL, extended on every beat. |
|
|
578
|
-
| `Room::Base::WATCH_DOG_INTERVAL` | `15.seconds` | How stale a watchdog ping may get. |
|
|
579
|
-
| `PortManagement::PORT_TIMEOUT` | `30.seconds` | How long a silent port survives. |
|
|
580
|
-
| `ChannelTracker::BEAT_INTERVAL` | `5.seconds` | Lock extension and watchdog sweep. |
|
|
581
|
-
|
|
582
|
-
The first two are read as `self::CONSTANT`, so a Room subclass can redefine them. The other two
|
|
583
|
-
are module constants that apply process-wide.
|
|
584
|
-
|
|
585
|
-
### Shutdown.
|
|
586
|
-
|
|
587
|
-
On process exit, CableRoom asks every local Room to shut down gracefully and waits up to 15
|
|
588
|
-
seconds for them to drain. It also hooks ActionCable's `restart`, so a code reload in development
|
|
589
|
-
stops Rooms instead of orphaning their locks.
|
|
590
|
-
|
|
591
|
-
## Introspection.
|
|
592
|
-
|
|
593
|
-
```ruby
|
|
594
|
-
CableRoom::Room.locally_open_rooms # every Room running in this process
|
|
595
|
-
QuizRoom.locally_running_instances # just the QuizRooms
|
|
596
|
-
```
|
|
597
|
-
|
|
598
|
-
Both are process-local. There's no cluster-wide registry — the Redis lock is the only source of
|
|
599
|
-
truth about who owns a key.
|
|
600
|
-
|
|
601
|
-
## Subclassing.
|
|
602
|
-
|
|
603
|
-
Room classes build a private `Channel` and `PortClient` for each subclass, chained to the
|
|
604
|
-
parent's. Periodic timers, callbacks, policies, and tag aliases all inherit correctly through
|
|
605
|
-
however many levels you need:
|
|
606
|
-
|
|
607
|
-
```ruby
|
|
608
|
-
class BaseGameRoom < CableRoom::Room::Base
|
|
609
|
-
periodically :tick, every: 1.second
|
|
610
|
-
reap_when { connected_users.empty? }
|
|
611
|
-
end
|
|
612
|
-
|
|
613
|
-
class TriviaRoom < BaseGameRoom
|
|
614
|
-
# keeps tick and the reaper, adds its own
|
|
615
|
-
periodically :rotate_question, every: 30.seconds
|
|
616
|
-
end
|
|
617
|
-
```
|
|
618
|
-
|
|
619
|
-
Note that a Room's pubsub keys derive from its class name, so anonymous Room classes won't work.
|
|
620
|
-
|
|
621
|
-
## Development.
|
|
622
|
-
|
|
623
|
-
Rooms need Redis and, for the test suite, Postgres:
|
|
624
|
-
|
|
625
|
-
```sh
|
|
626
|
-
bundle install
|
|
627
|
-
bundle exec rspec
|
|
628
|
-
```
|
|
629
|
-
|
|
630
|
-
To run against every supported Rails version:
|
|
631
|
-
|
|
632
|
-
```sh
|
|
633
|
-
bundle exec appraisal install
|
|
634
|
-
bundle exec appraisal rspec
|
|
635
|
-
```
|
|
636
|
-
|
|
637
|
-
The suite has two halves. Unit specs use `RoomHarness#build_room`, which runs a Room against a
|
|
638
|
-
stub channel with no Redis and no pubsub, so logic is testable synchronously. End-to-end specs
|
|
639
|
-
run the async ActionCable adapter and real message delivery, and wait on observable conditions
|
|
640
|
-
with `wait_until` rather than sleeping.
|
|
641
|
-
|
|
642
|
-
`spec/internal` holds a Combustion app, so `rackup` boots a minimal Rails host if you want to
|
|
643
|
-
poke at Rooms by hand.
|
data/cable_room.gemspec
CHANGED
|
File without changes
|
data/lib/cable_room/ports.rb
CHANGED
|
@@ -6,18 +6,8 @@ module CableRoom
|
|
|
6
6
|
@ports_proxy ||= PortsProxy.new(self)
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
# `on_live`, when given, runs once the pubsub adapter has confirmed the subscription — the
|
|
12
|
-
# first moment a message published to the port is guaranteed to reach the block. Subscribing
|
|
13
|
-
# is asynchronous, so anything published before then can be lost.
|
|
14
|
-
def stream_port(port, auto_close: true, on_live: nil, &blk)
|
|
15
|
-
broadcasting = room_port_key(port)
|
|
16
|
-
if on_live
|
|
17
|
-
_stream_from_observing_liveness(broadcasting, on_live, &blk)
|
|
18
|
-
else
|
|
19
|
-
@cable_channel.stream_from(broadcasting, coder: ActiveSupport::JSON, &blk)
|
|
20
|
-
end
|
|
9
|
+
def stream_port(port, auto_close: true, &blk)
|
|
10
|
+
@cable_channel.stream_from(room_port_key(port), coder: ActiveSupport::JSON, &blk)
|
|
21
11
|
_streamed_ports << port if auto_close
|
|
22
12
|
end
|
|
23
13
|
|
|
@@ -42,34 +32,6 @@ module CableRoom
|
|
|
42
32
|
@_streamed_ports ||= Set.new
|
|
43
33
|
end
|
|
44
34
|
|
|
45
|
-
# ActionCable::Channel::Streams#stream_from confirms the channel subscription once every one of
|
|
46
|
-
# its streams is live, but offers no per-stream hook. This is its body — unchanged from Rails
|
|
47
|
-
# 6.1 through 8.1 — with `on_live` added to the adapter's success callback.
|
|
48
|
-
#
|
|
49
|
-
# When the channel has replaced `stream_from` (ActionCable's channel test stubs, recorders,
|
|
50
|
-
# `StubRoomChannel`) there is no subscription to observe: defer to it and report live at once.
|
|
51
|
-
def _stream_from_observing_liveness(broadcasting, on_live, &blk)
|
|
52
|
-
chan = @cable_channel
|
|
53
|
-
unless chan.method(:stream_from).owner == ActionCable::Channel::Streams
|
|
54
|
-
chan.stream_from(broadcasting, coder: ActiveSupport::JSON, &blk)
|
|
55
|
-
on_live.call
|
|
56
|
-
return
|
|
57
|
-
end
|
|
58
|
-
return if chan.respond_to?(:unsubscribed?, true) && chan.send(:unsubscribed?)
|
|
59
|
-
|
|
60
|
-
chan.send(:defer_subscription_confirmation!)
|
|
61
|
-
handler = chan.send(:worker_pool_stream_handler, broadcasting, blk, coder: ActiveSupport::JSON)
|
|
62
|
-
chan.send(:streams)[broadcasting] = handler
|
|
63
|
-
|
|
64
|
-
chan.connection.server.event_loop.post do
|
|
65
|
-
chan.send(:pubsub).subscribe(broadcasting, handler, lambda do
|
|
66
|
-
chan.send(:ensure_confirmation_sent)
|
|
67
|
-
chan.logger.info "#{chan.class.name} is streaming from #{broadcasting}"
|
|
68
|
-
on_live.call
|
|
69
|
-
end)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
35
|
class PortsProxy
|
|
74
36
|
def initialize(ports_concern)
|
|
75
37
|
@ports_concern = ports_concern
|
|
@@ -3,7 +3,12 @@ module CableRoom
|
|
|
3
3
|
module PortManagement
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# A port that the room has not heard from for this long is dropped. Any inbound message
|
|
7
|
+
# counts as hearing from it (see the receive_message callback below), and members only
|
|
8
|
+
# ping when they have been otherwise silent for RoomMember::PING_INTERVAL, so the longest
|
|
9
|
+
# gap a live member produces is 2 x PING_INTERVAL. Keep this comfortably above that: under
|
|
10
|
+
# load the room processes its queue late, and a reaped port is a lost student.
|
|
11
|
+
PORT_TIMEOUT = 45.seconds
|
|
7
12
|
|
|
8
13
|
class_methods do
|
|
9
14
|
def on_port_connected(...)
|
|
@@ -29,7 +34,13 @@ module CableRoom
|
|
|
29
34
|
on_port_connected { reply({type: 'port_acknowledged' }) }
|
|
30
35
|
|
|
31
36
|
set_callback(:receive_message, :around) do |_, blk|
|
|
32
|
-
with_message_origin(message['mtok'])
|
|
37
|
+
with_message_origin(message['mtok']) do
|
|
38
|
+
# Every message from a known port is proof of life, not just port_ping — so a member
|
|
39
|
+
# that is actively sending never needs to ping (RoomMembership#ping!). Unknown origins
|
|
40
|
+
# (a port_connected creating its port) are touched when the port is created.
|
|
41
|
+
message_origin&.touch_activity!
|
|
42
|
+
blk.call
|
|
43
|
+
end
|
|
33
44
|
end
|
|
34
45
|
|
|
35
46
|
system_message_types(:port_connected, :port_disconnected, :port_ping)
|
|
@@ -107,7 +118,10 @@ module CableRoom
|
|
|
107
118
|
def handle_received_message(message)
|
|
108
119
|
case message['type']
|
|
109
120
|
when 'port_connected'
|
|
110
|
-
mo = @_port_clients[@current_message_origin]
|
|
121
|
+
mo = @_port_clients[@current_message_origin]
|
|
122
|
+
return if mo
|
|
123
|
+
|
|
124
|
+
mo = @_port_clients[@current_message_origin] = PortClient.new(self, @current_message_origin)
|
|
111
125
|
|
|
112
126
|
mo.touch_activity!
|
|
113
127
|
mo.tag!(message['tags'])
|
|
@@ -2,8 +2,15 @@ module CableRoom
|
|
|
2
2
|
module RoomMember
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
# How often a silent member reminds its room that it is alive. A member that has sent
|
|
6
|
+
# anything within this interval skips the ping (the room counts every inbound message as
|
|
7
|
+
# activity), so the room hears from a live member at least every 2 x PING_INTERVAL — which
|
|
8
|
+
# must stay under Room::PortManagement::PORT_TIMEOUT with margin for queue lag. Pings were
|
|
9
|
+
# ~30% of all room messages at 10 s with no skipping.
|
|
10
|
+
PING_INTERVAL = 15.seconds
|
|
11
|
+
|
|
5
12
|
included do
|
|
6
|
-
periodically :ping_room_memberships, every:
|
|
13
|
+
periodically :ping_room_memberships, every: PING_INTERVAL
|
|
7
14
|
|
|
8
15
|
after_unsubscribe do
|
|
9
16
|
to_close = _room_memberships.to_a
|
|
@@ -102,14 +109,8 @@ module CableRoom
|
|
|
102
109
|
def ping!
|
|
103
110
|
return if left?
|
|
104
111
|
if @has_established
|
|
105
|
-
port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true)
|
|
106
|
-
|
|
107
|
-
# Announced but never acknowledged. Our streams were already live when we announced (see
|
|
108
|
-
# initiate_connection), so this is not the subscribe race — the announcement or the
|
|
109
|
-
# acknowledgement was lost in transit, or the room did not exist yet. An unestablished
|
|
110
|
-
# membership silently drops everything the client sends (see #<<), so re-announce instead
|
|
111
|
-
# of pinging: port_connected is idempotent on the room side (the port is merged,
|
|
112
|
-
# user_joined fires only once).
|
|
112
|
+
port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true) unless recently_transmitted?
|
|
113
|
+
else
|
|
113
114
|
transmit_port_connected
|
|
114
115
|
end
|
|
115
116
|
@mutex.synchronize do
|
|
@@ -140,37 +141,30 @@ module CableRoom
|
|
|
140
141
|
|
|
141
142
|
def key; @room_key; end
|
|
142
143
|
|
|
143
|
-
# Every stream this membership opens reports back once it is live; the last one to come up
|
|
144
|
-
# sends port_connected (see initiate_connection).
|
|
145
|
-
#
|
|
146
|
-
# The adapter runs the liveness callback on its own terms — the async and inline adapters call
|
|
147
|
-
# it while holding their subscriber-map lock, the Redis adapter on the event loop — so nothing
|
|
148
|
-
# may happen inside it except a hop to a worker thread: taking @mutex there deadlocks against
|
|
149
|
-
# an initiate_connection that is mid-broadcast, and broadcasting from it re-enters the lock.
|
|
150
|
-
def stream_port(port, on_live: nil, **kwargs, &blk)
|
|
151
|
-
generation = @stream_generation
|
|
152
|
-
@streams_pending += 1
|
|
153
|
-
connection = @cable_channel.connection
|
|
154
|
-
super(port, **kwargs, on_live: lambda do
|
|
155
|
-
connection.worker_pool.async_invoke(self, :_stream_became_live, generation, connection: connection)
|
|
156
|
-
on_live&.call
|
|
157
|
-
end, &blk)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
144
|
protected
|
|
161
145
|
|
|
162
146
|
def port_transmit(port, data, secure_context: false)
|
|
163
147
|
data[:mtok] = @token
|
|
164
148
|
|
|
149
|
+
type = (data[:type] || data['type'])&.to_sym
|
|
165
150
|
unless secure_context
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
logger.warn "Dropping attempt to send system message type: #{t.inspect}"
|
|
151
|
+
if room_class._system_message_types.include?(type)
|
|
152
|
+
logger.warn "Dropping attempt to send system message type: #{type.inspect}"
|
|
169
153
|
return
|
|
170
154
|
end
|
|
171
155
|
end
|
|
172
156
|
|
|
173
157
|
super(port, data)
|
|
158
|
+
# Pings don't count: a ping must never be the reason the next ping is skipped.
|
|
159
|
+
@last_transmit_at = monotonic_now unless type == :port_ping
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def recently_transmitted?
|
|
163
|
+
@last_transmit_at && (monotonic_now - @last_transmit_at) < RoomMember::PING_INTERVAL
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def monotonic_now
|
|
167
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
174
168
|
end
|
|
175
169
|
|
|
176
170
|
def room_port_key(port)
|
|
@@ -187,9 +181,7 @@ module CableRoom
|
|
|
187
181
|
@has_established = true
|
|
188
182
|
@on_joined&.call(self) if first_acknowledgement
|
|
189
183
|
when 'room_opened'
|
|
190
|
-
|
|
191
|
-
# one would be acknowledged into a stream nobody is listening to yet).
|
|
192
|
-
transmit_port_connected if @streams_live
|
|
184
|
+
transmit_port_connected
|
|
193
185
|
@on_room_opened&.call(self)
|
|
194
186
|
when 'room_closed'
|
|
195
187
|
leave!
|
|
@@ -211,18 +203,6 @@ module CableRoom
|
|
|
211
203
|
@has_established = false
|
|
212
204
|
@cable_channel._room_memberships << self
|
|
213
205
|
|
|
214
|
-
# port_connected must not go out until every stream below is live on the pubsub adapter.
|
|
215
|
-
# Subscribing is asynchronous and the room acknowledges on the private @token stream, so
|
|
216
|
-
# announcing first lets a fast room — in practice one on another server — reply into a
|
|
217
|
-
# stream nobody is listening to yet; the acknowledgement is lost and the membership never
|
|
218
|
-
# establishes. stream_port (overridden above) counts the streams it opens and the last one
|
|
219
|
-
# to come up announces (_stream_became_live). The generation discards confirmations from
|
|
220
|
-
# a previous connection that land after a rejoin!.
|
|
221
|
-
@stream_generation = (@stream_generation || 0) + 1
|
|
222
|
-
@streams_pending = 0
|
|
223
|
-
@streams_armed = false
|
|
224
|
-
@streams_live = false
|
|
225
|
-
|
|
226
206
|
# Listen to public/broadcast channel
|
|
227
207
|
stream_port(room_class::ROOM_OUT_CHANNEL) do |message|
|
|
228
208
|
handle_received_message(message)
|
|
@@ -248,30 +228,14 @@ module CableRoom
|
|
|
248
228
|
|
|
249
229
|
@preconfigure&.call(self)
|
|
250
230
|
|
|
251
|
-
|
|
252
|
-
# that was all of them, otherwise the last confirmation will.
|
|
253
|
-
@streams_armed = true
|
|
254
|
-
_announce_if_streams_live
|
|
231
|
+
transmit_port_connected
|
|
255
232
|
|
|
256
233
|
maybe_provision_room
|
|
257
234
|
end
|
|
258
235
|
end
|
|
259
236
|
|
|
260
|
-
def
|
|
261
|
-
|
|
262
|
-
return unless generation == @stream_generation
|
|
263
|
-
|
|
264
|
-
@streams_pending -= 1
|
|
265
|
-
_announce_if_streams_live
|
|
266
|
-
end
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
# Under @mutex. Sends port_connected once per connection, when every stream opened by
|
|
270
|
-
# initiate_connection has been confirmed live.
|
|
271
|
-
def _announce_if_streams_live
|
|
272
|
-
return if @streams_live || !@streams_armed || @streams_pending > 0 || left?
|
|
273
|
-
|
|
274
|
-
@streams_live = true
|
|
237
|
+
def transmit_subscription_confirmation(...)
|
|
238
|
+
super
|
|
275
239
|
transmit_port_connected
|
|
276
240
|
end
|
|
277
241
|
|
data/lib/cable_room/version.rb
CHANGED