cable_room 0.7.0.beta2 → 0.8.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.
data/cable_room.gemspec CHANGED
@@ -18,17 +18,14 @@ Gem::Specification.new do |spec|
18
18
  spec.summary = "Build live Rooms on top of ActionCable"
19
19
  spec.homepage = "https://instructure.com"
20
20
 
21
- spec.files = Dir["{app,config,db,exe,lib}/**/*", "README.md", "CHANGELOG.md", "*.gemspec"]
22
- spec.bindir = "exe"
23
- spec.executables = ["cable_room"]
21
+ spec.files = Dir["{app,config,db,lib}/**/*", "README.md", "*.gemspec"]
24
22
  spec.require_paths = ['lib']
25
23
 
26
24
  spec.add_dependency "rails", ">= 7.2", "< 9.0"
27
25
  spec.add_dependency "rufus-scheduler", "~> 3.6"
28
26
  spec.add_dependency "redlock", "~> 2.0"
29
27
  spec.add_dependency "rediconn", "~> 0.1.2"
30
- # rediconn builds pools of redis-rb clients but doesn't declare the gem; the Bus needs it too
31
- spec.add_dependency "redis", ">= 5.0"
32
28
 
29
+ spec.add_development_dependency "redis"
33
30
  spec.add_development_dependency 'rspec', '~> 3'
34
31
  end
@@ -0,0 +1,35 @@
1
+ module CableRoom
2
+ class Host
3
+ # How rooms receive messages: straight off ActionCable's pubsub adapter, on the same stream
4
+ # names members publish to (Room::Base.room_port_key). This is the only place the room side
5
+ # touches ActionCable for inbound traffic; anything with the same two methods can be handed to
6
+ # `Host.new(inbound:)`.
7
+ #
8
+ # `subscribe` returns a handle that `unsubscribe` needs back. Handlers receive the payload
9
+ # exactly as published (a JSON string, for ActionCable) on the adapter's own thread, so they
10
+ # must be quick and hand the real work off. Host::Runner does that by queueing it on the room.
11
+ # `on_live` runs on that same thread once the adapter confirms the subscription.
12
+ class ActionCableInbound
13
+ # Subscribes from the calling thread. ActionCable's own stream_from posts the subscribe to
14
+ # the event loop first, but the adapter registers asynchronously either way, so that extra
15
+ # hop only widens the window in which the room can't hear anything.
16
+ #
17
+ # The adapter calls `on_live` while holding its own subscriber-map lock, so it must never
18
+ # wait on anything that might be waiting on the adapter (a room's mutex, say).
19
+ def subscribe(stream, on_live: nil, &on_message)
20
+ server.pubsub.subscribe(stream, on_message, on_live)
21
+ on_message
22
+ end
23
+
24
+ def unsubscribe(stream, handle)
25
+ server.pubsub.unsubscribe(stream, handle)
26
+ end
27
+
28
+ private
29
+
30
+ def server
31
+ ActionCable.server
32
+ end
33
+ end
34
+ end
35
+ end
@@ -2,7 +2,7 @@ module CableRoom
2
2
  class Host
3
3
  # Runs one Room for the Host. It holds everything that is per-room but not the room's own
4
4
  # business: the ordered work queue, lifecycle state, the Redlock and watchdog, the inbound
5
- # Bus subscriptions, and the periodic timers.
5
+ # stream subscriptions, and the periodic timers.
6
6
  #
7
7
  # A Room talks to its runner (`@runner`) for anything that touches threads or the outside
8
8
  # world. The Room DSL (`shutdown!`, `stop!`, `async`, `on_room_thread`, `ports[]`,
@@ -10,51 +10,40 @@ module CableRoom
10
10
  #
11
11
  # States: :initializing -> :starting -> :started -> :shutting_down -> :dead
12
12
  #
