cable_room 0.8.0.beta1 → 0.8.0.beta2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c01eff2a2851ae08dbbdfd83e8e168c874ba57eba33d473c2d1142b6f08bd5f
4
- data.tar.gz: 29956f4064236b00b416e947813f60ec35602964df2825c84257c4d3e6030044
3
+ metadata.gz: d0506851a3e7f5f160cc74d3c114f0233b91322b2c0037ae63a641c7486876c7
4
+ data.tar.gz: 448745b3feb776e0d3e32f3a720f3c25b549cc66659056911b2d6a6ae5116891
5
5
  SHA512:
6
- metadata.gz: 4b1b1b7bbf0d1482076987191905c32fe064357639558cb83f9964636e2dfb2436b65d67195a2ef342efce7a9114374c785a043183d9e060e6671605a9e18994
7
- data.tar.gz: 6119631c493af515e52a2d69ef7d796b0b27b22684faeb0c75aacade5ef50793d1b6983f0ecadf8c7718ade8f4bc602bfc689ca5f100c99b5233d9c7949a67be
6
+ metadata.gz: 7c41faeb794a2f112471ecf9202693a237f0839e63493df9100383b21bf151029f915eff74f8b2cf70980a51290eeb5a85a5cdc1cb675168d1aa1ee25eb7db2c
7
+ data.tar.gz: c0cdfaa13741102891ed4951d59b521ddd82671e73634ec23c68eac4eb9528a829c02a323feff95219e46b312119ef8c797e29ad021547551772e132583ca0be
data/cable_room.gemspec CHANGED
@@ -18,14 +18,21 @@ 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,lib}/**/*", "README.md", "*.gemspec"]
21
+ spec.files = Dir["{app,config,db,exe,lib}/**/*", "README.md", "*.gemspec"]
22
+ spec.bindir = "exe"
23
+ spec.executables = ["cable_room"]
22
24
  spec.require_paths = ['lib']
23
25
 
24
26
  spec.add_dependency "rails", ">= 7.2", "< 9.0"
25
27
  spec.add_dependency "rufus-scheduler", "~> 3.6"
26
28
  spec.add_dependency "redlock", "~> 2.0"
27
29
  spec.add_dependency "rediconn", "~> 0.1.2"
30
+ # Workaround approved October 2, 2026: rediconn requires "redis" at load time but only declares
31
+ # it as a development dependency, so cable_room declares it instead. Drop this once rediconn
32
+ # does. The Bus needs >= 5: redis-rb 4 holds its client lock for the whole SUBSCRIBE loop, so
33
+ # another thread can't add a channel to a live session. The ceiling matches ActionCable 8.1's
34
+ # Redis adapter (7.2's is "< 6"), so apps on that adapter still resolve.
35
+ spec.add_dependency "redis", ">= 5", "< 7"
28
36
 
29
- spec.add_development_dependency "redis"
30
37
  spec.add_development_dependency 'rspec', '~> 3'
31
38
  end
