Skip to content

Troubleshooting

Start with the health checks. GET /api/v1/system/checks, or the banner at the top of the web UI, covers most of what goes wrong and tells you which of it Suede can fix itself.

curl -s http://appliance:9088/api/v1/system/checks | python3 -m json.tool

Then look at GET /api/v1/status, which lists every piece of desired state that could not be realized.

How warnings reach you

Suede raises two kinds of complaint, and both are built so that they end somewhere you can act.

A check is a fact about the machine — a missing package, a compositor setting that will silently break spanning. Each carries a status of pass, warn or fail, and:

Field Meaning
docsUrl The page on https://suede.gameshow.pro describing the problem and its manual remedy.
fixAvailable Whether Suede can apply the remedy itself.
fixDescription Exactly what applying it would change, so you can decide before it happens.

A divergence is a fact about your configuration — something you asked for that could not be applied. It carries a kind, the subject it concerns, and its own docsUrl. Divergences have no fix button, because the remedy is always to change what you asked for or to change the hardware.

docsBaseUrl in the bootstrap file decides where those links point; set it if you host your own copy of these pages.

The web UI collects both into a banner above every tab, failures first, and shows the fix description with a confirmation step before anything is applied — a fix writes to the machine, so it should never happen on a stray click. Applying one calls:

curl -X POST http://appliance:9088/api/v1/system/checks/direct-scanout/fix

which returns what it did. Only four checks offer this: direct-scanout, sway-config, systemd-unit and pipewire. Everything else needs a package installed or a cable moved, and says so.

Suede is not reachable

systemctl --user status suede
journalctl --user -u suede -f

If the service is not running at all, the unit is probably not enabled — that is the systemd-unit check, and it has a fix button. If it restarts in a loop, the log will say why.

Remember the unit is a user service, tied to the session. sudo systemctl status suede will not find it.

"no sway IPC socket found"

