Busy, Stalled, Offline

A rookery that sends birds at three speeds. The button you pressed spins while its bird is out, and a note appears when the server takes too long. Each indicator is CSS reading an attribute the client sets, so the channel keeps no state for them.

The rookery

Send a

Out of the rookery

  1. Nothing has left the rookery today.
  2.  
  3.  
  4.  
  5.  

Birds you have sent since the rookery opened: 0

# frozen_string_literal: true

# One visitor's rookery. Nothing here knows about a spinner or a note: the
# client marks the page while an action is on its way, and the page's CSS
# does the rest. The channel's part is to be slow on purpose, and to hang
# up when asked.
class Recipes::Indicators::IndicatorsChannel < ApplicationCable::Channel
  include Hibiki::Rails::Channel

  Delivery = Recipes::Indicators::Delivery
  VIEWS = "app/recipes/indicators"

  # What a flight's seconds are multiplied by. The tests set it to 0.
  class_attribute :pace, default: 1

  def build_graph
    @version = Hibiki::State.new(0)
    # Lives in this graph only, so it starts again when the socket does.
    @sent = Hibiki::State.new(0)

    log = Hibiki::Derived.new { @version.value; Delivery.recent }

    Hibiki::Effect.new do
      broadcast_morph target: "indicators_log", partial: "#{VIEWS}/log", locals: { entries: log.value }
    end

    transmit_value(:indicators_sent) { @sent.value }
  end

  # The wait stands in for slow work. It holds this tab's graph thread, so
  # a second bird waits its turn behind the first.
  def send_bird(data)
    bird = Delivery::BIRDS[data["bird"]] or return

    sleep bird.flight * pace
    Delivery.deliver(data["bird"])
    @sent.value += 1
    @version.value += 1
  end

  # The same work as Recipes::Indicators::RunnersController#create, which
  # the browser calls when this island is not ready.
  def send_runner
    Delivery.deliver("runner")
    @version.value += 1
  end

  # Drops this tab's socket. Action Cable's client opens a new one a few
  # seconds later, and `build_graph` runs again from nothing.
  def close_rookery = connection.close(reason: "The rookery is shut", reconnect: true)

  private

  def subscribed
    super
    return if subscription_rejected?

    # A delivery by anyone. The block runs on a cable thread, so it hops
    # onto the graph thread before touching a signal.
    stream_from Delivery::CHANGED do
      graph_actor&.post { @version.value += 1 }
    end
  end
end

The panel above is a rookery. Each bird on the top row takes a different time to send, and the page shows what it is doing while you wait. Press "Close the rookery" and the server hangs up. The badge turns to offline, and a few seconds later the connection is back by itself.

While the rookery is shut, press "Send a runner": the form still works, since it's now automatically downgraded to an ordinary remote form post. We wrote no JavaScript for any of this. The spinners, the badges and the notes are in the page from the start, and CSS decides which of them show. The log is shared by every visitor and emptied each night.

How it fits together

An island is the part of the page Hibiki looks after, here the whole panel. It belongs to a channel: the server side of an Action Cable connection, an object that stays alive while the page is open, one for each tab. The channel's public methods are its actions, the methods the page may call. Here send_bird sleeps for the bird's flight time and then writes a row to the log, and close_rookery hangs up the connection. The channel holds nothing about a spinner or a note.

The indicators come from the browser side. While an action is on its way, the client sets data-hibiki-busy on the island and on the control that sent it. The island also carries data-hibiki-state, which is connecting, ready, stalled or offline. Each indicator is hidden until a Tailwind variant that reads one of those attributes shows it. This is what happens when you press Pigeon:

  1. The button calls send_bird. The client marks the button and the island busy, and 150 ms later their spinners show.
  2. The channel sleeps for two seconds, writes the pigeon to the log and adds one to this tab's count.
  3. The log is rendered again and morphed into the page, and the server acknowledges the action. The marks come off, and the spinners go with them.

Without Tailwind, the same indicators are this much CSS:

.spinner, .stalled-note, .offline-note { display: none }

/* The island has both attributes; a control has only the first. */
[data-hibiki-state][data-hibiki-busy] .island-spinner         { display: inline-block }
button[data-hibiki-busy] .spinner                             { display: inline-block }
button[data-hibiki-busy]                                      { opacity: .6; cursor: progress }

[data-hibiki-state="stalled"] .stalled-note                   { display: block }
[data-hibiki-state="offline"] .offline-note                   { display: block }
[data-hibiki-state="stalled"] .log,
[data-hibiki-state="offline"] .log                            { opacity: .5 }

Busy ends when the server answers

The server acknowledges every action once it has run, whether or not it rendered anything, and the busy marks come off on that acknowledgement. An action that changes nothing sends no HTML, and one that raises sends none either; both still end the spinner. So a spinner that stops tells you the action ran, and the log tells you whether anything changed.