data/exe/cable_room ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Loads only the CLI, not the gem: `cable_room server` boots the Rails app itself, and the app's
5
+ # Gemfile is what should load CableRoom (so its Railtie runs at the usual time).
6
+ require "cable_room/cli"
7
+
8
+ exit CableRoom::CLI.new(ARGV).run
@@ -0,0 +1,342 @@
1
+ require 'set'
2
+ require 'redis'
3
+ require 'active_support/json'
4
+
5
+ module CableRoom
6
+ # The Redis pub/sub bus for member-to-room traffic. It's a thin layer over the `CABLEROOM_*`
7
+ # Redis connection (see `CableRoom.redis_pool`) that adds three things:
8
+ #
9
+ # * One place that knows the channel names (`cr:{room_port_key}:in` and friends, and
10
+ # `cr:provision`).
11
+ # * JSON encoding on the way in and decoding on the way out, using the same coder the room
12
+ # ports already use for ActionCable streams, so a bad payload fails at the publisher.
13
+ # * A single subscriber thread that owns a dedicated pub/sub connection and hands each
14
+ # decoded message to the handler registered for its channel.
15
+ #
16
+ # Ordering: Redis delivers messages on one channel in publish order, and one thread dispatches
17
+ # them one at a time, so a handler sees them in the order they were published. `subscribe`
18
+ # blocks until Redis confirms the subscription, so anything published after it returns is
19
+ # delivered.
20
+ #
21
+ # Reconnects: when the pub/sub connection drops, the subscriber thread reports the error and
22
+ # opens a new session for every channel it still has a handler for, backing off from 0.1 s up
23
+ # to 5 s between attempts. Redis pub/sub doesn't queue, so anything published while the
24
+ # session is down is lost. Callers that can't afford that need their own recovery (members
25
+ # re-announce on their ping, for example).
26
+ #
27
+ # Handlers run on the subscriber thread. Keep them short (hand the work to a queue) and never
28
+ # block in them waiting on the bus itself. A handler is allowed to call `subscribe` and
29
+ # `unsubscribe`; those calls return without waiting when made from the subscriber thread,
30
+ # because the confirmation they'd wait for is delivered by that same thread.
31
+ class Bus
32
+ # Raised at the publisher when a payload can't be turned into JSON.
33
+ class EncodeError < ArgumentError; end
34
+
35
+ # Raised when Redis doesn't confirm a subscribe or unsubscribe within the timeout.
36
+ class TimeoutError < StandardError; end
37
+
38
+ KEY_PREFIX = "cr".freeze
39
+ DEFAULT_TIMEOUT = 5
40
+
41
+ INITIAL_BACKOFF = 0.1
42
+ MAX_BACKOFF = 5
43
+
44
+ class << self
45
+ # Pub/sub channel that a room's host listens on for member-to-room messages. A room's main
46
+ # inbound port has no suffix (`cr:{room_port_key}:in`); a custom inbound port gets one
47
+ # (`cr:{room_port_key}:in:{port}`), so every channel a room listens on shares one prefix.
48
+ # The room part is the room's own stream name, so an app's `broadcasting_for` override
49
+ # (PandaPal's tenant prefix) keeps two tenants' rooms apart here too.
50
+ def inbound_channel(room_class, room_key, port = nil)
51
+ channel = room_channel(room_class.room_port_key(room_key), "in")
52
+ port.nil? ? channel : "#{channel}:#{port}"
53
+ end
54
+
55
+ # The one process-wide channel `create: true` members ask for rooms on, and every rooms
56
+ # Host's Placement listens to (see Host::Placement). It isn't per tenant: the tenant rides in
57
+ # the request, and the Host that claims the room switches into it.
58
+ def provision_channel
59
+ "#{KEY_PREFIX}:provision"
60
+ end
61
+
62
+ private
63
+
64
+ def room_channel(port_key, suffix)
65
+ key = port_key.to_s
66
+ raise ArgumentError, "room_port_key can't be blank" if key.empty?
67
+ "#{KEY_PREFIX}:#{key}:#{suffix}"
68
+ end
69
+ end
70
+
71
+ # `redis_pool` defaults to `CableRoom.redis_pool` (looked up lazily so specs can swap it).
72
+ def initialize(redis_pool: nil)
73
+ @redis_pool = redis_pool
74
+
75
+ # Everything below is guarded by @mutex and changes are announced on @changed
76
+ @mutex = Mutex.new
77
+ @changed = ConditionVariable.new
78
+ @handlers = {} # channel => handler; the channels we want to be subscribed to
79
+ @requested = Set.new # channels we've sent SUBSCRIBE for in the current session
80
+ @confirmed = Set.new # channels Redis has confirmed in the current session
81
+ @thread = nil # the subscriber thread, nil when no channels are wanted
82
+ @session_live = false # true while @subscriber_redis accepts commands from other threads
83
+ @subscriber_redis = nil
84
+ end
85
+
86
+ # ---- Publisher --------------------------------------------------------------------------
87
+
88
+ # Publish a JSON-encodable payload. Returns the number of subscribers that received it.
89
+ def publish(channel, payload)
90
+ encoded = encode(payload)
91
+ redis { |r| r.publish(channel, encoded) }
92
+ end
93
+
94
+ # ---- Subscriber -------------------------------------------------------------------------
95
+
96
+ # Register `handler` for `channel` and block until Redis confirms the subscription. The
97
+ # handler is called as `handler.call(message, channel)` with the decoded message on the
98
+ # subscriber thread. Subscribing again to the same channel replaces the handler.
99
+ #
100
+ # Raises TimeoutError (and forgets the handler) if the subscription isn't confirmed in time,
101
+ # which usually means Redis is unreachable.
102
+ def subscribe(channel, timeout: DEFAULT_TIMEOUT, &handler)
103
+ raise ArgumentError, "subscribe needs a block to handle messages" unless handler
104
+ channel = channel.to_s
105
+
106
+ @mutex.synchronize do
107
+ @handlers[channel] = handler
108
+ if @thread
109
+ reconcile
110
+ else
111
+ start_subscriber_thread
112
+ end
113
+
114
+ return true if on_subscriber_thread?
115
+
116
+ begin
117
+ wait_until(timeout, "subscription to #{channel}") { @confirmed.include?(channel) }
118
+ rescue TimeoutError
119
+ @handlers.delete(channel)
120
+ reconcile
121
+ raise
122
+ end
123
+ end
124
+ true
125
+ end
126
+
127
+ # Drop the handler for `channel` and block until Redis confirms the unsubscribe. When no
128
+ # channels are left the subscriber thread exits, and this also waits for that. Returns
129
+ # false if the channel wasn't subscribed. With `handler:`, only unsubscribes while that
130
+ # handler is still the one registered (a later `subscribe` may have replaced it).
131
+ def unsubscribe(channel, handler: nil, timeout: DEFAULT_TIMEOUT)
132
+ channel = channel.to_s
133
+
134
+ @mutex.synchronize do
135
+ return false if handler && !@handlers[channel].equal?(handler)
136
+ return false unless @handlers.delete(channel)
137
+ reconcile
138
+
139
+ return true if on_subscriber_thread?
140
+
141
+ wait_until(timeout, "unsubscribe from #{channel}") do
142
+ !@confirmed.include?(channel) && (@thread.nil? || @handlers.any?)
143
+ end
144
+ end
145
+ true
146
+ end
147
+
148
+ # Unsubscribe from everything and wait for the subscriber thread to exit.
149
+ def shutdown(timeout: DEFAULT_TIMEOUT)
150
+ @mutex.synchronize do
151
+ @handlers.clear
152
+ reconcile
153
+ return true if on_subscriber_thread?
154
+ wait_until(timeout, "subscriber thread to exit") { @thread.nil? }
155
+ end
156
+ true
157
+ end
158
+
159
+ # Channels Redis has confirmed we're subscribed to right now.
160
+ def subscribed_channels
161
+ @mutex.synchronize { @confirmed.to_a }
162
+ end
163
+
164
+ def subscribed?(channel)
165
+ @mutex.synchronize { @confirmed.include?(channel.to_s) }
166
+ end
167
+
168
+ def subscriber_alive?
169
+ thread = @mutex.synchronize { @thread }
170
+ !!thread&.alive?
171
+ end
172
+
173
+ private
174
+
175
+ def redis_pool
176
+ @redis_pool || CableRoom.redis_pool
177
+ end
178
+
179
+ def redis(&blk)
180
+ redis_pool.with(&blk)
181
+ end
182
+
183
+ def encode(payload)
184
+ ActiveSupport::JSON.encode(payload)
185
+ rescue StandardError => e
186
+ raise EncodeError, "payload can't be encoded as JSON: #{e.class}: #{e.message}"
187
+ end
188
+
189
+ def decode(raw)
190
+ ActiveSupport::JSON.decode(raw)
191
+ end
192
+
193
+ def on_subscriber_thread?
194
+ Thread.current == @thread
195
+ end
196
+
197
+ # The subscriber needs a connection of its own: a pub/sub connection can't run other
198
+ # commands, and it lives as long as there are subscriptions, so it must not be a pooled
199
+ # one (a small pool would run dry). Borrow a pooled client just long enough to dup it,
200
+ # which gives an unconnected client with the same settings and no pool ties.
201
+ def build_subscriber_redis
202
+ redis(&:dup)
203
+ end
204
+
205
+ def start_subscriber_thread
206
+ @thread = Thread.new { subscriber_loop }
207
+ @thread.name = "cable_room-bus-subscriber"
208
+ @thread.report_on_exception = false
209
+ end
210
+
211
+ # Runs on the subscriber thread. Each pass opens one pub/sub session covering every wanted
212
+ # channel; a session ends when the last channel is unsubscribed (normal) or the connection
213
+ # fails (reported, then retried with backoff). The loop exits once nothing is wanted.
214
+ def subscriber_loop
215
+ backoff = INITIAL_BACKOFF
216
+ loop do
217
+ channels = @mutex.synchronize do
218
+ if @handlers.empty?
219
+ @thread = nil
220
+ @changed.broadcast
221
+ return
222
+ end
223
+ @handlers.keys
224
+ end
225
+
226
+ begin
227
+ run_session(channels)
228
+ backoff = INITIAL_BACKOFF
229
+ rescue StandardError => e
230
+ CableRoom.report_error(e, bus: self, channels: channels)
231
+ sleep backoff
232
+ backoff = [backoff * 2, MAX_BACKOFF].min
233
+ end
234
+ end
235
+ end
236
+
237
+ def run_session(channels)
238
+ redis = build_subscriber_redis
239
+ @mutex.synchronize { @requested = Set.new(channels) }
240
+
241
+ # redis-rb blocks here until the subscription count drops to zero, calling back for every
242
+ # event. Other threads add and remove channels by sending commands on the same client.
243
+ redis.subscribe(*channels) do |on|
244
+ on.subscribe do |channel, _count|
245
+ @mutex.synchronize do
246
+ unless @session_live
247
+ # redis-rb has the pub/sub socket up once the first confirmation arrives, so from
248
+ # here on other threads may send SUBSCRIBE/UNSUBSCRIBE through it
249
+ @session_live = true
250
+ @subscriber_redis = redis
251
+ end
252
+ @confirmed << channel
253
+ reconcile
254
+ @changed.broadcast
255
+ end
256
+ end
257
+
258
+ on.unsubscribe do |channel, count|
259
+ @mutex.synchronize do
260
+ @confirmed.delete(channel)
261
+ @requested.delete(channel)
262
+ if count.zero?
263
+ # redis-rb closes the socket as soon as this callback returns, so stop other
264
+ # threads from writing to it. Anything wanted by then starts a fresh session.
265
+ @session_live = false
266
+ @subscriber_redis = nil
267
+ else
268
+ reconcile
269
+ end
270
+ @changed.broadcast
271
+ end
272
+ end
273
+
274
+ on.message do |channel, raw|
275
+ dispatch(channel, raw)
276
+ end
277
+ end
278
+ ensure
279
+ @mutex.synchronize do
280
+ @session_live = false
281
+ @subscriber_redis = nil
282
+ @requested.clear
283
+ @confirmed.clear
284
+ @changed.broadcast
285
+ end
286
+ redis&.close
287
+ end
288
+
289
+ # Bring the server-side subscription set in line with the handlers we hold. Only possible
290
+ # while a session is live; before that, the thread's first confirmation calls this to pick
291
+ # up anything added or removed while it was connecting. Caller must hold @mutex.
292
+ #
293
+ # The writes below happen under @mutex, which is safe because they only queue a command on
294
+ # the socket: nothing here waits on a reply, and the subscriber thread never needs anything
295
+ # but @mutex to make progress.
296
+ def reconcile
297
+ return unless @session_live
298
+
299
+ wanted = @handlers.keys
300
+ (wanted - @requested.to_a).each do |channel|
301
+ @requested << channel
302
+ @subscriber_redis.subscribe(channel)
303
+ end
304
+ (@requested.to_a - wanted).each do |channel|
305
+ @requested.delete(channel)
306
+ @subscriber_redis.unsubscribe(channel)
307
+ end
308
+ rescue Redis::BaseConnectionError, RedisClient::ConnectionError
309
+ # The socket died under us. Stop writing to it and let the subscriber thread notice, report
310
+ # it, and open a new session for every channel still wanted. A caller waiting in
311
+ # `subscribe` rides on that session's confirmation (or times out) rather than seeing the
312
+ # connection error for a subscription the bus is about to retry anyway.
313
+ @session_live = false
314
+ @subscriber_redis = nil
315
+ end
316
+
317
+ # Decode and hand one message to its handler. A handler that raises is reported and the
318
+ # thread carries on; one bad message must never take the bus down.
319
+ def dispatch(channel, raw)
320
+ handler = @mutex.synchronize { @handlers[channel] }
321
+ return unless handler # unsubscribed while the message was in flight
322
+
323
+ handler.call(decode(raw), channel)
324
+ rescue StandardError => e
325
+ CableRoom.report_error(e, bus: self, channel: channel, raw_message: raw)
326
+ end
327
+
328
+ # Wait on @changed until the block is true or the timeout passes. Caller must hold @mutex.
329
+ def wait_until(timeout, reason)
330
+ deadline = monotonic_now + timeout
331
+ until yield
332
+ remaining = deadline - monotonic_now
333
+ raise TimeoutError, "Timed out after #{timeout}s waiting for #{reason}" if remaining <= 0
334
+ @changed.wait(@mutex, remaining)
335
+ end
336
+ end
337
+
338
+ def monotonic_now
339
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "etc"
5
+ require "logger"
6
+ require_relative "version"
7
+
8
+ module CableRoom
9
+ # The `cable_room` command. Its one command, `server`, is the rooms process for
10
+ # `room_host = :remote`: boot the Rails app, become this process's rooms Host, run until SIGTERM
11
+ # or SIGINT, then shut every room down (members get room_closed). Rooms aren't migrated
12
+ # anywhere on the way out.
13
+ #
14
+ # It refuses to run in :inline, where the web processes host rooms themselves and a server here
15
+ # would only race them for locks.
16
+ #
17
+ # What the rooms process needs from the app:
18
+ #
19
+ # * The app's own config/cable.yml with a Redis adapter. Rooms still talk to members with
20
+ # `ActionCable.server.broadcast`, which reaches web processes only through a shared
21
+ # pubsub; the async and inline adapters keep broadcasts inside this process.
22
+ # * The same CABLEROOM_* Redis settings as the web processes. Members publish to rooms on
23
+ # the Bus there, and room locks live there.
24
+ # * A tenant for every room it starts, in a multi-tenant app. Provision requests carry the
25
+ # member's tenant; a room started here by hand needs `MyRoom.ensure(key, tenant: ...)`.
26
+ #
27
+ # Rooms start here through provisioning: a `create: true` join on the web side publishes a
28
+ # provision request on the Bus, and each rooms Host's Placement (started by `Host.start!`)
29
+ # waits in proportion to its load, then races for the room's lock (see Host::Placement).
30
+ #
31
+ # Boot works the way sidekiq and good_job do it: `require` the app's config/environment.rb from
32
+ # the current directory, or from `--require PATH` (an app directory or a boot file). Bundler is
33
+ # already set up because the command runs under `bundle exec`. Nothing from the gem beyond this
34
+ # file and `version` loads before the app boots, so the app's Gemfile decides when the Railtie
35
+ # loads, the same as in a web process. Nothing subscribes to Redis or starts a thread until
36
+ # the app has booted and been eager-loaded, so a supervisor can fork after that point.
37
+ #
38
+ # `--workers N` picks the process model. With N = 1 this process hosts rooms itself, as a
39
+ # single rooms process always has; the container's restart policy is its supervisor. With
40
+ # N > 1 (the default is the machine's processor count) the app is booted once, here, and then
41
+ # `Host::Supervisor` forks N children that each host rooms with their own Bus connection;
42
+ # this parent only watches them, replaces one that dies, and relays SIGTERM and SIGINT. The
43
+ # parent never builds a Host, subscribes the Bus, or keeps a Redis or database connection:
44
+ # the children build their own after the fork, so no two processes ever share a socket or a
45
+ # thread.
46
+ #
47
+ # `run` returns an exit status rather than exiting, so specs can drive it in-process.
48
+ class CLI
49
+ STOP_SIGNALS = %w[TERM INT].freeze
50
+
51
+ # How long a room's startup waits for Redis to confirm each Bus subscription here. A healthy
52
+ # Redis confirms in milliseconds; this only bounds a sick one, and failing sooner frees the
53
+ # room's lock sooner. The web-side default (Bus::DEFAULT_TIMEOUT, 5 s) is sized for a member
54
+ # join that can afford to wait.
55
+ SUBSCRIBE_TIMEOUT = 2
56
+
57
+ attr_reader :options, :command
58
+
59
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
60
+ @argv = argv.dup
61
+ @stdout = stdout
62
+ @stderr = stderr
63
+ @options = { require: Dir.pwd, workers: nil }
64
+ end
65
+
66
+ # Parse and run. Returns the process exit status.
67
+ def run
68
+ parse!
69
+ return 0 if @help_shown
70
+
71
+ case command
72
+ when "server" then server
73
+ when nil
74
+ @stderr.puts parser
75
+ 1
76
+ else
77
+ @stderr.puts "Unknown command #{command.inspect}.", parser
78
+ 1
79
+ end
80
+ rescue OptionParser::ParseError => e
81
+ @stderr.puts e.message, parser
82
+ 1
83
+ end
84
+
85
+ # How many worker processes `server` runs: `--workers`, or one per processor
86
+ def workers
87
+ options[:workers] || Etc.nprocessors
88
+ end
89
+
90
+ private
91
+
92
+ def parser
93
+ @parser ||= OptionParser.new do |o|
94
+ o.banner = "Usage: cable_room server [options]"
95
+ o.separator ""
96
+ o.separator "Hosts CableRoom rooms for a Rails app with room_host = :remote: boots the app in"
97
+ o.separator "the current directory and runs until SIGTERM or SIGINT. Needs the app's"
98
+ o.separator "config/cable.yml (a Redis adapter) and the same CABLEROOM_* Redis as the web."
99
+ o.separator ""
100
+ o.on("-r", "--require PATH", "App directory (with config/environment.rb) or a boot file to require") do |path|
101
+ @options[:require] = path
102
+ end
103
+ o.on("-w", "--workers N", Integer,
104
+ "Worker processes to fork (default: one per processor, #{Etc.nprocessors} here). 1 runs in this process.") do |n|
105
+ @options[:workers] = n
106
+ end
107
+ o.on("-v", "--version", "Print the version and exit") do
108
+ @stdout.puts CableRoom::VERSION
109
+ @help_shown = true
110
+ end
111
+ o.on("-h", "--help", "Show this help") do
112
+ @stdout.puts o
113
+ @help_shown = true
114
+ end
115
+ end
116
+ end
117
+
118
+ def parse!
119
+ rest = parser.parse(@argv)
120
+ @command = rest.shift
121
+ raise OptionParser::ParseError, "Unexpected arguments: #{rest.join(' ')}" if rest.any?
122
+ end
123
+
124
+ def server
125
+ # Check the cheap things before booting Rails, so a bad flag fails in milliseconds
126
+ unless workers >= 1
127
+ @stderr.puts "cable_room server: --workers must be at least 1 (got #{workers})."
128
+ return 1
129
+ end
130
+
131
+ boot_app!
132
+
133
+ unless CableRoom.remote?
134
+ @stderr.puts "cable_room server: CableRoom.room_host is #{CableRoom.room_host.inspect}, but a rooms " \
135
+ "process only makes sense with :remote (in :inline the web processes host rooms " \
136
+ "themselves). Set CABLE_ROOM_HOST=remote or `CableRoom.room_host = :remote` and try again."
137
+ return 1
138
+ end
139
+
140
+ warn_about_local_cable_adapter
141
+ return run_host if workers == 1
142
+
143
+ supervise_workers
144
+ end
145
+
146
+ # Load the app: a directory means its config/environment.rb, anything else is required as-is.
147
+ # Then eager-load it, as production does anyway, so every room class is loaded before any
148
+ # room starts (and before a supervisor forks).
149
+ def boot_app!
150
+ path = File.expand_path(options[:require])
151
+ path = File.join(path, "config", "environment.rb") if File.directory?(path)
152
+ require path
153
+
154
+ Rails.application.eager_load! if defined?(Rails) && Rails.application && !Rails.application.config.eager_load
155
+ end
156
+
157
+ def warn_about_local_cable_adapter
158
+ cable = ActionCable.server.config.cable || {}
159
+ adapter = (cable[:adapter] || cable["adapter"]).to_s
160
+ return unless %w[async inline test].include?(adapter)
161
+
162
+ @stderr.puts "cable_room server: warning: ActionCable's adapter is #{adapter.inspect}, so room broadcasts " \
163
+ "never leave this process and members won't hear their rooms. Use the app's Redis cable.yml."
164
+ end
165
+
166
+ # Fork `workers` children that each run `run_host`, and watch them until a stop signal has
167
+ # come and gone. This parent hosts nothing itself (see the class comment for why).
168
+ def supervise_workers
169
+ Process.setproctitle("cable_room server: supervisor (#{workers} workers)")
170
+ # Fail now, once, if the CABLEROOM_* Redis is unreachable, rather than in N workers that
171
+ # would each die and come back with backoff. Then let go of what the ping (or the app's
172
+ # boot) opened, so the children inherit no Redis or database sockets.
173
+ CableRoom.redis(&:ping)
174
+ release_connections_before_fork
175
+ say "supervising #{workers} workers (pid #{Process.pid}, room_host=#{CableRoom.room_host})"
176
+
177
+ supervisor = CableRoom::Host::Supervisor.new(count: workers, logger: supervisor_logger) do |index|
178
+ run_host(worker: index)
179
+ end
180
+ status = supervisor.run
181
+ say "shut down"
182
+ status
183
+ end
184
+
185
+ # Close the parent's idle pooled Redis connections and its ActiveRecord connections. The
186
+ # children would drop their copies anyway (see `CableRoom.after_fork!`); closing them here
187
+ # means there are no copies to drop, and the parent, which only waits on children from now
188
+ # on, holds nothing it won't use. Both pools reconnect lazily if anything asks again.
189
+ def release_connections_before_fork
190
+ CableRoom.redis_pool.reload(&:close)
191
+ ActiveRecord::Base.connection_handler.clear_all_connections!(:all) if defined?(ActiveRecord::Base)
192
+ end
193
+
194
+ # Logs from the supervisor go to the same place as the CLI's own messages, in the same style.
195
+ # Unbuffered, so a "forked worker" line shows up when it happens, not when the process exits.
196
+ def supervisor_logger
197
+ @stdout.sync = true if @stdout.respond_to?(:sync=)
198
+ Logger.new(@stdout).tap do |logger|
199
+ logger.formatter = ->(_severity, _time, _progname, message) { "cable_room server: #{message}\n" }
200
+ end
201
+ end
202
+
203
+ # Host rooms in this process until SIGTERM or SIGINT, then shut them down. This is the whole
204
+ # life of a single-process server and of each forked worker (`worker` is the worker's index,
205
+ # nil when there's no supervisor). Returns the exit status.
206
+ def run_host(worker: nil)
207
+ # Trap before anything slow, so a signal that lands while rooms are still coming up (the
208
+ # supervisor relaying a SIGTERM that arrived mid-boot, say) is kept, not fatal
209
+ signals = trap_stop_signals
210
+
211
+ if worker
212
+ Process.setproctitle("cable_room server: worker #{worker}")
213
+ tag_logger("cable_room worker #{worker}")
214
+ end
215
+
216
+ # Fail now, not at the first room, if the CABLEROOM_* Redis is unreachable
217
+ CableRoom.redis(&:ping)
218
+
219
+ host = CableRoom::Host.start!(subscribe_timeout: SUBSCRIBE_TIMEOUT)
220
+ say "hosting rooms (pid #{Process.pid}, room_host=#{CableRoom.room_host})", worker: worker
221
+
222
+ signal = signals.pop
223
+ say "got SIG#{signal}, shutting down #{host.open_rooms_count} room(s)", worker: worker
224
+ # A second signal from here on gets the previous handler (the default one kills at once)
225
+ restore_signal_handlers
226
+ host.shutdown!
227
+ CableRoom.bus.shutdown
228
+ say "shut down", worker: worker
229
+ 0
230
+ ensure
231
+ restore_signal_handlers
232
+ end
233
+
234
+ # Every room on this Host logs through `ActionCable.server.logger` (Host#logger); give that
235
+ # logger a tag naming the worker, so lines from the N workers sharing one stdout can be told
236
+ # apart. `tagged` without a block returns a logger whose tags apply on every thread, which is
237
+ # what we need: rooms log from the worker pool, not from this thread. A logger without tags
238
+ # (a plain Logger, say) is left as it is.
239
+ def tag_logger(tag)
240
+ logger = ActionCable.server.logger
241
+ return unless logger.respond_to?(:tagged)
242
+
243
+ tagged = logger.tagged(tag)
244
+ ActionCable.server.config.logger = tagged if tagged.respond_to?(:info)
245
+ end
246
+
247
+ # Signal handlers may only do trivial work, so each one just drops the signal's name on a
248
+ # queue for the main thread to pick up
249
+ def trap_stop_signals
250
+ signals = Queue.new
251
+ @previous_handlers = STOP_SIGNALS.to_h { |sig| [sig, trap(sig) { signals << sig }] }
252
+ signals
253
+ end
254
+
255
+ def restore_signal_handlers
256
+ @previous_handlers&.each { |sig, handler| trap(sig, handler) }
257
+ @previous_handlers = nil
258
+ end
259
+
260
+ def say(message, worker: nil)
261
+ prefix = worker ? "cable_room server (worker #{worker})" : "cable_room server"
262
+ @stdout.puts "#{prefix}: #{message}"
263
+ @stdout.flush
264
+ end
265
+ end
266
+ end