From 9db935bc8702079c92850809359136797f2e45f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ryan4yin=20=7C=20=E4=BA=8C=E8=8A=B1?= Date: Tue, 1 Sep 2026 03:40:48 -0600 Subject: [PATCH] fix(sunshine): use Intel rendering and recover remote Niri sessions (#268) - render Niri on the Intel iGPU while retaining NVIDIA-connected physical outputs - encode Sunshine streams with Intel VAAPI and wait for a Niri output before startup - load VKMS persistently for headless sessions and configure `Virtual-1` at 2560x1600@60 - let Moonlight request the stream bitrate instead of enforcing a 20 Mbps host cap - remove unsupported Dynamic Boost and global NVIDIA session overrides - grant Sunshine access to virtual input devices - add a Nushell recovery helper that starts a temporary greetd/Niri session from SSH - identify and safely replace normal, standalone, or previous transient Niri sessions - launch the transient service in the background and verify greetd, Niri, and Sunshine before returning - ignore local `.worktrees` directories and keep eval regression coverage focused on structural behavior --- .gitignore | 1 + home/hosts/linux/12kingdoms-shoukei.nix | 1 - home/hosts/linux/idols-ai.nix | 1 - home/linux/gui/base/nvidia.nix | 26 --- hosts/idols-ai/hardware-intel.nix | 13 +- hosts/idols-ai/hardware-nvidia.nix | 25 ++- hosts/idols-ai/niri-hardware.kdl | 16 ++ .../desktop/networking/remote-desktop.nix | 10 +- .../desktop/networking/sunshine/README.md | 34 ++++ .../desktop/networking/sunshine/default.nix | 1 + .../networking/sunshine/remote-session.nu | 151 ++++++++++++++++++ .../tests/idols-ai-gpu/expected.nix | 8 + .../x86_64-linux/tests/idols-ai-gpu/expr.nix | 17 ++ 13 files changed, 266 insertions(+), 38 deletions(-) delete mode 100644 home/linux/gui/base/nvidia.nix create mode 100644 modules/nixos/desktop/networking/sunshine/README.md create mode 100644 modules/nixos/desktop/networking/sunshine/default.nix create mode 100755 modules/nixos/desktop/networking/sunshine/remote-session.nu create mode 100644 outputs/x86_64-linux/tests/idols-ai-gpu/expected.nix create mode 100644 outputs/x86_64-linux/tests/idols-ai-gpu/expr.nix diff --git a/.gitignore b/.gitignore index f19ca252..b5e95bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ result result/ .direnv/ +.worktrees/ .DS_Store .pre-commit-config.yaml logs/ diff --git a/home/hosts/linux/12kingdoms-shoukei.nix b/home/hosts/linux/12kingdoms-shoukei.nix index 4cf470e3..06fcf88d 100644 --- a/home/hosts/linux/12kingdoms-shoukei.nix +++ b/home/hosts/linux/12kingdoms-shoukei.nix @@ -13,7 +13,6 @@ in modules.desktop.gaming.enable = false; modules.desktop.niri.enable = true; - modules.desktop.nvidia.enable = false; xdg.configFile."niri/niri-hardware.kdl".source = mkSymlink "${config.home.homeDirectory}/nix-config/hosts/12kingdoms-shoukei/niri-hardware.kdl"; diff --git a/home/hosts/linux/idols-ai.nix b/home/hosts/linux/idols-ai.nix index 1f13577a..35bbbb75 100644 --- a/home/hosts/linux/idols-ai.nix +++ b/home/hosts/linux/idols-ai.nix @@ -9,7 +9,6 @@ in modules.desktop.gaming.enable = true; modules.desktop.niri.enable = true; - modules.desktop.nvidia.enable = true; programs.zed-editor.userSettings = { ui_font_size = 18.0; diff --git a/home/linux/gui/base/nvidia.nix b/home/linux/gui/base/nvidia.nix deleted file mode 100644 index c71408a4..00000000 --- a/home/linux/gui/base/nvidia.nix +++ /dev/null @@ -1,26 +0,0 @@ -{ - config, - lib, - ... -}: -with lib; -let - cfg = config.modules.desktop.nvidia; -in -{ - options.modules.desktop.nvidia = { - enable = mkEnableOption "whether nvidia GPU is used"; - }; - - config = mkIf (cfg.enable && cfg.enable) { - home.sessionVariables = { - # for hyprland with nvidia gpu" = " ref https://wiki.hyprland.org/Nvidia/ - "LIBVA_DRIVER_NAME" = "nvidia"; - "__GLX_VENDOR_LIBRARY_NAME" = "nvidia"; - # VA-API hardware video acceleration - "NVD_BACKEND" = "direct"; - - "GBM_BACKEND" = "nvidia-drm"; - }; - }; -} diff --git a/hosts/idols-ai/hardware-intel.nix b/hosts/idols-ai/hardware-intel.nix index 347ecc3f..9dae829c 100644 --- a/hosts/idols-ai/hardware-intel.nix +++ b/hosts/idols-ai/hardware-intel.nix @@ -14,8 +14,17 @@ # kvm virtualization support boot.initrd.kernelModules = [ ]; - boot.kernelModules = [ "kvm-intel" ]; - boot.extraModprobeConfig = "options kvm_intel nested=1"; + boot.kernelModules = [ + "kvm-intel" + # Provide a connected DRM output when the NVIDIA-attached monitors are off. + # Niri exposes it as Virtual-1 for headless Sunshine sessions. + "vkms" + ]; + boot.extraModprobeConfig = '' + options kvm_intel nested=1 + # Create Virtual-1 as soon as vkms loads, including during normal physical sessions. + options vkms create_default_dev=1 + ''; boot.extraModulePackages = [ ]; # Intel Graphics diff --git a/hosts/idols-ai/hardware-nvidia.nix b/hosts/idols-ai/hardware-nvidia.nix index bd5e8448..08e13ef8 100644 --- a/hosts/idols-ai/hardware-nvidia.nix +++ b/hosts/idols-ai/hardware-nvidia.nix @@ -1,6 +1,5 @@ { config, - lib, pkgs, ... }: @@ -42,8 +41,6 @@ # required by most wayland compositors! modesetting.enable = true; powerManagement.enable = true; - - dynamicBoost.enable = lib.mkForce true; }; hardware.nvidia-container-toolkit.enable = true; @@ -54,9 +51,23 @@ }; services.sunshine.settings = { - max_bitrate = 20000; # in Kbps - # NVIDIA NVENC Encoder - nvenc_preset = 3; # 1(fastest + worst quality) - 7(slowest + best quality) - nvenc_twopass = "full_res"; # quarter_res / full_res. + adapter_name = "/dev/dri/by-path/pci-0000:00:02.0-render"; # Intel iGPU + encoder = "vaapi"; + }; + + systemd.user.services.sunshine = { + after = [ "niri.service" ]; + environment.LIBVA_DRIVER_NAME = "iHD"; + preStart = '' + for _ in {1..20}; do + if ${config.programs.niri.package}/bin/niri msg outputs | ${pkgs.gnugrep}/bin/grep -q '^Output '; then + exit 0 + fi + sleep 0.5 + done + + echo "Sunshine requires at least one Niri output" >&2 + exit 1 + ''; }; } diff --git a/hosts/idols-ai/niri-hardware.kdl b/hosts/idols-ai/niri-hardware.kdl index e5d4b019..849678ea 100644 --- a/hosts/idols-ai/niri-hardware.kdl +++ b/hosts/idols-ai/niri-hardware.kdl @@ -1,4 +1,10 @@ // running `niri msg outputs` to find outputs +debug { + // Keep desktop rendering and ordinary applications on the Intel iGPU. + // Physical outputs remain on the NVIDIA GPU and use cross-GPU scanout. + render-drm-device "/dev/dri/by-path/pci-0000:00:02.0-render" +} + output "DP-1" { // Uncomment this line to disable this output. // off @@ -37,6 +43,16 @@ output "HDMI-A-2" { position x=2560 y=0 // on the right of DP-2 } +output "Virtual-1" { + // Created by vkms in hardware-intel.nix. Sunshine captures this output when + // the NVIDIA-attached physical monitors are off. It remains available in + // normal physical sessions, with minor idle GPU and framebuffer overhead. + scale 1 + transform "normal" + mode "2560x1600@60" + position x=0 y=0 +} + // ============= Named Workspaces ============= workspace "4entertainment" { open-on-output "DP-1"; } workspace "2browser" { open-on-output "DP-1"; } diff --git a/modules/nixos/desktop/networking/remote-desktop.nix b/modules/nixos/desktop/networking/remote-desktop.nix index b104d0cc..60581c5a 100644 --- a/modules/nixos/desktop/networking/remote-desktop.nix +++ b/modules/nixos/desktop/networking/remote-desktop.nix @@ -1,4 +1,10 @@ -{ lib, pkgs, ... }: +{ + config, + lib, + myvars, + pkgs, + ... +}: { environment.systemPackages = with pkgs; [ moonlight-qt # moonlight client, for streaming games/desktop from a PC @@ -39,4 +45,6 @@ wan_encryption_mode = 2; }; }; + + users.users."${myvars.username}".extraGroups = lib.mkIf config.services.sunshine.enable [ "input" ]; } diff --git a/modules/nixos/desktop/networking/sunshine/README.md b/modules/nixos/desktop/networking/sunshine/README.md new file mode 100644 index 00000000..2d4f0730 --- /dev/null +++ b/modules/nixos/desktop/networking/sunshine/README.md @@ -0,0 +1,34 @@ +# Temporary remote session recovery + +When a remote-only machine is stuck at the `greetd` TUI, run the Nushell helper from an +SSH session to start one complete PAM/logind Wayland session without changing +the persistent NixOS configuration: + +```bash +sudo nu ./remote-session.nu +``` + +The helper validates its dependencies before changing the current session. It +then stops the normal `greetd` service, starts a transient systemd-managed +`greetd` configuration on `/dev/tty1`, and runs Niri directly. It returns after +greetd, Niri, and Sunshine are ready. Tailscale is not affected. + +The `Virtual-1` output is created persistently by the `vkms` configuration in +`hosts/idols-ai/hardware-intel.nix` and configured by +`hosts/idols-ai/niri-hardware.kdl`. It gives Niri and Sunshine a capture target +when both physical monitors attached to the NVIDIA GPU are off. The output also +exists during normal physical sessions; keeping an idle virtual framebuffer has +minor GPU and memory overhead. + +Options: + +```text +-u USER session user (defaults to SUDO_USER) +-t TTY tty device, for example /dev/tty2 +-c COMMAND Wayland session command (defaults to niri --session) +-f, --force replace an existing Niri session without confirmation +--check validate prerequisites without changing service state +``` + +A reboot or a manual restart of `greetd.service` restores the normal TUI +configuration. diff --git a/modules/nixos/desktop/networking/sunshine/default.nix b/modules/nixos/desktop/networking/sunshine/default.nix new file mode 100644 index 00000000..ffcd4415 --- /dev/null +++ b/modules/nixos/desktop/networking/sunshine/default.nix @@ -0,0 +1 @@ +{ } diff --git a/modules/nixos/desktop/networking/sunshine/remote-session.nu b/modules/nixos/desktop/networking/sunshine/remote-session.nu new file mode 100755 index 00000000..2b1e4669 --- /dev/null +++ b/modules/nixos/desktop/networking/sunshine/remote-session.nu @@ -0,0 +1,151 @@ +#!/usr/bin/env nu + +def user-machine [user: string] { + $'($user)@.host' +} + +def niri-source [user: string] { + let process = (^pgrep -o -u $user -x niri | complete) + if $process.exit_code != 0 { return 'none' } + + let pid = ($process.stdout | str trim) + let cgroup = (open --raw $'/proc/($pid)/cgroup') + if ($cgroup | str contains '/greetd-sunshine.service/') { return 'remote session' } + if ($cgroup | str contains '/niri.service') { return 'user niri.service' } + 'standalone process' +} + +def stop-niri [user: string, machine: string, source: string] { + match $source { + 'remote session' => { stop-transient-session } + 'user niri.service' => { ^systemctl --machine $machine --user stop niri.service } + 'standalone process' => { ^pkill -TERM -u $user -x niri } + } +} + +def stop-transient-session [] { + let loaded = ((^systemctl show greetd-sunshine.service -p LoadState --value | complete).stdout | str trim) + if $loaded == 'not-found' or $loaded == '' { return } + + ^systemctl stop greetd-sunshine.service + let _ = (^systemctl reset-failed greetd-sunshine.service | complete) + for _ in 1..50 { + let state = ((^systemctl show greetd-sunshine.service -p LoadState --value | complete).stdout | str trim) + if $state == 'not-found' or $state == '' { return } + sleep 100ms + } + error make { msg: 'Previous greetd-sunshine.service was stopped but not unloaded' } +} + +def confirm-replacement [source: string] { + (input $'Niri is already running via ($source). Terminate it and start a remote session? [y/N] ' + | str lowercase) == 'y' +} + +def preflight [tty: string, command: string] { + let command_binary = ($command | split row ' ' | first) + if not ($command_binary | path exists) { + error make { msg: $'Wayland session command not found: ($command_binary)' } + } + if not ($tty | path exists) { + error make { msg: $'TTY not found: ($tty)' } + } + + let exec_start = (^systemctl show greetd.service -p ExecStart --value) + let greetd = ($exec_start + | parse --regex 'path=(?[^ ;]+)' + | get path + | first) + let system_config = ($exec_start + | parse --regex '--config (?[^ ;]+)' + | get path + | first) + let default_session = (open $system_config | get default_session) + { greetd: $greetd, default_session: $default_session } +} + +def write-greetd-config [path: string, user: string, tty: string, command: string, default_session: record] { + { + general: { runfile: $'($path).run' } + initial_session: { command: $command, user: $user } + default_session: $default_session + terminal: { vt: ($tty | path basename | str replace 'tty' '') } + } | to toml | save --force $path + chmod 600 $path +} + +def wait-ready [user: string, machine: string] { + for _ in 1..30 { + let greetd = ((^systemctl is-active greetd-sunshine.service | complete).stdout | str trim) == 'active' + let niri = ((^pgrep -u $user -x niri | complete).exit_code) == 0 + let sunshine = ((^systemctl --machine $machine --user is-active sunshine.service | complete).stdout | str trim) == 'active' + if $greetd and $niri and $sunshine { return } + sleep 1sec + } + error make { msg: 'Remote session did not become ready within 30 seconds; inspect journalctl -u greetd-sunshine.service' } +} + +def wait-niri [user: string] { + for _ in 1..30 { + let greetd = ((^systemctl is-active greetd-sunshine.service | complete).stdout | str trim) == 'active' + let niri = ((^pgrep -u $user -x niri | complete).exit_code) == 0 + if $greetd and $niri { return } + sleep 1sec + } + error make { msg: 'Niri did not become ready within 30 seconds; inspect journalctl -u greetd-sunshine.service' } +} + +def start-sunshine [machine: string] { + ^systemctl --machine $machine --user reset-failed sunshine.service + ^systemctl --machine $machine --user start sunshine.service +} + +def main [ + --user (-u): string = 'ryan' + --tty (-t): string = '/dev/tty1' + --command (-c): string = '' + --force (-f) + --check +] { + let session_user = ($env.SUDO_USER? | default $user) + let user_machine = (user-machine $session_user) + # niri-session starts niri.service through the user manager. Run the + # compositor directly so greetd owns this temporary session. + let command = if $command == '' { '/run/current-system/sw/bin/niri --session' } else { $command } + let runtime = (preflight $tty $command) + if $check { + print 'Remote session prerequisites are valid.' + return + } + + let source = (niri-source $session_user) + if $source != 'none' { + if not $force and not (confirm-replacement $source) { + print 'Aborted.' + return + } + ^systemctl --machine $user_machine --user stop sunshine.service + stop-niri $session_user $user_machine $source + } + # A previous helper may have exited after Niri but left its greeter running. + stop-transient-session + + let config = (mktemp --tmpdir-path /run greetd-sunshine.XXXXXX.toml) + write-greetd-config $config $session_user $tty $command $runtime.default_session + systemctl stop greetd.service + (^systemd-run + --unit greetd-sunshine + --collect + --property $'TTYPath=($tty)' + --property StandardInput=tty + --property StandardOutput=tty + --property StandardError=journal + -- + $runtime.greetd + --config $config) + + wait-niri $session_user + start-sunshine $user_machine + wait-ready $session_user $user_machine + print 'Remote Niri and Sunshine session is ready.' +} diff --git a/outputs/x86_64-linux/tests/idols-ai-gpu/expected.nix b/outputs/x86_64-linux/tests/idols-ai-gpu/expected.nix new file mode 100644 index 00000000..2a00a1c4 --- /dev/null +++ b/outputs/x86_64-linux/tests/idols-ai-gpu/expected.nix @@ -0,0 +1,8 @@ +{ + loadsVirtualDisplay = true; + createsVirtualDisplay = true; + niriUsesIntelRenderer = true; + sunshineUserHasInputAccess = true; + sunshineStartsAfterNiri = true; + sunshineWaitsForNiriOutput = true; +} diff --git a/outputs/x86_64-linux/tests/idols-ai-gpu/expr.nix b/outputs/x86_64-linux/tests/idols-ai-gpu/expr.nix new file mode 100644 index 00000000..ebe0f64c --- /dev/null +++ b/outputs/x86_64-linux/tests/idols-ai-gpu/expr.nix @@ -0,0 +1,17 @@ +{ + lib, + outputs, + ... +}: +let + cfg = outputs.nixosConfigurations.ai-niri.config; + niriHardware = builtins.readFile ../../../../hosts/idols-ai/niri-hardware.kdl; +in +{ + loadsVirtualDisplay = builtins.elem "vkms" cfg.boot.kernelModules; + createsVirtualDisplay = lib.hasInfix "options vkms create_default_dev=1" cfg.boot.extraModprobeConfig; + niriUsesIntelRenderer = lib.hasInfix "/dev/dri/by-path/pci-0000:00:02.0-render" niriHardware; + sunshineUserHasInputAccess = builtins.elem "input" cfg.users.users.ryan.extraGroups; + sunshineStartsAfterNiri = builtins.elem "niri.service" cfg.systemd.user.services.sunshine.after; + sunshineWaitsForNiriOutput = lib.hasInfix "niri msg outputs" cfg.systemd.user.services.sunshine.preStart; +}