From c2adfa1a394c0101e53118bd3a46fb4008a066f7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 22 May 2026 12:25:42 +0900 Subject: [PATCH 1/5] Modernize code. --- .github/copilot-instructions.md | 20 ++++ .github/workflows/documentation-coverage.yaml | 3 +- .github/workflows/documentation.yaml | 4 +- .github/workflows/rubocop.yaml | 2 +- .github/workflows/test-coverage.yaml | 8 +- .github/workflows/test-external.yaml | 4 +- .github/workflows/test.yaml | 4 +- .gitignore | 3 +- .rubocop.yml | 38 ++++++++ async-cable.gemspec | 2 +- bake.rb | 11 ++- benchmark/application.rb | 5 +- benchmark/broadcast.rb | 3 + benchmark/gems.rb | 3 + benchmark/servers/anycable.rb | 7 +- benchmark/servers/falcon.rb | 3 + benchmark/servers/iodine.rb | 93 ++++++++++--------- benchmark/servers/puma.rb | 3 + .../app/controllers/application_controller.rb | 3 + example/app/controllers/items_controller.rb | 3 + example/app/helpers/application_helper.rb | 3 + example/app/helpers/items_helper.rb | 3 + example/app/jobs/application_job.rb | 3 + example/app/mailers/application_mailer.rb | 3 + example/app/models/application_record.rb | 3 + example/app/models/item.rb | 3 + example/config/application.rb | 3 + example/config/boot.rb | 3 + example/config/environment.rb | 4 +- example/config/environments/development.rb | 3 + example/config/environments/production.rb | 3 + example/config/environments/test.rb | 6 +- example/config/importmap.rb | 3 +- example/config/initializers/assets.rb | 3 +- .../initializers/content_security_policy.rb | 3 +- .../initializers/filter_parameter_logging.rb | 3 +- example/config/initializers/inflections.rb | 3 +- example/config/puma.rb | 29 +----- example/config/routes.rb | 3 + example/db/cable_schema.rb | 3 + example/db/cache_schema.rb | 3 + .../db/migrate/20241115075615_create_items.rb | 3 + example/db/queue_schema.rb | 3 + example/db/schema.rb | 13 +-- example/db/seeds.rb | 12 +-- example/test/application_system_test_case.rb | 3 + .../test/controllers/items_controller_test.rb | 3 + example/test/models/item_test.rb | 3 + example/test/system/items_test.rb | 3 + example/test/test_helper.rb | 3 + fixtures/test_channel.rb | 2 +- gems.rb | 8 +- lib/async/cable/middleware.rb | 2 +- lib/async/cable/socket.rb | 2 +- lib/async/cable/version.rb | 2 +- license.md | 2 +- readme.md | 16 ++++ test/async/cable/middleware.rb | 2 +- 58 files changed, 267 insertions(+), 130 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..1de68b2 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,20 @@ +# GitHub Copilot Instructions + +## Use Agent Context + +When working on this project, consult the `agents.md` file for project-specific guidelines, architecture decisions, and development patterns. This file contains curated information that will help you make better decisions aligned with the project's goals and standards. + +If the file does not exist, you will need to install it, by running the following command: + +```bash +$ bundle install +$ bundle exec bake agent:context:install +``` + +This command will set up the necessary context files that help you understand the project structure, dependencies, and conventions. + +## Ignoring Files + +The `.gitignore` file is split into two sections, separated by a blank line. The first section is automatically generated, while the second section is user controlled. + +While working on pull requests, you should not add unrelated changes to the `.gitignore` file as part of the pull request. diff --git a/.github/workflows/documentation-coverage.yaml b/.github/workflows/documentation-coverage.yaml index 4e8d7ff..59a5593 100644 --- a/.github/workflows/documentation-coverage.yaml +++ b/.github/workflows/documentation-coverage.yaml @@ -7,13 +7,14 @@ permissions: env: COVERAGE: PartialSummary + BUNDLE_WITH: maintenance jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index e112a90..bfb7b97 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: @@ -39,7 +39,7 @@ jobs: run: bundle exec bake utopia:project:static --force no - name: Upload documentation artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: docs diff --git a/.github/workflows/rubocop.yaml b/.github/workflows/rubocop.yaml index d6dd892..da60e9e 100644 --- a/.github/workflows/rubocop.yaml +++ b/.github/workflows/rubocop.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml index 5f673df..5798e84 100644 --- a/.github/workflows/test-coverage.yaml +++ b/.github/workflows/test-coverage.yaml @@ -23,7 +23,7 @@ jobs: - ruby steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} @@ -33,7 +33,7 @@ jobs: timeout-minutes: 5 run: bundle exec bake test - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: include-hidden-files: true if-no-files-found: error @@ -45,13 +45,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ruby bundler-cache: true - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v6 - name: Validate coverage timeout-minutes: 5 diff --git a/.github/workflows/test-external.yaml b/.github/workflows/test-external.yaml index 287124c..9ec63e2 100644 --- a/.github/workflows/test-external.yaml +++ b/.github/workflows/test-external.yaml @@ -17,12 +17,12 @@ jobs: - macos ruby: - - "3.2" - "3.3" - "3.4" + - "4.0" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 69d7c6b..e91d97c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,9 +18,9 @@ jobs: - macos ruby: - - "3.2" - "3.3" - "3.4" + - "4.0" experimental: [false] @@ -36,7 +36,7 @@ jobs: experimental: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{matrix.ruby}} diff --git a/.gitignore b/.gitignore index bd2467d..ee82a6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -/.bundle +/agents.md /.context +/.bundle /pkg /gems.locked /.covered.db diff --git a/.rubocop.yml b/.rubocop.yml index 116fd0d..0eeddec 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,12 +1,23 @@ plugins: + - rubocop-md - rubocop-socketry AllCops: DisabledByDefault: true +# Socketry specific rules: + Layout/ConsistentBlankLineIndentation: Enabled: true +Layout/BlockDelimiterSpacing: + Enabled: true + +Style/GlobalExceptionVariables: + Enabled: true + +# General Layout rules: + Layout/IndentationStyle: Enabled: true EnforcedStyle: tabs @@ -33,6 +44,9 @@ Layout/BeginEndAlignment: Enabled: true EnforcedStyleAlignWith: start_of_line +Layout/RescueEnsureAlignment: + Enabled: true + Layout/ElseAlignment: Enabled: true @@ -41,10 +55,15 @@ Layout/DefEndAlignment: Layout/CaseIndentation: Enabled: true + EnforcedStyle: end Layout/CommentIndentation: Enabled: true +Layout/FirstHashElementIndentation: + Enabled: true + EnforcedStyle: consistent + Layout/EmptyLinesAroundClassBody: Enabled: true @@ -63,6 +82,25 @@ Layout/SpaceAroundBlockParameters: Enabled: true EnforcedStyleInsidePipes: no_space +Layout/FirstArrayElementIndentation: + Enabled: true + EnforcedStyle: consistent + +Layout/ArrayAlignment: + Enabled: true + EnforcedStyle: with_fixed_indentation + +Layout/FirstArgumentIndentation: + Enabled: true + EnforcedStyle: consistent + +Layout/ArgumentAlignment: + Enabled: true + EnforcedStyle: with_fixed_indentation + +Layout/ClosingParenthesisIndentation: + Enabled: true + Style/FrozenStringLiteralComment: Enabled: true diff --git a/async-cable.gemspec b/async-cable.gemspec index 0b301c1..9061848 100644 --- a/async-cable.gemspec +++ b/async-cable.gemspec @@ -20,7 +20,7 @@ Gem::Specification.new do |spec| spec.files = Dir["{lib}/**/*", "*.md", base: __dir__] - spec.required_ruby_version = ">= 3.2" + spec.required_ruby_version = ">= 3.3" spec.add_dependency "actioncable-next" spec.add_dependency "async", "~> 2.9" diff --git a/bake.rb b/bake.rb index 005430f..4f989e3 100644 --- a/bake.rb +++ b/bake.rb @@ -1,12 +1,19 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2024, by Samuel Williams. +# Copyright, 2024-2026, by Samuel Williams. # Update the project documentation with the new version number. # # @parameter version [String] The new version number. def after_gem_release_version_increment(version) context["releases:update"].call(version) - context["utopia:project:readme:update"].call + context["utopia:project:update"].call +end + +# Create a GitHub release for the given tag. +# +# @parameter tag [String] The tag to create a release for. +def after_gem_release(tag:, **options) + context["releases:github:release"].call(tag) end diff --git a/benchmark/application.rb b/benchmark/application.rb index e34a044..fbd8ea6 100644 --- a/benchmark/application.rb +++ b/benchmark/application.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "rails" require "global_id" @@ -30,7 +33,7 @@ class App < Rails::Application "url" => ENV["REDIS_URL"] } -ActionCable.server.config.connection_class = -> {ApplicationCable::Connection} +ActionCable.server.config.connection_class = ->{ApplicationCable::Connection} ActionCable.server.config.disable_request_forgery_protection = true ActionCable.server.config.logger = Rails.logger diff --git a/benchmark/broadcast.rb b/benchmark/broadcast.rb index db48e97..2bd6c6c 100755 --- a/benchmark/broadcast.rb +++ b/benchmark/broadcast.rb @@ -1,6 +1,9 @@ #!/usr/bin/env ruby # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + url = ARGV.pop || "http://localhost:8080/cable" require "async" diff --git a/benchmark/gems.rb b/benchmark/gems.rb index 4b060d1..d3fad90 100644 --- a/benchmark/gems.rb +++ b/benchmark/gems.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + gem "rails" gem "async-cable" diff --git a/benchmark/servers/anycable.rb b/benchmark/servers/anycable.rb index ddc1f98..f7105dd 100755 --- a/benchmark/servers/anycable.rb +++ b/benchmark/servers/anycable.rb @@ -1,6 +1,9 @@ #!/usr/bin/env ruby # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require_relative "../application" require "anycable-rails" @@ -11,8 +14,8 @@ def self.run! require "anycable/cli" cli = AnyCable::CLI.new # We're already within the app context - cli.define_singleton_method(:boot_app!) { } - + cli.define_singleton_method(:boot_app!){} + anycable_server_path = Rails.root.join("../bin/anycable-go") cli.run(["--server-command", "#{anycable_server_path} --host 0.0.0.0"]) end diff --git a/benchmark/servers/falcon.rb b/benchmark/servers/falcon.rb index f815759..588d611 100755 --- a/benchmark/servers/falcon.rb +++ b/benchmark/servers/falcon.rb @@ -1,6 +1,9 @@ #!/usr/bin/env ruby # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "async" require "async/http/endpoint" require "async/websocket/adapters/rack" diff --git a/benchmark/servers/iodine.rb b/benchmark/servers/iodine.rb index 7fea629..64df746 100755 --- a/benchmark/servers/iodine.rb +++ b/benchmark/servers/iodine.rb @@ -1,6 +1,9 @@ #!/usr/bin/env ruby # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "iodine" require_relative "../application" @@ -12,7 +15,7 @@ def initialize(*) super @redis = ::Redis.new end - + def broadcast(channel, payload) # FIXME: Doesn't publis to Redis when executed outside of the Iodine server # (e.g., from AnyT tests) @@ -21,18 +24,18 @@ def broadcast(channel, payload) end end end - + module Iodine # Middleware is a Rack middleware that upgrades HTTP requests to WebSocket connections class Middleware attr_reader :server - + delegate :logger, to: :server - + def initialize(_app, server: ::ActionCable.server) @server = server end - + def call(env) if env["rack.upgrade?"] == :websocket && server.allow_request_origin?(env) subprotocol = select_protocol(env) @@ -44,9 +47,9 @@ def call(env) [404, {}, ["Not Found"]] end end - + private - + def select_protocol(env) supported_protocols = ::ActionCable::INTERNAL[:protocols] request_protocols = env["HTTP_SEC_WEBSOCKET_PROTOCOL"] @@ -54,15 +57,15 @@ def select_protocol(env) logger.error("No Sec-WebSocket-Protocol provided") return end - + request_protocols = request_protocols.split(/,\s?/) if request_protocols.is_a?(String) - subprotocol = request_protocols.detect { _1.in?(supported_protocols) } - + subprotocol = request_protocols.detect {_1.in?(supported_protocols)} + logger.error("Unsupported protocol: #{request_protocols}") unless subprotocol subprotocol end end - + # This is a server wrapper to support Iodine native pub/sub class Server < SimpleDelegator # This is a pub/sub implementation that uses @@ -70,92 +73,92 @@ class Server < SimpleDelegator # For that, we need an instance of Iodine::Connection to call #subscribe/#unsubscribe on. class PubSubInterface < Data.define(:socket) delegate :iodine_client, to: :socket, allow_nil: true - + def subscribe(channel, handler, on_success = nil) return unless iodine_client - + # NOTE: Iodine doesn't allow having different handlers for the same channel name, # so, having multiple channels listening to the same stream is currently not possible. # # We can create internal, server-side, subscribers to handle original broadcast requests # and then forward them to the specific identifiers. SubsriberMap can be reused for that. - iodine_client.subscribe(to: channel, handler: proc { |_, msg| handler.call(msg) }) + iodine_client.subscribe(to: channel, handler: proc{|_, msg| handler.call(msg)}) on_success&.call end - + def unsubscribe(channel, _handler) iodine_client&.unsubscribe(channel) end end - + attr_accessor :pubsub - + def self.for(server, socket) new(server).tap do |srv| srv.pubsub = PubSubInterface.new(socket) end end end - + # Socket wraps Iodine client and provides ActionCable::Server::_Socket interface class Socket private attr_reader :server, :coder, :connection attr_reader :client - + alias_method :iodine_client, :client - + delegate :worker_pool, to: :server - + def initialize(server, env, protocol: nil, coder: ActiveSupport::JSON) @server = server @coder = coder @env = env - @logger = server.new_tagged_logger { request } + @logger = server.new_tagged_logger{request} @protocol = protocol - + server = Server.for(server, self) @connection = server.config.connection_class.call.new(server, self) - + # Underlying Iodine client is set on connection open @client = nil end - + #== Iodine callbacks == def on_open(conn) logger.debug "[Iodine] connection opened" - + @client = conn connection.handle_open - + server.setup_heartbeat_timer server.add_connection(connection) end - + def on_message(_conn, msg) logger.debug "[Iodine] incoming message: #{msg}" connection.handle_incoming(coder.decode(msg)) end - + def on_close(conn) logger.debug "[Iodine] connection closed" server.remove_connection(connection) connection.handle_close end - + def on_shutdown(conn) logger.debug "[Iodine] connection shutdown" conn.write( - coder.encode({ - type: :disconnect, - reason: ::ActionCable::INTERNAL[:disconnect_reasons][:server_restart], - reconnect: true - }) - ) - end - + coder.encode({ + type: :disconnect, + reason: ::ActionCable::INTERNAL[:disconnect_reasons][:server_restart], + reconnect: true + }) + ) + end + #== ActionCable socket interface == attr_reader :env, :logger, :protocol - + def request # Copied from ActionCable::Server::Socket#request @request ||= begin @@ -163,13 +166,13 @@ def request ActionDispatch::Request.new(environment || env) end end - + def transmit(data) client&.write(coder.encode(data)) end - + def close = client&.close - + def perform_work(receiver, method_name, *args) worker_pool.async_invoke(receiver, method_name, *args, connection: self) end @@ -185,14 +188,14 @@ def self.run! app = Rack::Builder.new do map "/cable" do use ActionCable::Iodine::Middleware - run(proc { |_| [404, {"Content-Type" => "text/plain"}, ["Not here"]] }) + run(proc{|_| [404, {"Content-Type" => "text/plain"}, ["Not here"]]}) end end - + Iodine::DEFAULT_SETTINGS[:port] = 8080 Iodine.threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i Iodine.workers = ENV.fetch("WEB_CONCURRENCY", 4).to_i - + Iodine.listen service: :http, handler: app Iodine.start end diff --git a/benchmark/servers/puma.rb b/benchmark/servers/puma.rb index bd1a14c..3e74709 100755 --- a/benchmark/servers/puma.rb +++ b/benchmark/servers/puma.rb @@ -1,6 +1,9 @@ #!/usr/bin/env ruby # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require_relative "../application" class BenchmarkServer diff --git a/example/app/controllers/application_controller.rb b/example/app/controllers/application_controller.rb index 0d95db2..cbcf0d2 100644 --- a/example/app/controllers/application_controller.rb +++ b/example/app/controllers/application_controller.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + class ApplicationController < ActionController::Base # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/example/app/controllers/items_controller.rb b/example/app/controllers/items_controller.rb index 548b7e2..ba83ad1 100644 --- a/example/app/controllers/items_controller.rb +++ b/example/app/controllers/items_controller.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + class ItemsController < ApplicationController before_action :set_item, only: %i[ show edit update destroy ] diff --git a/example/app/helpers/application_helper.rb b/example/app/helpers/application_helper.rb index de6be79..c041c2d 100644 --- a/example/app/helpers/application_helper.rb +++ b/example/app/helpers/application_helper.rb @@ -1,2 +1,5 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + module ApplicationHelper end diff --git a/example/app/helpers/items_helper.rb b/example/app/helpers/items_helper.rb index cff0c9f..944303e 100644 --- a/example/app/helpers/items_helper.rb +++ b/example/app/helpers/items_helper.rb @@ -1,2 +1,5 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + module ItemsHelper end diff --git a/example/app/jobs/application_job.rb b/example/app/jobs/application_job.rb index d394c3d..90f02de 100644 --- a/example/app/jobs/application_job.rb +++ b/example/app/jobs/application_job.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + class ApplicationJob < ActiveJob::Base # Automatically retry jobs that encountered a deadlock # retry_on ActiveRecord::Deadlocked diff --git a/example/app/mailers/application_mailer.rb b/example/app/mailers/application_mailer.rb index 3c34c81..4c110ab 100644 --- a/example/app/mailers/application_mailer.rb +++ b/example/app/mailers/application_mailer.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + class ApplicationMailer < ActionMailer::Base default from: "from@example.com" layout "mailer" diff --git a/example/app/models/application_record.rb b/example/app/models/application_record.rb index b63caeb..9cfef52 100644 --- a/example/app/models/application_record.rb +++ b/example/app/models/application_record.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + class ApplicationRecord < ActiveRecord::Base primary_abstract_class end diff --git a/example/app/models/item.rb b/example/app/models/item.rb index d743a1b..c020c9f 100644 --- a/example/app/models/item.rb +++ b/example/app/models/item.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + class Item < ApplicationRecord after_create_commit -> { broadcast_append_to "items" } end diff --git a/example/config/application.rb b/example/config/application.rb index be86ba6..04560c1 100644 --- a/example/config/application.rb +++ b/example/config/application.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require_relative "boot" require "rails/all" diff --git a/example/config/boot.rb b/example/config/boot.rb index 988a5dd..bf1b950 100644 --- a/example/config/boot.rb +++ b/example/config/boot.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) require "bundler/setup" # Set up gems listed in the Gemfile. diff --git a/example/config/environment.rb b/example/config/environment.rb index cac5315..27b9e20 100644 --- a/example/config/environment.rb +++ b/example/config/environment.rb @@ -1,4 +1,6 @@ -# Load the Rails application. +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require_relative "application" # Initialize the Rails application. diff --git a/example/config/environments/development.rb b/example/config/environments/development.rb index 4cc21c4..6858519 100644 --- a/example/config/environments/development.rb +++ b/example/config/environments/development.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "active_support/core_ext/integer/time" Rails.application.configure do diff --git a/example/config/environments/production.rb b/example/config/environments/production.rb index 351afa4..468d426 100644 --- a/example/config/environments/production.rb +++ b/example/config/environments/production.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "active_support/core_ext/integer/time" Rails.application.configure do diff --git a/example/config/environments/test.rb b/example/config/environments/test.rb index c2095b1..077bcc7 100644 --- a/example/config/environments/test.rb +++ b/example/config/environments/test.rb @@ -1,7 +1,5 @@ -# The test environment is used exclusively to run your application's -# test suite. You never need to work with it otherwise. Remember that -# your test database is "scratch space" for the test suite and is wiped -# and recreated between test runs. Don't rely on the data there! +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb. diff --git a/example/config/importmap.rb b/example/config/importmap.rb index 909dfc5..589de7d 100644 --- a/example/config/importmap.rb +++ b/example/config/importmap.rb @@ -1,4 +1,5 @@ -# Pin npm packages by running ./bin/importmap +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" diff --git a/example/config/initializers/assets.rb b/example/config/initializers/assets.rb index 4873244..91c989e 100644 --- a/example/config/initializers/assets.rb +++ b/example/config/initializers/assets.rb @@ -1,4 +1,5 @@ -# Be sure to restart your server when you modify this file. +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. # Version of your assets, change this if you want to expire all your assets. Rails.application.config.assets.version = "1.0" diff --git a/example/config/initializers/content_security_policy.rb b/example/config/initializers/content_security_policy.rb index b3076b3..483efdb 100644 --- a/example/config/initializers/content_security_policy.rb +++ b/example/config/initializers/content_security_policy.rb @@ -1,4 +1,5 @@ -# Be sure to restart your server when you modify this file. +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. # Define an application-wide content security policy. # See the Securing Rails Applications Guide for more information: diff --git a/example/config/initializers/filter_parameter_logging.rb b/example/config/initializers/filter_parameter_logging.rb index c0b717f..3a34af4 100644 --- a/example/config/initializers/filter_parameter_logging.rb +++ b/example/config/initializers/filter_parameter_logging.rb @@ -1,4 +1,5 @@ -# Be sure to restart your server when you modify this file. +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. # Use this to limit dissemination of sensitive information. diff --git a/example/config/initializers/inflections.rb b/example/config/initializers/inflections.rb index 3860f65..f34705f 100644 --- a/example/config/initializers/inflections.rb +++ b/example/config/initializers/inflections.rb @@ -1,4 +1,5 @@ -# Be sure to restart your server when you modify this file. +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. # Add new inflection rules using the following format. Inflections # are locale specific, and you may define rules for as many different diff --git a/example/config/puma.rb b/example/config/puma.rb index a248513..6a24171 100644 --- a/example/config/puma.rb +++ b/example/config/puma.rb @@ -1,29 +1,6 @@ -# This configuration file will be evaluated by Puma. The top-level methods that -# are invoked here are part of Puma's configuration DSL. For more information -# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. -# -# Puma starts a configurable number of processes (workers) and each process -# serves each request in a thread from an internal thread pool. -# -# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You -# should only set this value when you want to run 2 or more workers. The -# default is already 1. -# -# The ideal number of threads per worker depends both on how much time the -# application spends waiting for IO operations and on how much you wish to -# prioritize throughput over latency. -# -# As a rule of thumb, increasing the number of threads will increase how much -# traffic a given process can handle (throughput), but due to CRuby's -# Global VM Lock (GVL) it has diminishing returns and will degrade the -# response time (latency) of the application. -# -# The default is set to 3 threads as it's deemed a decent compromise between -# throughput and latency for the average Rails application. -# -# Any libraries that use a connection pool or another resource pool should -# be configured to provide at least as many connections as the number of -# threads. This includes Active Record's `pool` parameter in `database.yml`. +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) threads threads_count, threads_count diff --git a/example/config/routes.rb b/example/config/routes.rb index 255b154..fa6d342 100644 --- a/example/config/routes.rb +++ b/example/config/routes.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html diff --git a/example/db/cable_schema.rb b/example/db/cable_schema.rb index eef9db1..4572529 100644 --- a/example/db/cable_schema.rb +++ b/example/db/cable_schema.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + ActiveRecord::Schema[7.1].define(version: 1) do create_table "solid_cable_messages", force: :cascade do |t| t.binary "channel", limit: 1024, null: false diff --git a/example/db/cache_schema.rb b/example/db/cache_schema.rb index 3ac1f3f..098b85b 100644 --- a/example/db/cache_schema.rb +++ b/example/db/cache_schema.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + ActiveRecord::Schema[7.2].define(version: 1) do create_table "solid_cache_entries", force: :cascade do |t| t.binary "key", limit: 1024, null: false diff --git a/example/db/migrate/20241115075615_create_items.rb b/example/db/migrate/20241115075615_create_items.rb index c84d7fb..52ae85e 100644 --- a/example/db/migrate/20241115075615_create_items.rb +++ b/example/db/migrate/20241115075615_create_items.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + class CreateItems < ActiveRecord::Migration[8.0] def change create_table :items do |t| diff --git a/example/db/queue_schema.rb b/example/db/queue_schema.rb index 85194b6..bf2830f 100644 --- a/example/db/queue_schema.rb +++ b/example/db/queue_schema.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + ActiveRecord::Schema[7.1].define(version: 1) do create_table "solid_queue_blocked_executions", force: :cascade do |t| t.bigint "job_id", null: false diff --git a/example/db/schema.rb b/example/db/schema.rb index e13a0ac..068e423 100644 --- a/example/db/schema.rb +++ b/example/db/schema.rb @@ -1,14 +1,5 @@ -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# This file is the source Rails uses to define your schema when running `bin/rails -# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to -# be faster and is potentially less error prone than running all of your -# migrations from scratch. Old migrations may fail to apply correctly if those -# migrations use external dependencies or application code. -# -# It's strongly recommended that you check this file into your version control system. +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. ActiveRecord::Schema[8.0].define(version: 2024_11_15_075615) do create_table "items", force: :cascade do |t| diff --git a/example/db/seeds.rb b/example/db/seeds.rb index 4fbd6ed..039a64b 100644 --- a/example/db/seeds.rb +++ b/example/db/seeds.rb @@ -1,9 +1,3 @@ -# This file should ensure the existence of records required to run the application in every environment (production, -# development, test). The code here should be idempotent so that it can be executed at any point in every environment. -# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + diff --git a/example/test/application_system_test_case.rb b/example/test/application_system_test_case.rb index cee29fd..c8603e8 100644 --- a/example/test/application_system_test_case.rb +++ b/example/test/application_system_test_case.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase diff --git a/example/test/controllers/items_controller_test.rb b/example/test/controllers/items_controller_test.rb index 243596a..cde6582 100644 --- a/example/test/controllers/items_controller_test.rb +++ b/example/test/controllers/items_controller_test.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "test_helper" class ItemsControllerTest < ActionDispatch::IntegrationTest diff --git a/example/test/models/item_test.rb b/example/test/models/item_test.rb index 4bd69ff..3b42d30 100644 --- a/example/test/models/item_test.rb +++ b/example/test/models/item_test.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "test_helper" class ItemTest < ActiveSupport::TestCase diff --git a/example/test/system/items_test.rb b/example/test/system/items_test.rb index 796b035..15f0e40 100644 --- a/example/test/system/items_test.rb +++ b/example/test/system/items_test.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "application_system_test_case" class ItemsTest < ApplicationSystemTestCase diff --git a/example/test/test_helper.rb b/example/test/test_helper.rb index 0c22470..2fa5d95 100644 --- a/example/test/test_helper.rb +++ b/example/test/test_helper.rb @@ -1,3 +1,6 @@ +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" diff --git a/fixtures/test_channel.rb b/fixtures/test_channel.rb index a73d52d..fab9b36 100644 --- a/fixtures/test_channel.rb +++ b/fixtures/test_channel.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2024, by Samuel Williams. +# Copyright, 2024-2026, by Samuel Williams. require "action_cable" diff --git a/gems.rb b/gems.rb index e7c5346..d9f8261 100644 --- a/gems.rb +++ b/gems.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2024, by Samuel Williams. +# Copyright, 2023-2026, by Samuel Williams. source "https://rubygems.org" @@ -16,23 +16,23 @@ gem "bake-modernize" gem "bake-releases" + gem "decode" + gem "utopia-project" end group :test do gem "sus" gem "covered" - gem "decode" gem "rubocop" + gem "rubocop-md" gem "rubocop-rails-omakase" gem "rubocop-socketry" gem "sus-fixtures-async-http" gem "sus-fixtures-console" - gem "async-websocket" - gem "bake-test" gem "bake-test-external" diff --git a/lib/async/cable/middleware.rb b/lib/async/cable/middleware.rb index 1b23f41..3ab1fdb 100644 --- a/lib/async/cable/middleware.rb +++ b/lib/async/cable/middleware.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2024, by Samuel Williams. +# Copyright, 2024-2026, by Samuel Williams. require "async/websocket/adapters/rack" require "action_cable" diff --git a/lib/async/cable/socket.rb b/lib/async/cable/socket.rb index e829b1c..509703e 100644 --- a/lib/async/cable/socket.rb +++ b/lib/async/cable/socket.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2024, by Samuel Williams. +# Copyright, 2024-2026, by Samuel Williams. module Async::Cable class Socket diff --git a/lib/async/cable/version.rb b/lib/async/cable/version.rb index 86c2b03..a73e01c 100644 --- a/lib/async/cable/version.rb +++ b/lib/async/cable/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2023-2024, by Samuel Williams. +# Copyright, 2023-2026, by Samuel Williams. module Async module Cable diff --git a/license.md b/license.md index 8ce5275..be2904e 100644 --- a/license.md +++ b/license.md @@ -1,6 +1,6 @@ # MIT License -Copyright, 2023-2024, by Samuel Williams. +Copyright, 2023-2026, by Samuel Williams. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/readme.md b/readme.md index cf2a39d..49ad304 100644 --- a/readme.md +++ b/readme.md @@ -36,6 +36,22 @@ We welcome contributions to this project. 4. Push to the branch (`git push origin my-new-feature`). 5. Create new Pull Request. +### Running Tests + +To run the test suite: + +``` shell +bundle exec sus +``` + +### Making Releases + +To make a new release: + +``` shell +bundle exec bake gem:release:patch # or minor or major +``` + ### Developer Certificate of Origin In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed. diff --git a/test/async/cable/middleware.rb b/test/async/cable/middleware.rb index 59a8f04..836b446 100644 --- a/test/async/cable/middleware.rb +++ b/test/async/cable/middleware.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2024, by Samuel Williams. +# Copyright, 2024-2026, by Samuel Williams. require "async/cable/middleware" From 18c411c452d2b0b2219262d58bce39b894ba1a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Junstr=C3=B6m?= Date: Thu, 21 May 2026 17:26:25 +0200 Subject: [PATCH 2/5] Make `Socket#transmit` and `#close` idempotent. `transmit` pushed to the output queue unconditionally, raising `ClosedQueueError` if the socket had already been closed. That bubbles out of `ActionCable::Server::Base#restart`, which calls `close` on every connection (which itself calls `transmit` to send the disconnect frame) and surfaces as a 500 on the request that triggered the reload. Guard both methods on `@output.closed?` (the same primitive they mutate) The `rescue ClosedQueueError` covers a concurrent close racing past the check. --- lib/async/cable/socket.rb | 5 ++++- test/async/cable/socket.rb | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 test/async/cable/socket.rb diff --git a/lib/async/cable/socket.rb b/lib/async/cable/socket.rb index 509703e..a8a4085 100644 --- a/lib/async/cable/socket.rb +++ b/lib/async/cable/socket.rb @@ -49,12 +49,15 @@ def run(parent: Async::Task.current) def transmit(data) # Console.info(self, "Transmitting data:", data, task: Async::Task.current?) + return if @output.closed? @output.push(@coder.encode(data)) + rescue ClosedQueueError + # Closed concurrently between the check and the push. end def close # Console.info(self, "Closing socket.", task: Async::Task.current?) - @output.close + @output.close unless @output.closed? end # This can be called from the work pool, off the event loop. diff --git a/test/async/cable/socket.rb b/test/async/cable/socket.rb new file mode 100644 index 0000000..4d25a3a --- /dev/null +++ b/test/async/cable/socket.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "async/cable/socket" +require "action_cable" + +describe Async::Cable::Socket do + let(:socket) {subject.new({}, nil, ::ActionCable::Server::Base.new)} + + it "transmit returns nil after close" do + socket.close + expect(socket.transmit({type: "ping"})).to be_nil + end + + it "close is idempotent" do + socket.close + socket.close + end +end From 57b3a9bd313a30242cb72452cf4f5454010c8ce3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 22 May 2026 12:43:27 +0900 Subject: [PATCH 3/5] 100% documentation coverage. --- lib/async/cable/middleware.rb | 11 +++ lib/async/cable/railtie.rb | 1 + lib/async/cable/socket.rb | 122 +++++++++++++++++++--------------- lib/async/cable/version.rb | 2 + 4 files changed, 83 insertions(+), 53 deletions(-) diff --git a/lib/async/cable/middleware.rb b/lib/async/cable/middleware.rb index 3ab1fdb..ccf187d 100644 --- a/lib/async/cable/middleware.rb +++ b/lib/async/cable/middleware.rb @@ -10,7 +10,12 @@ module Async module Cable + # Rack middleware that intercepts WebSocket upgrade requests and dispatches them to ActionCable, passing all other requests to the next app in the middleware stack. class Middleware + # Create a new middleware instance. + # @parameter app [#call] The next Rack application in the middleware stack. + # @parameter path [String] The URL path that the cable endpoint is mounted at. + # @parameter server [ActionCable::Server::Base] The ActionCable server to use. def initialize(app, path: "/cable", server: ActionCable.server) @app = app @path = path @@ -21,10 +26,16 @@ def initialize(app, path: "/cable", server: ActionCable.server) attr :server + # Check whether the request path matches the configured cable path. + # @parameter env [Hash] The Rack environment. + # @returns [Boolean] Whether the request is for the cable endpoint. def valid_path?(env) env["PATH_INFO"] == @path end + # Handle an incoming Rack request. WebSocket upgrade requests on the configured path are handed off to ActionCable; all other requests are forwarded to the next app in the middleware stack. + # @parameter env [Hash] The Rack environment. + # @returns [Array] A Rack response triple. def call(env) if valid_path?(env) and Async::WebSocket::Adapters::Rack.websocket?(env) and allow_request_origin?(env) Async::WebSocket::Adapters::Rack.open(env, protocols: @protocols) do |websocket| diff --git a/lib/async/cable/railtie.rb b/lib/async/cable/railtie.rb index 96f47b4..d6e0dba 100644 --- a/lib/async/cable/railtie.rb +++ b/lib/async/cable/railtie.rb @@ -7,6 +7,7 @@ module Async module Cable + # Rails integration that automatically inserts {Middleware} into the application middleware stack during initialization. class Railtie < Rails::Railtie initializer "async.cable.configure_rails_initialization" do |app| app.middleware.use Async::Cable::Middleware diff --git a/lib/async/cable/socket.rb b/lib/async/cable/socket.rb index 509703e..e859879 100644 --- a/lib/async/cable/socket.rb +++ b/lib/async/cable/socket.rb @@ -3,64 +3,80 @@ # Released under the MIT License. # Copyright, 2024-2026, by Samuel Williams. -module Async::Cable - class Socket - def initialize(env, websocket, server, coder: ActiveSupport::JSON) - @env = env - @websocket = websocket - @server = server - @coder = coder - - @output = ::Thread::Queue.new - end - - attr :env - - def logger - @server.logger - end - - def request - # Copied from ActionCable::Server::Socket#request - @request ||= begin - if defined?(Rails.application) && Rails.application - environment = Rails.application.env_config.merge(@env) - end +module Async + module Cable + # Wraps a WebSocket connection to provide the interface expected by ActionCable connections. Buffers outbound messages in a queue and drains them asynchronously so that transmission never blocks the event loop. + class Socket + # Create a new socket wrapper. + # @parameter env [Hash] The Rack environment for the originating request. + # @parameter websocket [Async::WebSocket::Connection] The underlying WebSocket connection. + # @parameter server [ActionCable::Server::Base] The ActionCable server instance. + # @parameter coder [#encode, #decode] Coder used to serialise messages (defaults to `ActiveSupport::JSON`). + def initialize(env, websocket, server, coder: ActiveSupport::JSON) + @env = env + @websocket = websocket + @server = server + @coder = coder - ActionDispatch::Request.new(environment || @env) + @output = ::Thread::Queue.new end - end - - def run(parent: Async::Task.current) - parent.async do - while buffer = @output.pop - # Console.debug(self, "Sending cable data:", buffer, flush: @output.empty?) - @websocket.send_text(buffer) - @websocket.flush if @output.empty? + + attr :env + + # The ActionCable server logger. + # @returns [Logger] + def logger + @server.logger + end + + # Build an `ActionDispatch::Request` from the Rack environment, merging Rails application config when available. + # @returns [ActionDispatch::Request] + def request + @request ||= begin + if defined?(Rails.application) && Rails.application + environment = Rails.application.env_config.merge(@env) + end + + ActionDispatch::Request.new(environment || @env) end - rescue => error - Console.error(self, "Error while sending cable data:", error) - ensure - unless @websocket.closed? - @websocket.close_write(error) + end + + # Start an async task that drains the outbound message queue and writes each message to the WebSocket. The task stops when the queue is closed. + # @parameter parent [Async::Task] The parent task to spawn under. + # @returns [Async::Task] + def run(parent: Async::Task.current) + parent.async do + while buffer = @output.pop + # Console.debug(self, "Sending cable data:", buffer, flush: @output.empty?) + @websocket.send_text(buffer) + @websocket.flush if @output.empty? + end + rescue => error + Console.error(self, "Error while sending cable data:", error) + ensure + unless @websocket.closed? + @websocket.close_write(error) + end end end - end - - def transmit(data) - # Console.info(self, "Transmitting data:", data, task: Async::Task.current?) - @output.push(@coder.encode(data)) - end - - def close - # Console.info(self, "Closing socket.", task: Async::Task.current?) - @output.close - end - - # This can be called from the work pool, off the event loop. - def perform_work(receiver, ...) - # Console.info(self, "Performing work:", receiver) - receiver.send(...) + + # Encode and enqueue a message for asynchronous delivery to the client. + # @parameter data [Object] The data to transmit, which will be encoded by the coder. + def transmit(data) + # Console.info(self, "Transmitting data:", data, task: Async::Task.current?) + @output.push(@coder.encode(data)) + end + + # Close the outbound queue, causing the drain task to terminate once all pending messages have been sent. + def close + @output.close + end + + # This can be called from the work pool, off the event loop. + def perform_work(receiver, ...) + # Console.info(self, "Performing work:", receiver) + receiver.send(...) + end end end end diff --git a/lib/async/cable/version.rb b/lib/async/cable/version.rb index a73e01c..5d0d3aa 100644 --- a/lib/async/cable/version.rb +++ b/lib/async/cable/version.rb @@ -3,7 +3,9 @@ # Released under the MIT License. # Copyright, 2023-2026, by Samuel Williams. +# @namespace module Async + # @namespace module Cable VERSION = "0.3.0" end From 80d00856281d10241189c6f7ce1415c89cd67f8a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 22 May 2026 12:48:49 +0900 Subject: [PATCH 4/5] Indent. --- lib/async/cable/socket.rb | 114 +++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 56 deletions(-) diff --git a/lib/async/cable/socket.rb b/lib/async/cable/socket.rb index a8a4085..f92fcfc 100644 --- a/lib/async/cable/socket.rb +++ b/lib/async/cable/socket.rb @@ -3,67 +3,69 @@ # Released under the MIT License. # Copyright, 2024-2026, by Samuel Williams. -module Async::Cable - class Socket - def initialize(env, websocket, server, coder: ActiveSupport::JSON) - @env = env - @websocket = websocket - @server = server - @coder = coder - - @output = ::Thread::Queue.new - end - - attr :env - - def logger - @server.logger - end - - def request - # Copied from ActionCable::Server::Socket#request - @request ||= begin - if defined?(Rails.application) && Rails.application - environment = Rails.application.env_config.merge(@env) - end +module Async + module Cable + class Socket + def initialize(env, websocket, server, coder: ActiveSupport::JSON) + @env = env + @websocket = websocket + @server = server + @coder = coder - ActionDispatch::Request.new(environment || @env) + @output = ::Thread::Queue.new end - end - - def run(parent: Async::Task.current) - parent.async do - while buffer = @output.pop - # Console.debug(self, "Sending cable data:", buffer, flush: @output.empty?) - @websocket.send_text(buffer) - @websocket.flush if @output.empty? + + attr :env + + def logger + @server.logger + end + + def request + # Copied from ActionCable::Server::Socket#request + @request ||= begin + if defined?(Rails.application) && Rails.application + environment = Rails.application.env_config.merge(@env) + end + + ActionDispatch::Request.new(environment || @env) end - rescue => error - Console.error(self, "Error while sending cable data:", error) - ensure - unless @websocket.closed? - @websocket.close_write(error) + end + + def run(parent: Async::Task.current) + parent.async do + while buffer = @output.pop + # Console.debug(self, "Sending cable data:", buffer, flush: @output.empty?) + @websocket.send_text(buffer) + @websocket.flush if @output.empty? + end + rescue => error + Console.error(self, "Error while sending cable data:", error) + ensure + unless @websocket.closed? + @websocket.close_write(error) + end end end - end - - def transmit(data) - # Console.info(self, "Transmitting data:", data, task: Async::Task.current?) - return if @output.closed? - @output.push(@coder.encode(data)) - rescue ClosedQueueError - # Closed concurrently between the check and the push. - end - - def close - # Console.info(self, "Closing socket.", task: Async::Task.current?) - @output.close unless @output.closed? - end - - # This can be called from the work pool, off the event loop. - def perform_work(receiver, ...) - # Console.info(self, "Performing work:", receiver) - receiver.send(...) + + def transmit(data) + # Console.info(self, "Transmitting data:", data, task: Async::Task.current?) + return if @output.closed? + @output.push(@coder.encode(data)) + rescue ClosedQueueError + # Closed concurrently between the check and the push. + end + + def close + # Console.info(self, "Closing socket.", task: Async::Task.current?) + @output.close unless @output.closed? + end + + # This can be called from the work pool, off the event loop. + def perform_work(receiver, ...) + # Console.info(self, "Performing work:", receiver) + receiver.send(...) + end end end end From b7b26fa512bfa4144c9460d797914fa9cb36fd53 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 22 May 2026 12:57:05 +0900 Subject: [PATCH 5/5] RuboCop. --- lib/async/cable/socket.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/async/cable/socket.rb b/lib/async/cable/socket.rb index 8aa529b..765e748 100644 --- a/lib/async/cable/socket.rb +++ b/lib/async/cable/socket.rb @@ -29,7 +29,7 @@ def logger @server.logger end - # Build an `ActionDispatch::Request` from the Rack environment, merging Rails application config when available. + # Build an `ActionDispatch::Request` from the Rack environment, merging Rails application config when available. # @returns [ActionDispatch::Request] def request # Copied from `ActionCable::Server::Socket#request`: @@ -61,7 +61,7 @@ def run(parent: Async::Task.current) end end - # Encode and enqueue a message for asynchronous delivery to the client. + # Encode and enqueue a message for asynchronous delivery to the client. # @parameter data [Object] The data to transmit, which will be encoded by the coder. def transmit(data) # Console.info(self, "Transmitting data:", data, task: Async::Task.current?) @@ -71,7 +71,7 @@ def transmit(data) # Closed concurrently between the check and the push. end - # Close the outbound queue, causing the drain task to terminate once all pending messages have been sent. + # Close the outbound queue, causing the drain task to terminate once all pending messages have been sent. def close # Console.info(self, "Closing socket.", task: Async::Task.current?) @output.close unless @output.closed?