Docs menu

Reference

Hub internals

How slowhub is built: its runtime, the planner's arithmetic, memory admission, the app supervisor, ingest, events, chained hubs, and the planned web interface, releases and backups.

For contributors and anyone curious about how slowhub works inside. In code, SQL and on the wire a slowapp is called an app.

What is built. Today slowhub plans and runs renders (on Podman or as host commands), stores their output, serves screens over the protocol, and delivers answers to apps. The web interface with its passcode, the proxy for app pages, chained hubs, releases and backups are designed but not built yet; they are described below as designed.

Runtime

  • tokio current-thread runtime with at most two blocking threads. Blocking work (password hashing, PNG decoding and hashing at ingest, du, backups) runs in spawn_blocking behind a semaphore of 2.
  • axum for HTTP.
  • SQLite (/var/lib/slowhub/hub.db, WAL mode), owned by one writer thread.
  • Podman, driven through its CLI. No resident Podman API service, no resident podman events.

There is no periodic tick. The hub wakes on HTTP requests, the planner’s next wake time, clock steps (a timerfd with TFD_TIMER_CANCEL_ON_SET), container exits, idle timers on running app pages, the internet probe and database replies.

Clock gate. Until the system clock is synced (STA_UNSYNC clear), planner and schedule jobs don’t run, except test renders, and app summaries show “waiting for clock”.

The config document

The hub holds one versioned config document per screen: its prefs and its layout (Layout and preferences has the fields). Its version is (hub_id, epoch, n). The epoch is chosen at random when hub.db is created, so a rebuilt hub never collides with an old one. The hub clamps the document with the same clamp function slowd uses before storing it. slowd is authoritative for limits, the hub for prefs.

Screens

Each screen is a row in hub.db with its profile, capabilities, last config acked, needs[], applied wants and frame hash. The protocol (/sb/v1) is served on:

  • device_socket (/run/slowhub/device.sock) for a slowd on the same machine. The hub accepts a connection whose SO_PEERCRED uid is device_user (slowd) and registers it as local. No token is needed.
  • listen_device_tcp (opt-in) for screens and downstream hubs on other machines, with bearer tokens created by slowhub device add and stored as sha256.

Status and press events live in a RAM ring, never in SQLite.

Runners

  • PodmanRunner runs an app image under rootless Podman with its grants.
  • HostRunner runs a host command as the hub’s user with the same environment, /out and exit-code contract. It is configured in slowhub.toml only and is never installable.
  • RemoteHubRunner (planned) fetches an upstream hub’s feeds, as an ordinary app.

Podman flags

Every app container gets a read-only root filesystem, --pids-limit 64, --cap-drop all, --security-opt no-new-privileges and --oom-score-adj 500, and runs as root inside the default rootless user namespace, which maps to the hub’s user. The grants map to flags:

GrantFlags
network = "none"--network none
network = "egress"--network pasta:--no-map-gw (internet and LAN, never host loopback)
memory--memory = --memory-swap
tmp, cache--tmpfs /tmp, --tmpfs /cache, counted against memory
databind mount of /var/lib/slowhub/apps/<id>/data at /data
secrets--secret sb-<app>-<name>,target=<name>,type=mount,mode=0400

Because egress reaches the LAN, every Slowboard TCP listener requires authentication.

One instance per app

Stopping Idle Backoff cold job: Idle Admitting Starting Running Ingesting the job exit: back to Idle, or Backoff after a failure warm page: socket ready, health ok job dispatch Starting Warm Busy job v job done , no viewer, no any state: timeout, , unexpected exit Killing attached, --rm within start_timeout_s POST /_sb/jobs idle >= idle_s podman stop -t 5 health fail x3 (not Busy) podman kill; rm -f

An app has one instance at a time. A cold job runs attached and is ingested on exit. A warm page takes jobs over HTTP and runs each in a child process, so the page doesn’t keep the job’s memory; it stops itself when nothing is using it. A scheduled job never wakes a page.

Spawning. A semaphore of 1 covers only the spawn (until the cidfile and conmon pidfile exist), so a page start never waits behind a long render. At startup the hub lists containers labelled sb.hub, adopts a page whose socket still answers /_sb/health, and removes the rest.

Outcomes. Exit 137 with an OOM kill recorded is oom; elapsed time past the timeout is timeout. Run outcomes are new, unchanged, failed, transient, needs_config, oom, timeout, cancelled and waiting_network.

Memory admission

The hub computes the app memory budget at start:

app_budget = MemAvailable (at hub start, after 60 s settle)
             − spike_reserve_mb (64: Podman CLI spikes)
             − os_reserve_mb (48)
             − standing tmpfs (/run/user/<uid>, /run/sbblobs)
charge(app) = memory limit (or a refined figure) + tmp + conmon 1.5 + pasta 8 (egress) + 15 (attached CLI)

A job is admitted if the running charges plus its own fit within app_budget − ui_reserve, so there is always room for one page; a page is admitted if it fits within app_budget. After three runs the charge is refined from measured peaks, max(0.6 × limit, 1.25 × p95 of the peak), while the manifest limit stays the hard kill. On kernels with pressure stall information, jobs also wait while memory pressure is high.

