From 51c96214e097c44bc4db710f50219136a9152013 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 29 May 2026 16:01:03 +0900 Subject: [PATCH 1/2] Target Rails main; drop actioncable-next dependency. The ActionCable::Server::Socket abstraction (rails/rails#50979) is now available in Rails main (8.1+), so the actioncable-next shim is no longer required. This branch tracks Rails main. The main branch continues to support stable Rails via actioncable-next. --- async-cable.gemspec | 4 +++- gems.rb | 6 ++++-- readme.md | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/async-cable.gemspec b/async-cable.gemspec index da95f60..78a3209 100644 --- a/async-cable.gemspec +++ b/async-cable.gemspec @@ -24,7 +24,9 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" - spec.add_dependency "actioncable-next" + # Requires the `ActionCable::Server::Socket` abstraction introduced by + # https://github.com/rails/rails/pull/50979 (Rails 8.1+). + spec.add_dependency "actioncable", ">= 8.1.0.alpha" spec.add_dependency "async", "~> 2.9" spec.add_dependency "async-websocket" end diff --git a/gems.rb b/gems.rb index 3585cc0..06f869e 100644 --- a/gems.rb +++ b/gems.rb @@ -7,8 +7,10 @@ gemspec -# Use the fix branch until https://github.com/anycable/actioncable-next/pull/17 is merged and released. -gem "actioncable-next", github: "ioquatix/actioncable-next", branch: "fix/close-ensure-socket" +# The `next` branch targets Rails main, which now includes the +# `ActionCable::Server::Socket` abstraction (rails/rails#50979) that +# previously required actioncable-next. +gem "rails", github: "rails/rails", branch: "main" gem "async" diff --git a/readme.md b/readme.md index 49ad304..a5c8884 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,8 @@ # Async::Cable -This is a proof-of-concept adapter for Rails 7.2+. It depends on `actioncable-next` which completely replaces the internal implementation of Action Cable with a pluggable one. +This is a proof-of-concept adapter for Action Cable. + +The `next` branch tracks Rails `main` and relies on the `ActionCable::Server::Socket` abstraction introduced by [rails/rails#50979](https://github.com/rails/rails/pull/50979) (Rails 8.1+). For stable Rails (≤ 8.0), use the `main` branch, which depends on [`actioncable-next`](https://github.com/anycable/actioncable-next). [![Development Status](https://github.com/socketry/async-cable/workflows/Test/badge.svg)](https://github.com/socketry/async-cable/actions?workflow=Test) From fb6ae03a615aecc44db44a7b6eb7087709cae2d3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 29 May 2026 17:34:14 +0900 Subject: [PATCH 2/2] Test: cover remaining branches in Socket and Middleware. Adds tests for: - Socket#request (both with and without Rails.application). - Socket#run rescue branch (websocket raising during send). - Middleware fall-through to inner app for non-websocket requests. - Middleware#allow_request_origin? same-host, allowed, and denied paths. - Middleware#handle_incoming_websocket abnormal-failure rescue. Brings line coverage from 81.58% to 100.0%. --- test/async/cable/middleware.rb | 75 ++++++++++++++++++++++++++++++++++ test/async/cable/socket.rb | 62 +++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/test/async/cable/middleware.rb b/test/async/cable/middleware.rb index 87f491e..54d6a9d 100644 --- a/test/async/cable/middleware.rb +++ b/test/async/cable/middleware.rb @@ -72,6 +72,81 @@ def url end end + with "non-websocket requests" do + let(:inner_app) do + lambda do |env| + [200, {"content-type" => "text/plain"}, ["hello"]] + end + end + + let(:app) do + Protocol::Rack::Adapter.new(subject.new(inner_app, server: cable_server)) + end + + it "forwards non-websocket requests on the cable path to the inner app" do + response = client.get("/cable") + expect(response.read).to be == "hello" + ensure + response&.close + end + + it "forwards requests on other paths to the inner app" do + response = client.get("/other") + expect(response.read).to be == "hello" + ensure + response&.close + end + end + + with "#allow_request_origin?" do + let(:middleware) {subject.new(nil, server: cable_server)} + + before do + cable_server.config.disable_request_forgery_protection = false + end + + it "allows requests matching the configured origin host" do + cable_server.config.allow_same_origin_as_host = true + env = {"HTTP_ORIGIN" => "http://example.com", "HTTP_HOST" => "example.com", "rack.url_scheme" => "http"} + expect(middleware.__send__(:allow_request_origin?, env)).to be == true + end + + it "allows requests from explicitly allowed origins" do + cable_server.config.allowed_request_origins = ["http://allowed.example"] + env = {"HTTP_ORIGIN" => "http://allowed.example", "HTTP_HOST" => "example.com", "rack.url_scheme" => "http"} + expect(middleware.__send__(:allow_request_origin?, env)).to be == true + end + + it "rejects requests from disallowed origins" do + cable_server.config.allowed_request_origins = ["http://allowed.example"] + env = {"HTTP_ORIGIN" => "http://evil.example", "HTTP_HOST" => "example.com", "rack.url_scheme" => "http"} + expect(middleware.__send__(:allow_request_origin?, env)).to be == false + end + end + + with "#handle_incoming_websocket" do + include Sus::Fixtures::Async::ReactorContext + + let(:middleware) {subject.new(nil, server: cable_server)} + + # Minimal websocket double whose `read` raises an unexpected error, + # exercising the abnormal-failure rescue branch. + let(:failing_websocket) do + Class.new do + def read; raise "unexpected"; end + def send_text(_); end + def flush; end + def closed?; true; end + def close_write(_ = nil); end + end.new + end + + it "logs unexpected errors and cleans up the connection" do + env = {"PATH_INFO" => "/cable", "HTTP_HOST" => "example.com", "rack.url_scheme" => "http"} + middleware.__send__(:handle_incoming_websocket, env, failing_websocket) + end + end + it "can connect and receive welcome messages" do welcome_message = connection.read.parse diff --git a/test/async/cable/socket.rb b/test/async/cable/socket.rb index 19bf180..b896264 100644 --- a/test/async/cable/socket.rb +++ b/test/async/cable/socket.rb @@ -1,10 +1,17 @@ # frozen_string_literal: true +# Released under the MIT License. +# Copyright, 2024-2026, by Samuel Williams. + require "async/cable/socket" require "action_cable" +require "action_dispatch" +require "action_dispatch/http/request" +require "sus/fixtures/async" describe Async::Cable::Socket do - let(:socket) {subject.new({}, nil, ::ActionCable::Server::Base.new)} + let(:server) {::ActionCable::Server::Base.new} + let(:socket) {subject.new({}, nil, server)} it "cannot transmit after close" do socket.close @@ -18,4 +25,57 @@ socket.close socket.close end + + with "#request" do + it "builds an ActionDispatch::Request from the Rack environment" do + request = socket.request + expect(request).to be_a(ActionDispatch::Request) + end + + it "memoizes the request" do + expect(socket.request).to be_equal(socket.request) + end + + it "merges Rails.application env_config when available" do + fake_application = Object.new + def fake_application.env_config; {"rails.test" => true}; end + + rails_was_defined = defined?(Rails) + Object.const_set(:Rails, Module.new) unless rails_was_defined + previous = Rails.respond_to?(:application) ? Rails.application : nil + Rails.define_singleton_method(:application){fake_application} + + request = socket.request + expect(request.env["rails.test"]).to be == true + ensure + if rails_was_defined + Rails.define_singleton_method(:application){previous} + else + Object.send(:remove_const, :Rails) + end + end + end + + with "#run" do + include Sus::Fixtures::Async::ReactorContext + + # Minimal websocket double that raises on send_text, exercising the rescue + # branch in Socket#run. + let(:failing_websocket) do + Class.new do + def send_text(_buffer); raise "boom"; end + def flush; end + def closed?; true; end + end.new + end + + let(:socket) {subject.new({}, failing_websocket, server)} + + it "logs errors raised while draining the output queue" do + task = socket.run + socket.transmit({type: "ping"}) + socket.close + task.wait + end + end end