Suede runs inside the Sway session and finds the socket through $SWAYSOCK, $XDG_RUNTIME_DIR, or /run/user/*. It waits for the socket rather than failing, so this usually means Sway is not running, or the service is running outside the session.

Over SSH, the variable is not set. Borrow it from the session:

export SWAYSOCK=$(ls /run/user/$(id -u)/sway-ipc.* | head -1)
swaymsg -t get_outputs

If Sway itself is not starting, check ~/.sway.log and confirm auto-login is landing on tty1.

A compositor restart strands the daemon

Sway's IPC socket path contains its process id, so a compositor that restarts comes back on a different path. A running daemon captured SWAYSOCK from its environment at launch and cannot follow, so it holds a socket that no longer exists: healthz reports "sway": false, and reconciliation raises a sway_unreachable divergence rather than claiming to be synced, because a pass that cannot reach the compositor has verified nothing — the outputs it lists are simply the last ones it saw.

The fix is to restart the daemon:

systemctl --user restart suede.service

On a correctly provisioned appliance this is automatic: suede.service is PartOf=graphical-session.target, so a compositor going away stops it, and starting the session starts it again with the new environment. It is worth knowing about anyway, because a machine running a second compositor by hand breaks that chain — whichever one publishes SWAYSOCK last is the one the daemon inherits, and it may not be the one with the screens on it.

A display stays dark

Check what Sway actually sees:

curl -s http://appliance:9088/api/v1/outputs | python3 -m json.tool
Symptom Cause
The output is missing entirely Cable, EDID, or the connector is genuinely absent. status reports output_not_connected
Present but "active": false No configuration entry, or one with "enable": false
Active but the wrong mode The requested mode is not advertised; status reports mode_unsupported
Configured but nothing changed Look for command_failed divergences — Sway rejected the command

A configured output that is not connected is deliberately not an error. Suede keeps the configuration and applies it the moment the display appears.

A browser will not start

curl -s http://appliance:9088/api/v1/apps | python3 -m json.tool
State Meaning
waitingForOutput The target output is not connected or not enabled
backoff It exited and is waiting out the restart delay; detail says why
crashed The restart policy declined a relaunch
starting Launched, but no window has appeared yet

"Failed to create a ProcessSingleton for your profile directory"

The app crash-loops, and Chromium's own log says:

Failed to create /home/you/.local/state/suede/profiles/<app>/SingletonLock:
  Permission denied (13)
Failed to create a ProcessSingleton for your profile directory. ...
  Aborting now to avoid profile corruption.

Chromium is describing a symptom, not the cause. Nothing is corrupt: the browser is a snap, and a confined snap may write anywhere in $HOME except a hidden directory — which is exactly where Suede keeps its state.

Current versions of Suede do not choose a snap at all, so this only appears when one was named deliberately with launcher.program, and even then the profile is placed under ~/snap/<name>/common/suede-profiles/<app> so it works. If you are seeing it, either the daemon predates that behaviour or something else is passing --user-data-dir. Check which binary is actually being launched:

curl -s http://appliance:9088/api/v1/system/checks   | python3 -c 'import sys,json;print([c for c in json.load(sys.stdin) if c["id"]=="browsers"][0]["detail"])'

The reliable fix is a browser from a .deb rather than a snap — on Debian apt install chromium, on Ubuntu Google Chrome's own package — because a snap also restarts itself whenever it updates, which on an appliance means the screens go blank mid-show.

An app that cannot run does not stay a private matter: after three consecutive failed launches Suede raises an app_crash_looping divergence and the appliance reports degraded, and an app whose restart policy declines a relaunch raises app_halted at once. Both name the app and carry the reason, so GET /api/v1/status is enough to see what is wrong:

curl -s http://appliance:9088/api/v1/status | python3 -m json.tool

The commonest reason is the simplest: the browser the app asks for is not installed. A firefox-kiosk app on a machine with only Chromium can never start, however healthy everything else is. The browsers health check reports this before it happens, because it compares the configured apps against what is actually present rather than just confirming that some browser exists:

FAIL  configured apps cannot start: test-card needs firefox or firefox-esr.
      Install the browser, or change those apps to one that is present
      (available: /usr/bin/google-chrome-stable ...)

Note that Firefox is also subject to the autoplay policy Suede cannot disable for it — see above — so chromium-kiosk is the better default.

An app stuck in starting usually means the browser is failing before it maps a window. The browsers health check runs chromium --version to catch an unusable install. Beyond that, run the same command by hand in the session:

chromium --ozone-platform=wayland --kiosk http://example.com

Two Chromium instances, one profile

Chromium refuses to start a second instance sharing a profile. Suede gives every app its own --user-data-dir automatically, so this only bites if you have passed a conflicting --user-data-dir in extraArgs.

A spanned window mirrors instead of spanning

Every display shows the same part of the page rather than its own slice, even though GET /windows reports the window at the full width of the layout and sway agrees.

This is not a layout problem. When wlroots can hand a fullscreen client buffer straight to the display controller, each output scans that buffer out from its own origin — so a 3840-wide window on two 1920-wide displays shows pixels 0–1920 on both. Everything reports as correct, which makes it very hard to spot from the API alone.

Start sway with direct scanout disabled:

WLR_SCENE_DISABLE_DIRECT_SCANOUT=1 sway

provision.sh sets this for you. The direct-scanout health check warns whenever an application is spanning a non-overlapping layout while the running compositor was started without it:

curl -s http://appliance:9088/api/v1/system/checks   | python3 -c 'import sys,json;print([c for c in json.load(sys.stdin) if c["id"]=="direct-scanout"])'

Observed with the Nvidia proprietary driver. Per-output kiosks are unaffected — each window covers one display, so the buffer and the output match.

A page freezes but the browser keeps running

That is exactly what the content watchdog is for. Enable it on the app, and have the page post to {heartbeatUrl} every 10 seconds. Suede then kills and relaunches the browser after 25 seconds of silence.

Without heartbeats there is nothing to detect: from the outside, a frozen page and a working one look identical.

If the watchdog is firing when it should not, check that the page is actually posting — lastHeartbeat in the app status shows the last one received.

Audio goes to the wrong place, or nowhere

curl -s http://appliance:9088/api/v1/audio/outputs | python3 -m json.tool
wpctl status    # what PipeWire itself thinks

Use the id field (PipeWire's node.name) in the app's audio.output; it is stable across reboots. A configured sink that is absent is reported as an audio_sink_not_present divergence, and the app still launches on the default sink.

If no sinks appear at all, pw-dump is failing — check that PipeWire is running. If sinks appear but browsers have no audio device, pipewire-pulse is missing; browsers reach PipeWire through its PulseAudio compatibility layer, which is what PULSE_SINK routing depends on.

Only a "Dummy Output" is listed

The most common cause on an appliance, and the most confusing, because everything else looks healthy: PipeWire is running, pipewire-pulse is serving, and one sink is listed. But that sink is auto_null, the dummy PipeWire invents when it can open no audio devices at all, and anything routed to it is discarded. Suede reports this as a warning on the PipeWire health check rather than a pass.

The cause is almost always device permissions. /dev/snd/* is owned by root:audio with no world access, and the ACLs that normally grant a desktop user access are applied by systemd-logind per session, to sessions attached to a seat. An appliance auto-logs in and runs its compositor from a systemd user service, which does not reliably get a seat, so those ACLs are never applied. Confirm it directly:

id -nG | tr ' ' '
' | grep -x audio    # is the user in the group at all?
loginctl list-sessions                  # SEAT column empty means no ACLs
ls -l /dev/snd/                         # root:audio, mode 0660

The fix is static group membership, which does not depend on a session:

sudo usermod -aG audio "$USER"
sudo reboot

A reboot is genuinely required. A running process keeps the groups it started with, so restarting the PipeWire units is not enough — they are spawned by a user manager that still has the old set. Provisioning does this for you (provision.sh adds audio, video and render); a machine set up by hand is the usual way to end up here.

Changing an app's sink relaunches it. That is expected: routing is applied at launch.

The page is silent, but everything looks right

If a sink exists, the app is running, and still nothing is heard, check whether the page was ever allowed to start playing. Browsers block audio until a "user gesture", and an appliance never provides one. The chromium-kiosk preset disables that policy; a page run some other way (an exec launcher, or firefox-kiosk) may still be blocked.

A page can report the answer itself — new AudioContext().state is suspended when blocked and running when not. Writing it into document.title makes it readable straight from the API, with no access to the machine's screen:

curl -s http://appliance:9088/api/v1/windows | python3 -m json.tool

Whether audio is genuinely reaching a device is a separate question, and PipeWire answers it: a playing app appears as a Stream/Output/Audio node.

pw-dump | grep -A2 Stream/Output/Audio
wpctl status          # sinks in state "running" are being fed

Configuration was lost

It should not be. Desired state lives in $XDG_STATE_HOME/suede/state.json, is written atomically, and keeps a .bak. Package upgrades do not touch it.

If Suede fell back to an empty document, the log says so at startup. The backup is still on disk:

ls -la ~/.local/state/suede/

A state.json written by a newer Suede is refused rather than downgraded, so rolling back a version can look like lost configuration. The file is intact; install the newer version again.

Everything reconciles constantly

Watch the log at debug level:

RUST_LOG=suede=debug systemctl --user restart suede
journalctl --user -u suede -f

A pass that never converges usually means a command silently fails to take effect — the plan asks for something, Sway reports success, and the next query shows the old value. The command_failed divergences and the debug log showing the same commands repeating will identify which setting.

Getting a clean look at the wire

# Everything Suede is doing, live
curl -N http://appliance:9088/api/v1/events

# Force a pass and see the result
curl -X POST http://appliance:9088/api/v1/reconcile | python3 -m json.tool