Skip to content

sunshine

Module: system/services/sunshine.nix

Screen streaming for Moonlight, over Hyprland/Wayland. It captures through wlr-screencopy and encodes on the Arc B580's AV1/HEVC encoder (VA-API). It replaced Tailscale as the remote access path on 08/08/2026.

One way in, and it does not open a port on any interface

openFirewall = false, and the only door is the router's WireGuard, through the 10.10.10.0/24 source rule in network.md. The tunnel terminates on the ROUTER, so this machine has no WireGuard interface and nothing here has to be up for the path to exist.

There is no relay: the endpoint is the home router itself. That was a Tailscale risk (DERP), and Tailscale is gone since 08/08/2026.

A second path existed from 10/08 to 19/08/2026 and was RETIRED: the internet directly, restricted to two UFSCar blocks by 8 rules here plus 8 Moonlight-* redirects on the router. Its stated reason was that adding a third VPN client to the FAI machine would be a routing conflict, and that premise died the day the client went on that machine and worked. Three measurements finished it:

  • it did NOT reach the subnet that machine actually uses (200.136.204.0/23), which was never declared, so it was not the fallback it looked like
  • the /21 rules had forwarded ZERO packets, ever
  • the tunnel reaches the host from any network, including the campus /20 the direct path served

What it cost while it lived: /serverinfo with NO authentication, offered to two UFSCar blocks, and 16 rules mirrored by hand across two systems, which is the divergence that cost the morning of 19/08. The full story is in the august history, and router-ssot now FAILS if a redirect reappears on the router, so coming back is a decision and not an accident.

47990, the admin panel, was never forwarded, and now there is nothing to forward it through. Whoever adds a redirect for it publishes user creation and client pairing on the internet.

Encryption, and why it is not downgraded. Sunshine classifies the client by IP. Through the tunnel it arrives as 10.10.10.x, which is LAN, so lan_encryption_mode = 0 and the TUNNEL is what encrypts. wan_encryption_mode = 1 stays at its default and no longer has a path to serve. Do not "fix" the LAN value without noticing that the tunnel is now the only way in: turning it on costs CPU to encrypt what WireGuard already encrypted.

The port list is derived, because the blogs are wrong

The offsets come from the build in use (2026.516.143833), read in assets/web/assets/config-*.js: tcp port-5, port, port+1, port+21, and udp port+9 through port+11.

Almost every list on the internet includes a UDP 48002 ("mic") that does not exist in this version. There are three UDP ports, not four. Check the js in the store when updating.

The offsets are still load bearing with the direct path gone: sunshine-health probes port-5 and reads /serverinfo on port, and sunshine-stream-active looks for port+9 through port+11. Moving basePort moves all of them at once, which is the entire point of deriving them.

The black screen was DPMS, not a codec

A long debug in jul/2026. "Black screen in Moonlight" was wlr capturing the monitor while DPMS-OFF, and NOT a version or encoder regression. Capture works as long as the monitor is ON during the stream, which is why the streamBegin guard exists.

capture=kms would be an alternative, but kmsgrab does NOT enumerate on the xe driver (Battlemage): "Unable to find display", and the service does not even stream.

Careful: toggling DPMS WITH capture and encoding active caused a GPU engine reset (xe RCS), which is why the guard wakes the screen BEFORE the stream, in the prep-cmd, never in the middle.

Force capture=wlr. Without it Sunshine PROBES the portalgrab backend at startup, and on Hyprland that probe fires hyprland-share-picker, which does not render (a missing Qt plugin) and HANGS Sunshine, so it never opens the ports. Video is wlr, input is uinput through the /dev/uinput uaccess ACL, both with no portal.

Pin WHICH monitor. Without it wlgrab takes the FIRST in the enumeration, which is not necessarily the one being used. Measured on the setup of the time (a TV on HDMI that left in 09/2026): Monitor 0 is HDMI-A-3 / Monitor 1 is DP-2 then Selected monitor [... LG TV], so Moonlight opened on the SECONDARY. It is not the client's choice: Moonlight gets what the host sends. It matches by connector NAME and not by index, because the index depends on enumeration order, which is exactly what went wrong. Following my.monitors.primary means it now captures the QHD panel at 180Hz, which costs more to encode than the 1080p it was written for.

