shinyctl

Staying up

What shinyctl does when a copy dies, when you deploy a new version, when you change the number of copies, and when you upgrade shinyctl itself.

active warm draining starting down load balancer serving

When a copy dies

Scroll sideways to see the whole timeline.

A copy dies and the spare takes over Copy 1 is active and copy 2 is warm. Copy 1 is killed; the next request goes to copy 2, which becomes active. Copy 1 waits out its backoff, starts again, and becomes the new warm spare. Every request is answered throughout. copy 1 killed copy 1 ready again copy 1 first active active backoff starting warm · the new spare copy 2 first the spare warm active · takes copy 1's visitors requests every request answered: the spare took over in 0.04 s on a Pi 5 time → a few seconds in all
  • The spare takes over at once. The load balancer sends the dead copy's visitors to a warm copy, and shinyctl-api makes that copy active within milliseconds. The visitors stay on it.
  • The dead copy comes back as the spare. It restarts after its backoff and is the newest copy, so it becomes the warm spare: roles don't swap back.
  • Backoff: crashes are counted for each copy, and restarts wait 2 seconds, doubling up to 1 minute. status shows the copy as crash-looping until it is up again; its crash count goes back to - once it has stayed up for a minute.
  • restart-policy decides whether it comes back: always (the default) after any exit, on-failure only after a non-zero exit, never not at all. A copy left down on purpose shows as exited, with a line under the table saying why.
  • Without a spare (warm-replicas: 0), a request waits up to 15 seconds for the app to come back; after that the visitor gets a page saying the app is restarting, which reloads itself every few seconds.
  • If shinyctl-api is down, the load balancer still puts the spare in, but nothing restarts the dead copy until shinyctl-api is back.

After its copy changes, an open Shiny page greys out until it is reloaded. The example app reloads by itself once the app answers again: copy its tags$script into your app's UI. Shiny's own session$allowReconnect("force") is no substitute: with shiny 1.14, a page reconnected to a new R process keeps its old outputs, and its downloads stop working.

Redeploys

restart starts new copies from the manifest as it is now, so new code in app.R takes effect. An apply does the same when it changes how the app starts: env, working-dir, app-group, app-name or entrypoint-file. Both return at once.

Scroll sideways to see the whole timeline.

A redeploy that drains On restart, new copy 3 starts while the old copies serve. At the switch, new visitors go to copy 3, old copy 2 drains and old spare copy 1 stops. The new spare, copy 4, starts after the switch. Copy 2 stops when its last visitor leaves, or at drain-timeout. restart switch: new visitors go to copy 3 last visitor leaves, or drain-timeout copy 2 old version active draining · keeps its visitors stops copy 1 old spare warm no visitors: stops at the switch copy 3 new version starting active · new visitors copy 4 new spare starting warm time → draining lasts until the visitors leave, 15m at most
  • New copies first. replicas new copies start next to the running ones. The spares start once those have taken over, so at most one extra R process runs with the defaults.
  • The switch. Once the new copies that serve are ready, new visitors go to them, and the old copies drain: they keep the visitors they have until those sessions end or drain-timeout (15 minutes by default) passes. An old spare with no visitors stops right away, and an old copy with no sessions stops within about 15 seconds.
  • Old pages keep working. A page left open on an old copy keeps working on the old version, and reloading it brings the new one. When an old copy stops at the timeout, pages with the example's script reload onto the new version by themselves.
  • A broken version never takes over. If the new copies don't start, the old ones keep serving, and status says which copy doesn't start and where to look.
  • --now stops the old copies as soon as the new ones are ready, without waiting for their visitors.
  • A redeploy during another goes straight to the newest version.

While it goes on, status shows the draining copies and how long they have left:

NAME   COPY  ROLE      PORT  PID    STATUS  READY  MEM   SESSIONS  CRASHES  STARTED
hello  1     draining  8765  61429  alive   ready  142M  2         -        2026-10-27T10:00:01+02:00
hello  3     active    8765  61501  alive   ready  140M  1         -        2026-10-27T11:30:05+02:00
hello  4     warm      8765  61502  alive   ready  138M  0         -        2026-10-27T11:30:05+02:00

Memory: 420M in 3 R processes (1 warm, 1 draining), plus 41M for the load balancer.

hello: copy 1 is draining with 2 sessions; it stops when they end, or in 15m at the latest.

Both versions run side by side until the drain ends, so a change to shared data, such as a database schema or files, must suit both.

Scaling

Changing replicas or warm-replicas and applying starts copies, or drains the ones it removes the same way: they get no new visitors, and stop when their sessions end or at drain-timeout. Copies that are starting or dead stop at once, and --now stops them all at once. Copies leave in this order: those with no process, those still starting, then ready ones with the fewest open sessions, a warm copy before an active one when they tie.

Changing port, host, lb-policy, drain-timeout or restart-policy takes effect without restarting the app's processes.

Upgrading shinyctl

Install the new binary (running the installer again does it), then restart shinyctl-api:

systemctl --user restart shinyctl                 # if you installed the service
# or, if you started it by hand:
pkill -u "$USER" -f '(^|/)shinyctl serve$' && shinyctl serve -d

Scroll sideways to see the whole timeline.

Upgrading shinyctl without a gap You restart shinyctl-api. The new one starts a new load balancer that serves next to the old one; a quarter of a second later the old one is stopped, finishes the requests it is answering, and exits, and the new one takes over its lock. The R copies keep running throughout and are adopted, and every request is answered. 1 · you restart shinyctl-api 2 · new load balancer serves next to the old one 3 · a quarter second later, the old one is stopped 4 · it exits; the new one takes over shinyctl-api old new · adopts the copies and the load balancer load balancer old build serving finishing requests load balancer new build serving · same ports, so nothing is refused R copies keep running: the same processes, adopted requests time → the old one finishes its requests for up to 5 s

Your apps keep running: the new shinyctl-api adopts them. When the new version comes with a new load balancer, it starts next to the old one, on the same ports, and takes over without refusing a connection. The old one answers the requests it is in the middle of, for up to five seconds, and then stops.

Connections that were open on the old one end, and a Shiny session is one, so a page open at that moment reconnects or reloads once. On Linux, a request that reaches the old one in the very instant it closes can fail once. In the release test on a Raspberry Pi 5, two upgrades under load lost 0 of 473 and 0 of 470 requests.