13
- # A room being migrated off this host takes a detour: :started -> :freezing -> :frozen, then
14
- # either `discard!` (-> :dead, quietly, once another host has it) or `stop!` (-> :dead with
15
- # room_closed, when nobody took it). See `freeze!`. A room arriving on this host by migration
16
- # runs the detour backwards: `restore!(hold_inbound: true)` brings it up straight into :frozen,
17
- # and `thaw!` moves it to :started once the handoff has replayed what the old host relayed.
13
+ # Locking: `@mutex` guards lifecycle state and the stream and timer lists. It is never held
14
+ # across a call into the inbound transport or into the room's own callbacks, because the
15
+ # transport's threads call back into us and would deadlock against it. Those callbacks only
16
+ # ever take `@queue_lock`, a leaf lock around the work queue that is never held while calling
17
+ # out to anything. Lock order is always `@mutex` then `@queue_lock`.
18
18
  class Runner
19
- FROZEN_STATES = %i[freezing frozen].freeze
20
-
21
19
  attr_reader :host, :room, :room_class, :key, :uuid, :tenant, :logger
22
20
 
23
- # Monotonic time this runner was built. `Host#drain!` migrates rooms oldest first.
24
- attr_reader :started_at
25
-
26
21
  delegate :worker_pool, :inbound, to: :host
27
22
 
28
- def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:, tenant: nil)
23
+ def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:)
29
24
  @host = host
30
25
  @room_class = room_class
31
26
  @key = key
32
27
  @lock_info = lock_info
33
28
  @watchdog_interval = watchdog_interval
34
29
  @lock_duration = lock_duration
35
- @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
30
 
37
31
  # Used mainly for logs and being able to follow a specific Room instance
38
32
  @uuid = SecureRandom.hex(6)
39
33
 
40
34
  @mutex = Monitor.new
41
- @work_finished = @mutex.new_cond
35
+ @queue_lock = Mutex.new
42
36
  @current_state = :initializing
37
+ @stopping = false
43
38
  @processing_work = false
44
39
  @work_queue = []
45
- @held_inbound = []
46
- @hold_from_start = false
47
- @streams = {} # stream => the inbound transport's handle, needed to unsubscribe
48
- @handlers = {} # stream => the room's handler, so a message can be fed in by hand (see `inject`)
40
+ @streams = {}
49
41
  @periodic_timers = []
50
42
 
51
- # Whatever the member's provision request said (RoomMembership defaults that to the
52
- # ambient tenant at join_room's own call site -- see RoomMembership#initialize) or, for a
53
- # migrated room, whatever its snapshot carried forward. Nothing here parses it out of
54
- # `key` or guesses from ambient state itself: a Host thread has no request of its own, so
55
- # anything read here would be leftover from whatever this thread ran last, not this room's
56
- # tenant.
57
- @tenant = tenant
43
+ # Nothing in the gem reads this, but the runner is the "connection" for its work items
44
+ # (see WorkerPool), and PandaPal's Worker :work hook and broadcasting_for read
45
+ # `connection.tenant` to pick the room's tenant. Don't remove it.
46
+ @tenant = Apartment::Tenant.current if defined?(Apartment)
58
47
 