The idle guard, and why it needs a watchdog

The capture is of the PHYSICAL monitor, so idle must not turn the screen off. dpms-off was removed for that reason (black screen plus a GPU engine reset on xe). What is left is the LOCK after 5 min, which mid-stream would lock the remote session, so the guard PAUSES hypridle while the stream runs and turns it back on at disconnect.

The undo is not a guarantee. Measured on 10/08/2026: a client disappeared with no clean teardown at ~14:57, Sunshine never closed the session, the undo never ran, and hypridle stayed stopped for 6h. ping_timeout does not cover this: it drops the STREAM, not Sunshine's session bookkeeping. And while hypridle is stopped the machine never locks by itself.

The signal has to be reality, not Sunshine's bookkeeping. The obvious path would be /serverinfo, and that is precisely what lies: at 17:30 that day it still said SUNSHINE_SERVER_BUSY with the client dead for 2h30. A watchdog keyed on it would never fire.

What did not lie in the same measurement: the sockets. With the ghost session "active", Sunshine had ZERO UDP sockets on the video ports; it creates them per session and closes them at the end. So bound means a real stream.

Honest about the evidence, and the check is now DONE. The negative side was measured on 10/08 (no stream implies no socket). The positive side stopped being an inference on 19/08/2026, during a live session over the tunnel: all THREE ports bound, 47998, 47999 and 48000. So bound means a real stream, observed and no longer assumed.

The same measurement demoted the other half of the test. During that live stream there was NOT ONE established TCP on 47984 or 48010: Moonlight closes them after negotiation and the session runs on UDP alone. So the ss -tanH state established check only covers the NEGOTIATION window, and everything that protects a stream in progress rests on the UDP sockets. Do not remove it, it is what covers the window before the video binds, but do not read it as a second independent signal either.

Since 19/08/2026 that test is one script, sunshine-stream-active, because a second consumer showed up (the ghost reaper below) and the ss filters were worth writing once instead of twice.

The second consumer changes the stakes of the measurement still pending. The guard only turns hypridle back on, so a wrong answer costs a lock. The reaper RESTARTS Sunshine, so a live stream with no bound socket would cost a dropped session, and that failure mode is precisely the negation of the inference nobody has confirmed yet. That is why the reaper does not trust the sockets on the first look and waits.

The guard writes a mark in XDG_RUNTIME_DIR saying the hypridle pause belongs to IT and not to a human, because the bar's pill also stops hypridle on purpose, and without the mark the watchdog would undo that manual toggle within 5 min.

Three shell traps this file paid for

set -o pipefail plus grep -q inverts the result. grep -q exits on the 1st match, the producer dies of SIGPIPE, and the pipeline returns an ERROR despite having matched. In sunshine-health that read a successful TLS handshake as a failure, and the timer would have restarted Sunshine every 2 min forever. The fix is capturing into a variable plus a case, with no pipe at all. The same trap appears in vpn.md.

ss filters itself, for the same reason. And it is not dst X, never dst != X: the second looks natural and the parser rejects it with bison bellows (syntax error). Tested 10/08/2026.

A runtimeInputs missing a binary is a LATENT bug, not a build error. writeShellApplication PREPENDS its inputs and then appends the inherited PATH, so a command it does not declare still resolves when you run the script by hand, because an interactive shell has /run/current-system/sw/bin. Under systemd it does not: a USER unit gets a fixed PATH of coreutils, findutils, gnugrep, gnused and systemd, and nothing else. hypridle-guard used awk without declaring gawk, so it exited 127 on the /proc/uptime line every 5 min from 10/08 to 19/08/2026: the watchdog was dead for those 9 days while testing by hand kept saying it worked. Measured on 19/08: ExecMainStatus=127 with Result=exit-code.

