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
No word yet. The bird is still out, and the log will catch up when it lands.
The rookery is shut. No bird will fly until it opens again, in a few seconds.
Out of the rookery
- Nothing has left the rookery today.
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
# frozen_string_literal: true
# A note that left the rookery in the Busy, Stalled, Offline recipe: which
# carrier took it, and whether the order to send it came over the socket or
# as a plain request.
#
# The rows are whatever visitors sent, and the newest few are the rookery's
# log, shown to everyone. Recipes::Reset empties the table each night, and
# `deliver` caps it in between. A row holds nothing a visitor typed.
class Recipes::Indicators::Delivery < ApplicationRecord
self.table_name = "recipes_indicators_deliveries"
# A carrier, and how many seconds the server takes to send one.
Carrier = Data.define(:name, :flight)
# A line of the log: a plain copy of a row, compared by what it says.
Entry = Data.define(:id, :carrier, :route)
BIRDS = {
"sparrow" => Carrier.new(name: "Sparrow", flight: 0),
"pigeon" => Carrier.new(name: "Pigeon", flight: 2),
"albatross" => Carrier.new(name: "Albatross", flight: 13)
}.freeze
# The runner goes on foot, and is the one carrier a plain request can send.
CARRIERS = BIRDS.merge("runner" => Carrier.new(name: "Runner", flight: 0)).freeze
ROUTES = %w[socket request].freeze
# How many rows `deliver` leaves, and how many of them the log shows.
KEEP = 200
LOG = 5
# Every open rookery listens here for a delivery sent by someone else.
CHANGED = "recipes:indicators:deliveries"
after_commit { self.class.announce }
validates :bird, inclusion: { in: CARRIERS.keys }
validates :route, inclusion: { in: ROUTES }
# The newest deliveries, the latest last, as a log is written.
def self.recent = order(id: :desc).limit(LOG).map(&:entry).reverse
# One transaction, so the new row and the rows it pushes out are one ping.
def self.deliver(bird, route: "socket")
transaction do
create!(bird:, route:)
prune!
end
end
# A ping with nothing in it. Whoever hears it reads the log again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# `delete_all` runs no callback, so the ping is sent by hand.
def self.reset!
delete_all
announce
end
# Drops all but the newest rows.
def self.prune! = where.not(id: order(id: :desc).limit(KEEP).select(:id)).delete_all
def entry = Entry.new(id:, carrier: CARRIERS.fetch(bird).name, route:)
end
# frozen_string_literal: true
# Where the rookery's "Send a runner" form posts when its island is not
# ready: the socket is down, or the server has gone quiet. It does what the
# channel's `send_runner` does and sends the visitor back to the page.
class Recipes::Indicators::RunnersController < ApplicationController
rate_limit to: 20, within: 1.minute, only: :create
def create
Recipes::Indicators::Delivery.deliver("runner", route: "request")
redirect_to app_recipe_path("indicators"), status: :see_other
end
end
<%# Nothing in this file is switched on by the channel. The client sets
data-hibiki-busy and data-hibiki-state, and the classes below read them. %>
<% delivery = Recipes::Indicators::Delivery %>
<%= island Recipes::Indicators::IndicatorsChannel, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex items-center gap-2">
<h2 class="text-xl font-bold">The rookery</h2>
<%# The island's own mark: any action of this island, whichever control sent it. %>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
<%# One badge for each state. The island's attribute picks the one to show. %>
<p id="indicators_state" class="ml-auto text-sm">
<span class="badge badge-sm hidden in-data-[hibiki-state=connecting]:inline-flex">connecting</span>
<span class="badge badge-sm badge-success hidden in-data-[hibiki-state=ready]:inline-flex">ready</span>
<span class="badge badge-sm badge-warning hidden in-data-[hibiki-state=stalled]:inline-flex">stalled</span>
<span class="badge badge-sm badge-error hidden in-data-[hibiki-state=offline]:inline-flex">offline</span>
</p>
</header>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold">Send a</span>
<% delivery::BIRDS.each do |key, bird| %>
<%# The button's own mark. The client sets data-hibiki-busy on the
control that sent the action, so the group is the button. %>
<%= tag.button id: "indicators_send_#{key}", type: "button",
class: "group/bird btn btn-sm data-hibiki-busy:cursor-progress data-hibiki-busy:opacity-60",
**on(:send_bird, with: { bird: key }) do %>
<%= bird.name %>
<span class="relative text-xs font-normal">
<span class="opacity-70 group-data-hibiki-busy/bird:invisible"><%= bird.flight %> s</span>
<span class="loading loading-spinner loading-xs absolute inset-0 m-auto hidden group-data-hibiki-busy/bird:inline-block" aria-hidden="true"></span>
</span>
<% end %>
<% end %>
</div>
<div role="status" class="mt-2 min-h-6 text-sm font-semibold">
<p id="indicators_stalled" class="hidden text-warning in-data-[hibiki-state=stalled]:block">
No word yet. The bird is still out, and the log will catch up when it lands.
</p>
<p id="indicators_offline" class="hidden text-error in-data-[hibiki-state=offline]:block">
The rookery is shut. No bird will fly until it opens again, in a few seconds.
</p>
</div>
<%# What is on the page while the server is quiet is old, not missing, so
it is dimmed and left where it is. %>
<div class="transition-opacity in-data-[hibiki-state=offline]:opacity-50 in-data-[hibiki-state=stalled]:opacity-50">
<%= render "app/recipes/indicators/log", entries: delivery.recent %>
<p class="mt-2 text-right text-sm opacity-70">
Birds you have sent since the rookery opened: <b id="indicators_sent"><%= reactive :indicators_sent, 0 %></b>
</p>
</div>
<footer class="mt-3 flex flex-wrap items-center gap-2 border-t-2 border-base-300 pt-3">
<%= tag.button "Close the rookery", id: "indicators_close", type: "button", class: "btn btn-sm", **on(:close_rookery) %>
<%# A real form with a real address. A ready island sends the action
over the socket; in any other state the browser posts the form. %>
<%= form_with url: recipes_indicators_runner_path, id: "indicators_runner", class: "ml-auto",
**on(:send_runner, event: :submit, fallback: true) do %>
<button class="btn btn-primary btn-sm">Send a runner</button>
<% end %>
</footer>
<% end %>
<%# locals: (entries:) -%>
<%# The rookery's log: the last few deliveries from every visitor, the
latest at the bottom. Morphed by the channel whenever one lands. %>
<div id="indicators_log" class="rounded-box border-2 border-base-300 bg-base-200 px-4 pt-2 pb-3">
<p id="indicators_log_title" class="border-b-2 border-base-content/40 pb-1 text-center font-serif text-xs font-bold tracking-[0.25em] uppercase opacity-70">
Out of the rookery
</p>
<ol id="indicators_entries" class="font-serif text-sm italic">
<% entries.each do |entry| %>
<li id="indicators_entry_<%= entry.id %>" class="grid grid-cols-[3.5rem_1fr_auto] gap-x-3 border-b border-dashed border-base-content/30 py-1">
<span class="tabular-nums opacity-70">No. <%= entry.id %></span>
<span><%= entry.carrier %></span>
<span class="text-right opacity-80"><%= entry.route == "request" ? "on foot, by a plain request" : "over the socket" %></span>
</li>
<% end %>
<% (Recipes::Indicators::Delivery::LOG - entries.size).times do |line| %>
<li id="indicators_blank_<%= line %>" class="border-b border-dashed border-base-content/30 py-1 opacity-50">
<%= line.zero? && entries.empty? ? "Nothing has left the rookery today." : sanitize(" ") %>
</li>
<% end %>
</ol>
</div>
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:
-
The button calls
send_bird. The client marks the button and the island busy, and 150 ms later their spinners show. - The channel sleeps for two seconds, writes the pigeon to the log and adds one to this tab's count.
- 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>
.spinner, .rookery-spinner { display: none }
/* The delay keeps a quick answer from flashing. */
form[aria-busy] .spinner,
.rookery:has(form[aria-busy]) .rookery-spinner {
display: inline-block;
animation: appear 0s 150ms both;
}
@keyframes appear { from { visibility: hidden } to { visibility: visible } }
.rookery.is-stalled .log,
.rookery.is-offline .log { opacity: .5 }
import { Controller } from "@hotwired/stimulus"
// Turbo says when a request starts and when it ends, and nothing about
// one that is taking too long. This counts the seconds.
export default class extends Controller {
static targets = ["note"]
static ceiling = 10000
connect() {
this.timers = new Map()
this.start = this.start.bind(this)
this.end = this.end.bind(this)
this.element.addEventListener("turbo:submit-start", this.start)
this.element.addEventListener("turbo:submit-end", this.end)
}
disconnect() {
this.element.removeEventListener("turbo:submit-start", this.start)
this.element.removeEventListener("turbo:submit-end", this.end)
this.timers.forEach(clearTimeout)
}
start({ target }) {
this.timers.set(target, setTimeout(() => this.show(true), this.constructor.ceiling))
}
// Any answer, however late, means the server is there.
end({ target }) {
clearTimeout(this.timers.get(target))
this.timers.delete(target)
this.show(false)
}
show(stalled) {
this.noteTarget.hidden = !stalled
this.element.classList.toggle("is-stalled", stalled)
}
}
import { Controller } from "@hotwired/stimulus"
// The stream source has no `connected` attribute before its first
// connection either, so CSS alone would show the note on every page load.
// This waits until the socket has been up once.
export default class extends Controller {
static targets = ["source", "note"]
connect() {
this.wasConnected = false
this.observer = new MutationObserver(() => this.read())
this.observer.observe(this.sourceTarget, { attributes: true, attributeFilter: ["connected"] })
this.read()
}
disconnect() {
this.observer.disconnect()
}
read() {
const connected = this.sourceTarget.hasAttribute("connected")
this.wasConnected ||= connected
const offline = this.wasConnected && !connected
this.noteTarget.hidden = !offline
this.element.classList.toggle("is-offline", offline)
}
}
class BirdsController < ApplicationController
# A request of its own, so it works whether the socket is up or not. The
# wait holds a web server thread, not a thread of the visitor's own.
def create
bird = Delivery::BIRDS.fetch(params[:bird])
sleep bird.flight
Delivery.deliver(params[:bird])
# The log changes when the model's broadcast arrives.
head :no_content
end
end
resources :birds, only: :create
The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| 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.