Queue order: page requests, then on-demand jobs, then planner jobs (by deadline), then scheduled jobs. A page request or planner job may stop another app’s page that has been idle for 30 s. Running jobs are never killed to make room.

The internet probe

The probe checks [probe] target every 5 minutes while healthy and every 30 s while degraded. While the internet is down, jobs of apps with network = "egress" are not started; they are recorded as waiting_network, with no backoff.

The planner

The planner is a serial backward queue. Its input is the needs[] of every screen and downstream hub, or, for a screen it hasn’t heard from yet, the refresh grid of its prefs.

the planner packs backwards from the next grid time T start T weather D 48 s calendar reminders margin screen p90 of last 10 runs D 20 s 3 s M refresh longest first ........................ shortest M max 20 s, nearest T 0.25 x sum T M sum D T M D a short exit 4 exit 5 job that fails can retry and back off 1, 2, 4 ... 30 min, needs config, no retry until still make T; a long capped by the next T settings, secrets or one cannot grants change
for each distinct deadline T among needs:
  collect the render jobs that produce a needed (feed, surface)
    skip if a run for T is already queued, running or done
    skip if the job's declared inputs are unchanged and the feed is still valid past T + every_min
  D = p90 of the last 10 runs (cold starts included), else the manifest's expected_s, else 60 s
  order longest first; margin M = max(20 s, 0.25 × ΣD); the last job ends at T − M
  next_ready_by(feed) = planned finish for the next T

Longest first puts the short jobs closest to T, where a retry can still make it. Jobs run in parallel only with [planner] overlap = true, and only when both charges fit the budget. Schedule triggers (every, daily) run at wall-clock times with jitter, separately from the planner. After a reboot, the first T uses the feeds persisted on disk, so there is no burst of renders.

Update now marks every placed feed due now. As each job is ingested, seq is bumped; when the last is done, the screen’s wants.refresh generation increments and slowd’s open long-poll returns within milliseconds.

Ingest

Ingest reads /out (with O_NOFOLLOW and lstat), validates it with sb-proto, writes the blobs and records bundle, feed and seq in one transaction. Then the run directory is deleted.

  • Blobs are files in /var/lib/slowhub/blobs/<id>.png, each written once (temporary file, then rename). The hub keeps the current and previous blobs; garbage collection runs after the commit. At startup, any feed whose blobs are missing is dropped.
  • Blob id = sha256("SBP2" ‖ u16le w ‖ u16le h ‖ packed rows), so identical pixels always give the same id.
  • Exit 3 or an unchanged cache_key keeps the previous feeds and creates no new ids.
  • App data is /var/lib/slowhub/apps/<id>/data. The hub runs du after runs and pauses an app that exceeds its max_mb.

Events

emit events (answers) whose kind is listed in an app’s [events] accept are stored once per id and delivered at least once: to a running page as POST /_sb/events, otherwise as the app’s event job with /in/events.jsonl. After delivery the hub runs the app’s render job. Stored events are pruned after 7 days.

Web interface (planned)

listen_web (:8080 on the Slowboard 1085) will serve an embedded web interface fed by GET /api/status and a server-sent event stream at GET /api/events. Today it forwards to the older control page.

  • App cards show each app’s stored summary.json. Showing them starts no containers; a button runs a job.
  • Settings are a form generated from the manifest’s [settings].
  • App pages open only on click, under /apps/<id>/.
  • Preview shows the frame slowd last uploaded.

Auth. A passcode, mandatory when listen_web is not loopback, stored as PBKDF2-SHA256 (200,000 iterations), with login rate-limited to 5 a minute. The session cookie sb_session is HttpOnly, SameSite=Strict, valid 30 days, and only its hash is stored. Non-GET requests need Sec-Fetch-Site: same-origin or a matching Origin. Every TCP listener already checks the Host header against host_allow.

App page proxy. /apps/<id>/* goes to the app’s socket at /run/user/<uid>/sb/<id>/ui.sock. Inbound, it strips Cookie, Authorization and hop-by-hop headers, adds X-Forwarded-Prefix: /apps/<id>, X-SB-User: owner and X-SB-Request-Id, blocks /_sb/* and caps bodies at 4 MB. Outbound, it strips Set-Cookie, adds Referrer-Policy: no-referrer, and adds Content-Security-Policy: sandbox allow-scripts allow-forms for apps that are not first-party. WebSocket upgrades are not proxied.

Trust and grants. Trust comes from where an app was installed from, never from its manifest. Approving grants requires the passcode. When a new manifest asks for more grants, the app waits for approval and the old digest keeps running.

Chained hubs (planned)

A hub can be a screen of another hub. It connects upstream over HTTPS with its own token (tokens are never forwarded), reports its screens’ needs[] minus hop latency, and serves the upstream feeds through RemoteHubRunner. hello.via and a hop count guard against loops: a hub refuses a hello that lists its own id in via, or has hops > 3.

Releases and backups (planned)

  • Releases. First-party app digests are pinned in release.json. The hub pulls by digest in a quiet window, runs a test render, saves the data folder before switching, keeps the previous digest and offers a rollback.
  • Backups. slowhub backup will write an age-encrypted tar.zst of hub.db, app data, secrets and slowd’s state.

Updates, now and then

A short note when there is a new board, a new slowapp, or kit news. Rarely more than one a month.