59
48
  @logger = ActionCable::Connection::TaggedLoggerProxy.new(
60
49
  host.logger,
@@ -74,14 +63,8 @@ module CableRoom
74
63
  @current_state
75
64
  end
76
65
 
77
- def frozen?
78
- FROZEN_STATES.include?(state)
79
- end
80
-
81
- # The Redlock this runner holds, or nil once it has been released or handed off. Read-only:
82
- # use `release_lock!` to let go of it.
83
- def lock_info
84
- @lock_info
66
+ def closed?
67
+ state == :dead || state == :shutting_down
85
68
  end
86
69
 
87
70
  # -- Lifecycle ---------------------------------------------------------------------------
@@ -89,37 +72,30 @@ module CableRoom
89
72
  # Run the room's startup callbacks and start its class-level timers, on the calling thread.
90
73
  # If startup fails the room is torn down and the error re-raised.
91
74
  def start!
92
- start_with { room.send(:_startup) }
93
- end
94
-
95
- # Bring the room up from a CableRoom::Snapshot instead of from scratch: same as `start!`,
96
- # except the room rebuilds its ports, users, reaper deadlines, and (through `restore_state`)
97
- # its own state first, and doesn't tell its members it opened. The caller holds the room's
98
- # lock already, the same as for `start!`.
99
- #
100
- # With `hold_inbound: true` the room comes up :frozen instead of :started: it subscribes its
101
- # inbound streams as usual, but every message that arrives is held (see `held_inbound`) and
102
- # its timers don't start until `thaw!`. This is how a migration's adopter takes a room: the
103
- # messages the old host relayed have to run before anything that arrives live here.
104
- def restore!(snapshot, hold_inbound: false)
105
- @hold_from_start = hold_inbound
106
- start_with(final_state: hold_inbound ? :frozen : :started) { room.send(:_restore, snapshot) }
75
+ @current_state = :starting
76
+ with_executor do
77
+ room.send(:_startup)
78
+ start_periodic_timers
79
+ end
80
+ # Startup may already have asked to shut down (a reaper, or a KILL that arrived early)
81
+ @queue_lock.synchronize { @current_state = :started if @current_state == :starting }
82
+ rescue => e
83
+ terminate!
84
+ raise e
107
85
  end
108
86
 
109
87
  # Stop right now, on the calling thread: run the room's shutdown callbacks and free
110
88
  # everything. Work still on the queue is dropped. `initiate_shutdown` is the graceful version.
111
- # `reason` is what members see in `room_closed`; nil keeps whatever the room already set.
112
89
  #
113
- # Only the state change happens under the mutex. Unsubscribing waits for the Bus to confirm,
114
- # and the Bus thread needs this same mutex to queue an arriving message, so holding it here
115
- # would deadlock the two whenever a message lands mid-shutdown.
116
- def stop!(reason: nil)
90
+ # Only the state change happens under the mutex (see the class comment). `@stopping` makes
91
+ # sure the shutdown callbacks run once even if, say, a lost lock and a queued stop race.
92
+ def stop!
117
93
  @mutex.synchronize do
118
- return if state == :dead
119
- @current_state = :shutting_down
94
+ return if @stopping || state == :dead
95
+ @stopping = true
96
+ @queue_lock.synchronize { @current_state = :shutting_down }
120
97
  end
121
98
 
122
- room.send(:_shutdown_reason=, reason) unless reason.nil?
123
99
  unsubscribe_all
124
100
  begin
125
101
  with_executor { room.send(:_shutdown) }
@@ -128,191 +104,42 @@ module CableRoom
128
104
  end
129
105
  end
130
106
 
131
- # Shut down gracefully: stop listening, then stop once everything already queued has run.
132
- # `reason` reaches the members in `room_closed`.
107
+ # Shut down gracefully: stop listening, then stop once everything already queued has run
133
108
  def initiate_shutdown(reason)
134
109
  @mutex.synchronize do
135
110
  return if closed?
136
111
 
137
112
  logger.info "Initiating shutdown: #{reason}"
138
- room.send(:_shutdown_reason=, reason)
139
113
 
140
- # The actual stop goes behind whatever is already waiting, so those messages still run
141
- post_work(async: false) { stop! }
142
-
143
- # From here on new messages are dropped at the queue (see `enqueue`), so nothing new
144
- # lands after the stop even before the unsubscribe below is confirmed
145
- @current_state = :shutting_down
114
+ # The actual stop goes behind whatever is already waiting, so those messages still run.
115
+ # Closing in the same step means nothing posted from here on can land after it.
116
+ @queue_lock.synchronize do
117
+ @work_queue << wrap_work(-> { stop! })
118
+ @current_state = :shutting_down
119
+ end
146
120
  end
147
121
 
148
- unsubscribe_all
149
- # A frozen room had stopped taking work off its queue; now that it's shutting down the
150
- # stop posted above has to actually run
151
122
  schedule_work
123
+ unsubscribe_all
152
124
  end
153
125
 
154
- # Free everything without running the room's shutdown callbacks. Members hear nothing (no
155
- # room_closed); the lock is released unless the caller asks to keep it (see `discard!`).
156
- def terminate!(release_lock: true)
126
+ # Free everything without running the room's shutdown callbacks
127
+ def terminate!
157
128
  lock_info = @mutex.synchronize do
158
- @current_state = :dead
159
- stop_periodic_timers
129
+ @queue_lock.synchronize do
130
+ @current_state = :dead
131
+ @work_queue.clear
132
+ end
160
133
  @lock_info.tap { @lock_info = nil }
161
134
  end
162
135
 
136
+ stop_periodic_timers
137
+
163
138
  # Unsubscribe before releasing the lock: the next runner for this key (which needs the
164
- # lock) must not subscribe to the same Bus channel before this one has let go of it.
139
+ # lock) must not subscribe to the same streams before this one has let go of them.
165
140
  unsubscribe_all
166
- CableRoom.lock_manager.unlock(lock_info) if release_lock && lock_info
141
+ CableRoom.lock_manager.unlock(lock_info) if lock_info
167
142
  host.untrack(self)
168
- lock_info
169
- end
170
-
171
- # -- Migration ---------------------------------------------------------------------------
172
- #
173
- # The pieces the migration protocol (CableRoom::Migration) is built from, in the order it
174
- # uses them: `freeze!`, `snapshot`, `release_lock!`, and finally `discard!` once another
175
- # host has restored the room — or `stop!` if none did. Members never hear about any of it.
176
-
177
- # Bring the room to a standstill so it can be snapshotted: stop the periodic timers, let
178
- # whatever is already queued finish, and from then on hold inbound messages instead of
179
- # handling them. The Bus subscription stays up, so the messages that arrive while the room
180
- # is frozen still land here — in `held_inbound`, or with the block if one is given (called
181
- # on the Bus thread with `(stream, message)`, so keep it quick), for the migration to relay
182
- # to the new host. The lock is kept and keeps being renewed.
183
- #
184
- # Blocks until the room is quiet, or for `timeout` seconds (nil waits as long as it takes).
185
- # Returns true once frozen, or false if the room died on the way (a queued message asked it
186
- # to shut down, say) or the timeout passed — in which case the room is running again as if
187
- # nothing happened, with the messages held meanwhile back on its queue, so the caller can
188
- # still `stop!` it cleanly. A room that never goes quiet must not hang a drain.
189
- def freeze!(timeout: nil, &hold)
190
- @mutex.synchronize do
191
- return false if closed?
192
- return true if frozen?
193
-
194
- logger.info "Freezing"
195
- @current_state = :freezing
196
- @hold_inbound = hold
197
- stop_periodic_timers
198
- # Nothing else may be running in this room once we return: wait for the queue to drain
199
- # and the item in flight to finish. New inbound is already being held, so this ends.
200
- deadline = timeout && monotonic_now + timeout
201
- while state == :freezing && (@processing_work || @work_queue.any?)
202
- remaining = deadline && deadline - monotonic_now
203
- if remaining && remaining <= 0
204
- logger.warn "Room did not go quiet within #{timeout}s; not freezing it"
205
- resume_from_freeze
206
- return false
207
- end
208
- @work_finished.wait(remaining)
209
- end
210
- return false unless state == :freezing
211
-
212
- @current_state = :frozen
213
- # Messages that arrived while the room was still going quiet were held, not handed to
214
- # the block: had the freeze timed out they'd have to go back on the queue, and once
215
- # relayed they'd be gone. Now that the freeze is final, hand them over first — under the
216
- # mutex, so nothing arriving on the Bus thread can get ahead of them.
217
- if hold
218
- held = @held_inbound
219
- @held_inbound = []
220
- held.each { |stream, message| hold.call(stream, message) }
221
- end
222
- end
223
- true
224
- end
225
-
226
- # Messages that arrived while frozen (and weren't handed to a `freeze!` block), as
227
- # `[stream, message]` pairs in arrival order.
228
- def held_inbound
229
- @mutex.synchronize { @held_inbound.dup }
230
- end
231
-
232
- # The streams this runner is subscribed to right now (Bus channel names).
233
- def subscribed_streams
234
- @mutex.synchronize { @streams.keys }
235
- end
236
-
237
- # Feed `message` into the room as if it had arrived on `stream`, behind whatever is already
238
- # queued — even while the room is frozen, when a live message would be held instead. This is
239
- # how a migration's adopter replays what the old host relayed: the messages go onto the
240
- # queue in the order given and run once the room thaws. Returns false (and drops the
241
- # message, with a warning) if the room doesn't listen on that stream.
242
- def inject(stream, message)
243
- handler = @mutex.synchronize { @handlers[String(stream)] }
244
- unless handler
245
- logger.warn "Dropping a relayed message for #{stream}: this room doesn't listen on it"
246
- return false
247
- end
248
-
249
- post_work(async: false, silent: true) { handler.call(message) }
250
- true
251
- end
252
-
253
- # Take a frozen room back to :started. The block gets the inbound held so far (as
254
- # `[stream, message]` pairs, in arrival order) and returns the pairs to run, in order —
255
- # a migration's adopter uses it to drop the ones it has already replayed from the handoff
256
- # list. Those are queued (after anything `inject`ed before), the timers start, and the room
257
- # runs again. Everything happens under the mutex, so no message can land between the block
258
- # seeing the held list and the room going live: it is either in the list or queued after.
259
- # Returns false if the room isn't frozen.
260
- def thaw!
261
- @mutex.synchronize do
262
- return false unless state == :frozen
263
-
264
- held = @held_inbound
265
- @held_inbound = []
266
- @hold_inbound = nil
267
- @hold_from_start = false
268
- to_run = block_given? ? yield(held) : held
269
- to_run.each { |stream, message| inject(stream, message) }
270
- @current_state = :started
271
- start_periodic_timers
272
- ping_watchdog
273
- end
274
- logger.info "Thawed"
275
- schedule_work
276
- true
277
- end
278
-
279
- # The room's CableRoom::Snapshot. Only a frozen room can be snapshotted: that's the one
280
- # state where nothing else is touching its state.
281
- def snapshot
282
- raise "#{room_class.name}[#{key}] must be frozen before it can be snapshotted (state: #{state})" unless state == :frozen
283
-
284
- with_executor { Snapshot.take(room) }
285
- end
286
-
287
- # Give the room's lock up while staying alive, so another host can claim the room and this
288
- # one can keep relaying inbound until it has. Returns false if there was no lock to release.
289
- def release_lock!
290
- lock_info = @mutex.synchronize { @lock_info.tap { @lock_info = nil } }
291
- return false unless lock_info
292
-
293
- CableRoom.lock_manager.unlock(lock_info)
294
- true
295
- end
296
-
297
- # Take the room's lock again after `release_lock!`, when no other host claimed it. Returns
298
- # false if someone else holds it (or this runner still holds it). With the lock back, `stop!`
299
- # releases it the normal way.
300
- def retake_lock!
301
- return false if @lock_info
302
-
303
- lock_info = CableRoom.lock_manager.lock(room_class.room_port_key(key), @lock_duration.in_milliseconds)
304
- return false unless lock_info
305
-
306
- @mutex.synchronize { @lock_info = lock_info }
307
- true
308
- end
309
-
310
- # Drop a room that now lives somewhere else: no shutdown callbacks, no room_closed, and the
311
- # lock — if this runner still holds one — is left alone, since it may belong to the new host
312
- # by now. Returns the lock_info that was still held, or nil, so the caller can decide.
313
- def discard!
314
- logger.info "Discarding (the room has moved)"
315
- terminate!(release_lock: false)
316
143
  end
317
144
 
318
145
  # -- Watchdog and lock -------------------------------------------------------------------
@@ -341,9 +168,6 @@ module CableRoom
341
168
  return
342
169
  end
343
170
 
344
- # A frozen room is idle on purpose; its timers are stopped, so nobody pings the watchdog
345
- return if frozen?
346
-
347
171
  unless @last_watchdog_ping_at && @last_watchdog_ping_at > @watchdog_interval.ago
348
172
  logger.warn "Watchdog timeout for room #{room_class.name}[#{key}], shutting down"
349
173
  initiate_shutdown("Watchdog timeout")
@@ -364,16 +188,10 @@ module CableRoom
364
188
  # Errors inside the work are reported (see `report_work_error`), never raised, so one bad
365
189
  # message can't take the room down with it.
366
190
  def post_work(async: false, silent: false, &blk)
367
- work = proc do
368
- worker_pool.invoke(blk, :call, connection: self)
369
- rescue => e
370
- report_work_error(e)
371
- end
372
-
373
191
  if async
374
- worker_pool.executor.post(&work)
192
+ worker_pool.executor.post(&wrap_work(blk))
375
193
  else
376
- enqueue(work, silent: silent)
194
+ enqueue(wrap_work(blk), silent: silent)
377
195
  end
378
196
  end
379
197
 
@@ -394,37 +212,44 @@ module CableRoom
394
212
 
395
213
  # -- Inbound streams ---------------------------------------------------------------------
396
214
 
397
- # Deliver every message published on `stream` to `handler`, on the room's own queue. The
398
- # transport hands us decoded messages on its own thread; all we do there is queue, so one
399
- # room's handler can't hold up another room's traffic. `on_live` runs once the transport
400
- # confirms the subscription; anything published before that may be missed.
215
+ # Deliver every message published on `stream` to `handler`, on the room's own queue.
216
+ # Decoding happens on the room's thread too, so a bad payload is reported like any other
217
+ # work error instead of hurting the transport. `on_live` runs once the transport confirms
218
+ # the subscription; anything published before that may be missed.
401
219
  #
402
- # The subscribe itself happens outside the mutex (it blocks until the Bus confirms, and the
403
- # Bus thread needs the mutex to enqueue), so a room that stopped meanwhile undoes it.
404
- def subscribe(stream, on_live: nil, &handler)
220
+ # The subscribe itself happens outside the mutex (see the class comment), so a room that
221
+ # stopped meanwhile undoes it.
222
+ def subscribe(stream, coder: ActiveSupport::JSON, on_live: nil, &handler)
405
223
  raise ArgumentError, "Block required" unless handler
406
224
 
407
225
  stream = String(stream)
408
226
  return if closed?
409
227
 
410
- handle = inbound.subscribe(stream, on_live: on_live) do |message|
411
- receive_inbound(stream, message, handler)
228
+ confirmed = lambda do
229
+ logger.info "#{room_class.name} is streaming from #{stream}"
230
+ on_live&.call
231
+ end
232
+
233
+ handle = inbound.subscribe(stream, on_live: confirmed) do |raw|
234
+ post_work(async: false, silent: true) do
235
+ handler.call(coder ? coder.decode(raw) : raw)
236
+ end
412
237
  end
413
238
 
414
239
  stopped = @mutex.synchronize do
415
- closed? || (@streams[stream] = handle; @handlers[stream] = handler; false)
240
+ closed? || (@streams[stream] = handle; false)
416
241
  end
417
242
  inbound.unsubscribe(stream, handle) if stopped
418
243
  end
419
244
 
420
245
  def unsubscribe(stream)
421
246
  stream = String(stream)
422
- handle = @mutex.synchronize { @handlers.delete(stream); @streams.delete(stream) }
247
+ handle = @mutex.synchronize { @streams.delete(stream) }
423
248
  inbound.unsubscribe(stream, handle) if handle
424
249
  end
425
250
 
426
251
  def unsubscribe_all
427
- handles = @mutex.synchronize { @handlers.clear; @streams.to_a.tap { @streams.clear } }
252
+ handles = @mutex.synchronize { @streams.to_a.tap { @streams.clear } }
428
253
  handles.each { |stream, handle| inbound.unsubscribe(stream, handle) }
429
254
  end
430
255
 
@@ -439,94 +264,46 @@ module CableRoom
439
264
  post_work(async: false, silent: true) { callback.call }
440
265
  end
441
266
 
442
- PeriodicTimer.new(job)
267
+ timer = PeriodicTimer.new(job)
268
+ @mutex.synchronize { @periodic_timers << timer }
269
+ timer
443
270
  end
444
271
 
445
272
  private
446
273
 
447
- def closed?
448
- state == :dead || state == :shutting_down
449
- end
450
-
451
- # `start!` and `restore!` differ only in what the room does first; everything around it —
452
- # the executor, the timers, tearing down on failure — is the same. A room restored with
453
- # `hold_inbound` ends up :frozen with no timers running; `thaw!` starts them.
454
- def start_with(final_state: :started)
455
- @current_state = :starting
456
- with_executor do
457
- yield
458
- start_periodic_timers unless final_state == :frozen
459
- end
460
- @current_state = final_state
461
- rescue => e
462
- terminate!
463
- raise e
464
- end
465
-
466
- # A message from the Bus, on the Bus thread. Normally it's queued for the room; while the
467
- # room is frozen it's held for the migration to relay instead. Decided under the mutex so a
468
- # message can't slip onto the queue in the moment the room freezes.
469
- #
470
- # A room restored with `hold_inbound` holds from its very first subscribe, while it is still
471
- # :starting: the adopter has to see everything that arrives before it thaws the room.
472
- def receive_inbound(stream, message, handler)
473
- disposition = @mutex.synchronize do
474
- next :queue unless frozen? || @hold_from_start
475
- # Only a fully frozen room relays; while still :freezing it holds (see `freeze!`)
476
- next :relay if @hold_inbound && state == :frozen
477
-
478
- @held_inbound << [stream, message]
479
- :held
480
- end
481
-
482
- case disposition
483
- when :queue
484
- # A handoff marker is the migration protocol talking to itself (see Migration); it is
485
- # never for the room. One can only reach a live room on a failure path, so drop it here.
486
- return if Migration.marker?(message)
274
+ # Timers declared on the Room class with `periodically`. None if startup already asked the
275
+ # room to shut down.
276
+ def start_periodic_timers
277
+ return if closed?
487
278
 
488
- post_work(async: false, silent: true) { handler.call(message) }
489
- when :relay then @hold_inbound.call(stream, message)
279
+ room_class.periodic_timers.each do |callback, every|
280
+ start_periodic_timer(-> { room.instance_exec(&callback) }, every: every)
490
281
  end
491
282
  end
492
283
 
493
- # Undo a freeze that didn't complete: the room runs on as if `freeze!` had never been
494
- # called, with the messages held meanwhile queued in arrival order. Caller holds the mutex,
495
- # and the state is :freezing, so nothing else is running in the room.
496
- def resume_from_freeze
497
- held = @held_inbound
498
- @held_inbound = []
499
- @hold_inbound = nil
500
- @current_state = :started
501
- held.each { |stream, message| inject(stream, message) }
502
- start_periodic_timers
503
- ping_watchdog
504
- end
505
-
506
- def monotonic_now
507
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
284
+ def stop_periodic_timers
285
+ timers = @mutex.synchronize { @periodic_timers.slice!(0..) }
286
+ timers.each(&:shutdown)
508
287
  end
509
288
 
510
- # Timers declared on the Room class with `periodically`
511
- def start_periodic_timers
512
- room_class.periodic_timers.each do |callback, every|
513
- @periodic_timers << start_periodic_timer(-> { room.instance_exec(&callback) }, every: every)
289
+ def wrap_work(blk)
290
+ proc do
291
+ worker_pool.invoke(blk, :call, connection: self)
292
+ rescue => e
293
+ report_work_error(e)
514
294
  end
515
295
  end
516
296
 
517
- def stop_periodic_timers
518
- @periodic_timers.each(&:shutdown)
519
- @periodic_timers.clear
520
- end
521
-
522
297
  def enqueue(work, silent:)
523
- @mutex.synchronize do
524
- if closed?
525
- raise "Attempt to post work to dead or shutting down room" unless silent
526
- return
527
- end
528
-
298
+ accepted = @queue_lock.synchronize do
299
+ next false if closed?
529
300
  @work_queue << work
301
+ true
302
+ end
303
+
304
+ unless accepted
305
+ raise "Attempt to post work to dead or shutting down room" unless silent
306
+ return
530
307
  end
531
308
 
532
309
  schedule_work
@@ -536,28 +313,18 @@ module CableRoom
536
313
  # room is running already. When it finishes, come back for the next one. This is the whole
537
314
  # ordering guarantee: one thread per room at a time, in arrival order.
538
315
  def schedule_work
539
- @mutex.synchronize do
540
- return if @processing_work
541
- # A frozen room keeps its queue but runs nothing from it (while :freezing it still
542
- # drains, so that `freeze!` can return to a quiet room)
543
- return if state == :frozen
544
-
545
- work = @work_queue.shift
546
- return unless work
547
-
548
- @processing_work = true
549
-
550
- worker_pool.executor.post do
551
- begin
552
- work.call
553
- ensure
554
- @mutex.synchronize do
555
- @processing_work = false
556
- @work_finished.broadcast
557
- end
558
- schedule_work
559
- end
560
- end
316
+ work = @queue_lock.synchronize do
317
+ next if @processing_work
318
+
319
+ @work_queue.shift.tap { |w| @processing_work = true if w }
320
+ end
321
+ return unless work
322
+
323
+ worker_pool.executor.post do
324
+ work.call
325
+ ensure
326
+ @queue_lock.synchronize { @processing_work = false }
327
+ schedule_work
561
328
  end
562
329
  end
563
330