The 150 ms before a spinner shows is why the sparrow never spins. Press two birds one after the other and each button keeps its own spinner until its own answer comes, while the title's spinner stays until the last one. The second bird waits for the first, because a tab's actions run one at a time.

Stalled: ten seconds with no answer

After ten seconds with no answer, the client stops waiting. It takes the busy marks off and sets the state to stalled. It cannot tell whether the action is still running, as the albatross is, or lost, so the note says only that it has heard nothing. Nothing is cancelled or sent again, and the first answer to arrive, however late, puts the state back to ready. In between, the log shows what the page last heard, so it is dimmed and left in place.

Offline starts the channel again

When the socket drops, the state is offline and Action Cable's client keeps trying to connect. A new connection starts the channel again from nothing, which you can see in the count under the log: it lives in this tab's channel, and it is back at 0 when the rookery opens again. The log comes back whole, because it is in the database. A bird sent while the rookery is shut is dropped: the channel that would receive it does not exist yet, and the next one starts from nothing.

The first connection is treated differently. A click while the badge still says connecting is held and sent when the socket opens, because the page it was made on is the page the channel starts from.

A control that works without the socket

Some controls a visitor must not lose, such as a save. The runner is one: a form with an address of its own, marked fallback: true. When the island is ready, the client sends the action over the socket as usual. In any other state it stands aside and the browser posts the form. The controller does the same work and redirects back, and the log shows which way each runner went.

<%# The view: a form the browser can post by itself. %>
<%= form_with url: runner_path, **on(:send_runner, event: :submit, fallback: true) do %>
  <button>Send a runner</button>
<% end %>

<%# The channel: what a ready island calls.
      def send_runner
        Delivery.deliver("runner")
        @version.value += 1
      end

    The controller: what the browser calls in any other state.
      def create
        Delivery.deliver("runner", route: "request")
        redirect_to app_recipe_path("indicators"), status: :see_other
      end %>

The cost is that the work has two entrances, a channel action and a controller action, and both have to be kept. It is worth paying for the few controls that must survive a lost socket.

The timings

The 150 ms and the ten seconds are properties of the controller class, shared by every island on the site. To change them, subclass it where it is registered:

// app/javascript/controllers/hibiki_controller.js
import { HibikiController } from "hibiki-rails"

// Milliseconds. The defaults are 150 and 10000.
export default class extends HibikiController {
  static busyDelay = 300
  static busyCeiling = 20000
}

The same feature in stock Rails

Turbo gives more here than it is often credited with. A form being submitted has aria-busy and its button is disabled, and a turbo-cable-stream-source has a connected attribute while its subscription is up. So the busy marks and a socket-down note can be CSS in stock Rails too, as long as every control is a form. What is left for JavaScript is the overdue request, and telling "not connected yet" from "connection lost". Here is a sketch. It is not run here.

<%# The log arrives on this stream. Its `connected` attribute is the only
    sign of the socket that Turbo puts in the page. %>
<%= turbo_stream_from :deliveries, data: { connection_target: "source" } %>

<section class="rookery" data-controller="stalled connection">
  <span class="rookery-spinner"></span>

  <%# Every control is a form, so Turbo marks it with aria-busy and
      disables its button while the request is out. %>
  <% Delivery::BIRDS.each do |key, bird| %>
    <%= button_to birds_path(bird: key), class: "bird" do %>
      <%= bird.name %> <span class="spinner"></span>
    <% end %>
  <% end %>

  <p class="stalled-note" data-stalled-target="note" hidden>No word yet.</p>
  <p class="offline-note" data-connection-target="note" hidden>The rookery is shut.</p>

  <%= render "deliveries/log", entries: Delivery.recent %>
</section>

The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
Mark the control that was pressed CSS on form[aria-busy] when the control is a form; a Stimulus method for a select, a checkbox or a typed field CSS on data-hibiki-busy, for any control
Mark the region CSS with :has(form[aria-busy]) The same attribute, on the island
No flash on a quick answer An animation delay you write 150 ms, built in
The server is overdue A Stimulus controller with a timer on turbo:submit-start and turbo:submit-end CSS on data-hibiki-state="stalled"
The socket is down CSS on the stream source without connected, which is also true before the first connection; a Stimulus controller to tell the two apart CSS on data-hibiki-state="offline"; connecting is a state of its own
A click while the socket is down Works. A form post does not use the socket Dropped, unless the control has fallback: true and a controller action behind it
A slow action holds A web server thread That tab's own thread

Stock Rails sends each click as its own request, so a dead socket stops the live updates and leaves the clicks working. Here the socket carries the clicks too, so the page has to say when it is down. That is why these states are built in, and why a control that must survive a lost socket needs a fallback mechanism.