sunshine-health does a real TLS handshake on 47984, because -brief prints Protocol version: only when the handshake COMPLETES. An accepted TCP is not enough, which was exactly the hung state of 29/07. It targets 127.0.0.1, so it does not depend on the VPN.

packet_size is locked at 1024, and that is a constraint now

Sunshine's default is 1392, calibrated for MTU 1500. In a tunnel it overflows and WireGuard drops it SILENTLY (no ICMP, no log): the host streams normally, the client receives half, fails to reassemble the frame and disconnects in ~4 s. That is what happened on 29/07 with tailscale0 (MTU 1280).

With the direct path retired on 19/08/2026 there is ONE path again, and its MTU is measured rather than assumed: 1420 exactly, end to end from this host to the peer. So 1024 is no longer calibrated for a number that stopped existing (tailscale0's 1280) and there IS headroom: 1136 by the same proportion, 1164 by the same absolute margin, with the arithmetic in guides/wireguard-moonlight.md.

It was left at 1024 on purpose. The ask that day was stability, and the smaller packet is the robust one: every byte closer to the ceiling is a byte closer to the silent drop described above, and the win on the other side is overhead per frame, not anything you can see.

The encoder is pinned, and the codec still is not the host's to choose

encoder = "vaapi" since 16/09/2026. There is exactly ONE GPU here, card0 on xe (8086:E20B, the B580) with a single render node, so auto-detection cannot pick the wrong adapter and adapter_name would be a declaration that changes nothing (rule 16).

What the pin buys is the PROBE. With no encoder set, Sunshine walks its whole list on every start and the log opens with:

Error: [CUDA @ 0x...] Could not dynamically load CUDA
Error: Failed to create a CUDA device: Operation not permitted

which is NVENC being probed on a machine that removed NVIDIA for good. It is harmless, and Sunshine itself prints // Ignore any errors mentioned above // right after, but an error line that is always there is an error line nobody reads, and that is rule 16's argument applied to a log.

vainfo is what says the pin lands on something real, and it lists all three entry points: VAProfileH264High, VAProfileHEVCMain / Main10 and VAProfileAV1Profile0, every one of them with VAEntrypointEncSlice. The host announces all three at startup.

The pin does NOT choose the codec, and that is the trap worth repeating: the codec is NEGOTIATED per session and the CLIENT picks. See the correction of 03/08/2026 below, which is still the most useful paragraph in this file.

Two more settings that look wrong and are not

origin_web_ui_allowed = wan is kept at the more permissive value on purpose, because what decides the reach is the firewall, not Sunshine. Tightening it gains nothing and risks breaking the web UI silently, since the stream does not use CSRF, so the symptom only shows up when opening the panel.

This only stays safe while 47990 is not forwarded. Until 10/08/2026 the claim was "only the WireGuard range reaches this port", and it stopped being true the day direct access landed. 47990 was left out of both lists precisely because this value is wan; forwarding it would publish the admin panel with no gate at all.

The CSRF origin is a snapshot. It was already wrong once, between the cutover (01/08) and 02/08/2026, and nobody noticed, because only the web UI breaks. Back then it pointed at the tailnet, whose IP changed on every rejoin. Now it is this machine's LAN IP, which is where the WireGuard peer arrives. An IP cannot be derived at build time, so it is worth guaranteeing a static lease on the router: without one, changing IP breaks the panel again and you only find out when you try to open it.

The other direction: this machine as the CLIENT

Since 22/08/2026 there is a second Sunshine in the picture and it is not this one. My mother's T480 (Windows, 10.10.10.6) is the HOST there and this machine is the client, through moonlight-qt in home/packages.nix. Everything above describes the opposite role, so these are the pieces that do NOT transfer:

The client's config is state, not config. The paired hosts, the per-host resolution and the bitrate slider live in ~/.config/Moonlight Game Streaming Project, which the app rewrites at runtime, so rule 14 says Nix declares the PACKAGE and nothing else. There is no client-side equivalent of system/services/sunshine.nix.

There is no codec to choose there, and the sentence that used to sit here was wrong. It said "HEVC is the only step up available, turn it on in the client", reasoning from the hardware: an UHD 620 is Kaby Lake-R, which does have an HEVC encoder. Then that host's own log answered, on its first run (22/08/2026):

Trying encoder [nvenc]      -> Encoder [nvenc] is not supported on this GPU
Creating encoder [hevc_qsv] -> some encoding parameters are not supported by the QSV runtime
Retrying with fallback ...  -> Could not open codec [hevc_qsv]: Function not implemented
Creating encoder [av1_qsv]  -> Could not open codec [av1_qsv]: Unknown error occurred
Found H.264 encoder: h264_qsv [quicksync]

H.264 only, so asking for HEVC in the client gets H.264 anyway and every bit of quality has to come out of bitrate, FEC and resolution. The useful part is not the verdict, it is why the guess failed: what refuses is the QSV RUNTIME on the driver in use (31.0.101.2135, from 2023), not the silicon, and no spec sheet says that. This repo already knew the codec is negotiated per session and has to be read off the log; it now also knows that the ENCODER LIST has to be read off the log, never inferred from the GPU. A newer Intel driver is the candidate to test, not a fix.

packet_size is the host's setting, and that host has its own tunnel. The 1024 above was calibrated for the path from HERE. The T480 reaches the same router over WireGuard, so the ceiling is the same 1420, but the last leg is her Wi-Fi and her ISP rather than this LAN, which is the part that has not been measured. The protocol to measure it is the same one in guides/wireguard-moonlight.md, run from her side.

A closed lid stops the capture, and that is why the client set has TWO tools. Measured on 22/08/2026: with a stream running, closing the laptop lid froze Moonlight, video and input alike, while the tunnel stayed up and the machine stayed reachable over SSH the whole time. The host's log says it plainly, Failed to locate an output device, and on the next attempt the capture INITIALIZES (it enumerates 1920x1080 at 60 Hz and creates h264_qsv) and then delivers no frame at all. The tunnel counters agree: 50 KiB in three minutes, which is not video. Sunshine duplicates what is being DISPLAYED, and with the panel off there is nothing being displayed. It is the DPMS lesson of this file, arrived at again on the other operating system.

So RDP is not a duplicate of Moonlight there, it is the other half. RDP creates a session of its own instead of duplicating a screen, so it does not depend on a panel and works with the lid shut. The division: Moonlight to see HER screen while she is using it, RDP to work on the machine with nobody in front of it. That is also why freerdp sits next to moonlight-qt in home/packages.nix. The price is real and worth stating: a Windows client SKU has ONE active session, so an RDP login DISCONNECTS whoever is on the console.

The invocation is a command, not an alias, and it lives in home/net/t480.nix beside the option that owns the address:

t480          # the maintenance account
t480 mae      # her account, which RECONNECTS to her session instead of replacing it
t480 mae /f   # anything after the account still reaches freerdp untouched

It expands to this, and every flag in it earns its place:

sdl-freerdp /v:10.10.10.6 /d:DESKTOP-MEM12EE /u:<conta> \
  /cert:tofu,name:DESKTOP-MEM12EE /dynamic-resolution +clipboard

A wrapper and not a shellAliases entry for two reasons: the account has to be an ARGUMENT, and the address would have become a second literal of a value ssh.nix already holds, which rule 11 forbids. Both consumers read my.t480 now, the same shape as wake-workstation.

sdl-freerdp and NOT wlfreerdp: upstream deprecated the Wayland client and prints five lines saying so on every run. The SDL3 client is the replacement and is just as native.

name:DESKTOP-MEM12EE is what silences a wall of MITM warnings WITHOUT silencing the check. Windows signs RDP with a self-signed certificate whose CN is the COMPUTER name, and the connection is made to an address, so the names differ and FreeRDP shouts CERTIFICATE NAME MISMATCH plus REMOTE HOST IDENTIFICATION HAS CHANGED. Telling it which name to expect makes the comparison correct instead of absent, and tofu then pins the fingerprint on first use, so a real change still warns. /cert:ignore would have hidden both, which is the version of this that people paste from the internet.

/d:DESKTOP-MEM12EE skips the Domain: prompt: for a LOCAL Windows account the domain is the machine's own name.

The client hangs on to the keyboard, and the key that gives it back is not the obvious one. The SDL client's hotkey modifier is the RIGHT SHIFT (SDL_KeyModMask, default KMOD_RSHIFT), so RShift+G toggles the keyboard and mouse grab, RShift+Enter goes fullscreen, RShift+R toggles resizable, RShift+M minimizes and RShift+D disconnects. While the grab is ON the man page is explicit that "local system shortcuts do no longer work and are sent to the remote system", which is why Hyprland's SUPER + drag does nothing until the grab is released. -grab-keyboard on the command line disables it for good, at the cost of never being able to send Super or Alt+Tab to Windows. Moonlight's equivalent, for the muscle memory, is Ctrl+Alt+Shift+Z.

The alternative was measured against and passed over: a virtual display driver, or an EDID dummy plug, would give Sunshine a panel that never turns off. Both work, and both put a SECOND screen on her desktop for windows to get lost in, on a machine used by somebody who will not go hunting for a window that vanished. RDP does natively what those emulate.

Nothing is forwarded to it, and that is now checked. router-ssot fails on any redirect whose dest_ip is inside vpnSubnet, so Moonlight to that machine works through the tunnel or does not work at all. It is the same conclusion the direct path reached here on 19/08/2026, arrived at before the mistake instead of after it.

moonlight-stats

"It drops all the time" is not measurable, and the Sunshine log only says CLIENT DISCONNECTED, the same line for a client that closed, a client that gave up and a host that dropped it.

What it answers, and what closed the 31/07 diagnosis: the distribution is BIMODAL, so either the session lasts hours or it dies in 3 to 60 s. That alone separates "bad network" from "something drops it", which was the question.

It SHRANK on 08/08/2026 when Tailscale left. It used to cross-reference each session with tailscaled events and path probes to answer "was this direct or did it fall into DERP?". With WireGuard there is no relay, so the question lost its object and those sections were removed instead of adapted.

Setup, once

From the browser of any WireGuard peer: https://192.168.1.10:47990, create the admin user and pair Moonlight with the PIN. The paired clients live in ~/.config/sunshine, which is state, not config.

The bitrate ceiling, and a correction that inverts the reasoning

The default is 0, meaning "obey whatever Moonlight asks", and the client asked for up to 79 Mbps. Across 67 sessions over 7 days (jul/2026), the 79 Mbps ones had a median lifetime of 22 s against 290 s for the 23.8 Mbps ones. The cap lives on the HOST and not on the client's slider, because that way it is declarative and holds for ANY client that pairs.

10000 became 20000 on 31/07, and two measurements changed the arithmetic:

  1. The encoder in use was AV1 (av1_vaapi, confirmed live), not h264 as the comment used to claim. AV1 delivers ~40 to 50% more per bit, so 10 Mbps already amounted to ~18 to 20 Mbps of h264. The ceiling was looser than it looked, but by a mistaken premise. Careful, because this is NOT a host property: the codec is what the CLIENT asks for, per session. On 19/08/2026 the Windows client of pc-trampo negotiated h264_vaapi, 8-bit, Rec. 601, while the host offers h264, HEVC and AV1 alike. So the AV1 arithmetic above holds for the client that was measured in jul/2026 and NOT for every session. Read the encoder off the log of the session in question (Creating encoder [...]) before reasoning about bitrate.
  2. The "79 Mbps" was NOT what the client asked that week. Cross-referencing bitrate against encoder in the journal, the 7 days ran at 19.4 Mbps, and the SHORT sessions on 31/07 (15 to 68 s) were ALSO at 19.4. So a short drop happens at a moderate bitrate and the cap is not what prevents it. The confounder is the hour: they cluster in the 08:00 window, the same one in which the FAI network dropped the VPN 52 times that week.

So 20000 is not an experiment, it is going back to the de facto bitrate, now explicit. The ceiling still exists to stop a client asking for 79. It serves Cities Skylines II, where a camera pan changes EVERY pixel (the worst case for interframe compression, despite the game looking calm); Hearthstone, with a fixed camera, fit comfortably in 10. The 10 Mbps sample was ONE session that did not even complete, so the previous ceiling never had a stability record.

Correction (03/08/2026), and it is the useful part: item 1 is WRONG for the FAI client. The encoder is NOT the host's choice, it is NEGOTIATED, and Moonlight picks. Measured in a real session from that machine:

Creating encoder [h264_vaapi] / Color depth: 8-bit / Rec. 601

while the host announces hevc_vaapi AND av1_vaapi, both 10-bit, at startup. That client asks for H.264 8-bit, the LEAST efficient codec available, so the AV1 arithmetic does not apply to it.

The practical consequence: turning HEVC/AV1 on IN THE CLIENT'S Moonlight is worth more than any tweak in this file, and it is where to look first when the stream suffers. There is no host setting that forces it; hevc_mode/av1_mode only ANNOUNCE support, which is already announced.

Which one to ask for, and why it is HEVC and not AV1. The choice is decided by the CLIENT'S DECODER, not by this host, which offers all three. HEVC decodes in fixed-function silicon on essentially anything since ~2016 (Intel from Skylake, NVIDIA from Maxwell 2, AMD from Polaris), and it is worth roughly 25 to 35% over H.264 at equal quality. AV1 is better again, by ~20% over HEVC, but its hardware decode only starts at Intel Tiger Lake / Arc, NVIDIA RTX 30 and AMD RX 6000, so on an unknown work notebook it is a gamble and the losing side of it is SOFTWARE decode at 1440p60.

So: ask for HEVC first. Asking costs nothing, because Moonlight checks its own decoder and falls back on its own, and the verification does not depend on trusting the client's UI. The host log names what was actually negotiated:

journalctl --user -u sunshine.service --since -10min | grep 'Creating encoder'

hevc_vaapi there means it took. h264_vaapi means the client refused or was never asked.

FEC is back at the default 20% (16/09/2026), and the 30 is what a number looks like when it outlives its reason. It was set for the DIRECT path, measured at 1.67% loss with RTT jumping from 20 to 312 ms in a burst of 300 packets of 1 KB, and that path was retired on 19/08/2026. The tunnel that replaced it has now measured 0% loss twice: 40 packets on 19/08, and 100 packets on 16/09 at RTT 29.4/33.0/39.4 ms with mdev 1.8 ms. FEC recovers loss without retransmitting, which in real time would arrive late, but it is paid for in bitrate: against the 20 Mbps ceiling the 10 points returned are ~2 Mbps that carry picture instead of parity.

Caveat, and it is the same one the 30 carried: both measurements are ICMP, which switches and firewalls deprioritize, so they indicate a good path rather than proving what the video flow gets. If sessions start breaking up, this is the first number to put back, and the second is packet_size.

ping_timeout above the 10 s default tolerates a transient hole. It only helps when the HOST is the one giving up, and the logs cannot tell that from the client giving up. It is not free: a dead client's session holds hypridle paused for longer.

The apps are declared, and one factory app dropped every stream

Until 03/08/2026 this was NOT declared, so Sunshine created its own apps.json with the FACTORY apps. One of them killed the stream in 2.5 s:

"Low Res Desktop" with prep-cmd: xrandr --output HDMI-1 --mode 1920x1080

Two mistakes in one factory command: xrandr is X11 (here it is pure Wayland, with no Xwayland in the capture path) and HDMI-1 does not exist, since the real outputs are DP-2 and HDMI-A-3. A prep-cmd that FAILS makes Sunshine abort the session, so clicking that app was a guaranteed drop. Worse, the global guard's undo does not run on that abort, leaving hypridle stopped.

"Low Res Desktop" was NOT reimplemented with hyprctl output, on purpose: changing the video mode WITH the wlr capture active is the same class of risk as DPMS under capture, which caused a GPU engine reset. A lower resolution is asked for on the CLIENT, where Moonlight picks the mode and Sunshine scales, without touching the host's scanout.

The trade-off: declaring applications makes the module point file_apps at the store, so the web UI's Applications tab becomes READ ONLY. That is the price of being declarative and the right side of rule 14. The old ~/.config/sunshine/apps.json is now IGNORED; do not delete it by reflex on discovering it has no effect, it is leftover from the non-declarative period.

Steam runs detached, so Sunshine does not wait for it (the session would die with Steam), and the undo closes Big Picture on disconnect. setsid goes by absolute path (rule 7), but steam stays a NAME on purpose, because what resolves it is the programs.steam FHS wrapper on the session PATH, and a store path here would bypass that wrapper.

The HTTPS handler hang, which is invisible by definition

On 29/07 Sunshine ended up with 47984 (HTTPS) accepting TCP and NEVER completing the TLS handshake, with 22 connections stacked in CLOSE-WAIT, while 47989 (HTTP) answered 200 normally. Moonlight uses HTTPS on an already paired host, so it showed "offline".

The worst part: the service stayed active, with ExecMainStatus=0 and NOT ONE line of log. There was no way to notice; only a systemctl restart fixed it. It is the same shape as the nxBender hang in vpn.md.

Hence the active probe: the only way to detect it is to ATTEMPT the handshake. Three attempts in ~10 s before restarting, so a hang is not confused with the service starting up. It restarts even with an active stream, because a host with a hung HTTPS is already useless.

The timer runs every 2 min, which would make systemd log "Starting…/Finished…" 440 lines/day (measured), so LogLevelMax = warning cuts the info out. It needs SyslogLevel = warning beside it, which was missing until 19/08/2026: systemd logs a script's stdout AND stderr at INFO, so the filter alone also swallowed everything the script did not prefix by hand. That is why the 127 above logged no reason at all, seven times in a row. A <3> or <4> prefix carries its own level and was always visible, which is the asymmetry that hid the crash while showing the guard's deliberate warning.

The idle guard also has a 2 min grace between the do that stops hypridle and the bind of the video ports. In "Steam Big Picture" that window lasts the whole Steam launch, and turning hypridle back on inside it is the 03/08 remote lockout all over again.

The ghost session, which the handshake probe cannot see

19/08/2026, the second occurrence. A session ran from 08:15 to 08:24 and the client left with no clean teardown. Sunshine never closed the session: /serverinfo kept answering state=SUNSHINE_SERVER_BUSY with currentgame=958645192 while there were zero UDP sockets on 47998-48000 and no established TCP on 47984 or 48010. Moonlight reads that state and will not open a new session, so from the client the host is simply broken.

The 29/07 probe cannot detect this, by construction. The TLS handshake on 47984 completes perfectly the whole time, because the HTTPS handler is healthy; what is stuck is the SESSION behind it. Two different failures, the same remedy (restart), and detectors that share nothing. Measured on 19/08: sunshine-health ran every 2 min through the entire ghost and exited 0 every time, which is correct behaviour for what it was asked to check and useless for what was actually wrong.

The reaper's condition is SUNSHINE_SERVER_BUSY plus no socket, HELD for over 5 min (three probes at 2 min) before restarting. The hold is not caution for its own sake: between the app launching and the client binding video there is a window that lasts the whole Steam Big Picture launch, the same window the idle guard needs its 2 min grace for, and restarting inside it kills a session that was being born. Reaping a ghost 6 min late costs nothing, since nobody can connect either way.

A restart does not lose pairing. The paired clients live in ~/.config/sunshine, which is state and survives (verified on 19/08: fai pc was still there afterwards). That is what makes restart an acceptable remedy instead of a last resort.

It is a mop and not a fix. The bug is upstream, in Sunshine not closing a session whose client vanished, and ping_timeout does not cover it: that drops the STREAM, never the bookkeeping.