diff --git a/CLAUDE.md b/CLAUDE.md index 650956d..c9470dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -342,6 +342,23 @@ MultiSeat is self-contained and **non-destructive**: it works out of the box whe 2. **Own port range.** Default `PortBase = 48100`, above a stock Apollo's block — no runtime port conflict. 3. **Never kills a non-MultiSeat Apollo.** On startup `MultiSeatWorker.KillOrphanedApolloProcesses` reaps **only** Apollo processes MultiSeat launched, identified via WMI (`GetManagedApolloPids`) by executable path (under the ApolloVibe dir) or a MultiSeat per-seat config path on the command line. It no longer stops/disables `ApolloService`, and `install-service.ps1` leaves that service alone. (WMI failure → empty set → cleanup is skipped rather than risk killing an unrelated Apollo.) +⚠️ **"Non-destructive" has one exception, and it is not fixable here: tearing a seat down stalls a +standalone Apollo's stream for about 690 ms** while it rebuilds its encoder. Self-recovering, and +it does not compound with seat count — seat-to-seat interference was measured and does not exist. + +⛔ **The cause is NOT a second Apollo starting or stopping**, which is the intuitive guess and was +this issue's original premise. Decomposed step by step (#23): stopping the seat's Apollo produced +*no reaction at all*; killing mstsc and logging the session off each produced the rebuild. **A seat +IS an RDP session, and that session's display appearing and disappearing is a desktop topology +change** — which invalidates any DXGI duplication on the host, including one MultiSeat has nothing +to do with. It connects directly to #15: because seats stream the RDP surface rather than a virtual +display, seat churn *is* display churn. + +There is no VDD detach to defer or reorder, so the stall stays. What `SeatManager` does now is +**say so**: `WarnIfStandaloneApolloStreamingAsync` logs a warning before teardown when the +standalone Apollo reports it is streaming. ⚠️ It never blocks or fails a teardown — a seat that +would not tear down because a status query timed out is a far worse bug than the one it reports. + ## Shared game library & emulator netplay Because each seat is its own Windows account, games/ROMs would otherwise be siloed per account and seats couldn't easily netplay. Two provisioning helpers address this (config in `MultiSeatOptions` / `appsettings.json`): diff --git a/src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs b/src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs index c1ca1ad..7c6e4e2 100644 --- a/src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs +++ b/src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs @@ -195,7 +195,64 @@ private async Task QueryServerInfoAsync(HostApolloInfo info, int port, Cancellat info.Reachable = true; info.HostName = server.HostName; info.AppVersion = server.AppVersion; - info.Streaming = server.Streaming; + + // ⛔ serverinfo ALONE gets this wrong, and wrong in the common direction. + // + // Its `state` and `currentgame` describe a launched APPLICATION, not a client session. + // Someone streaming the plain desktop — which is what a console Apollo is usually for — + // leaves currentgame at 0 and state at SUNSHINE_SERVER_FREE for the whole session. + // + // Measured 2026-09-11: the standalone Apollo encoding steadily at ~15.8% for 26 minutes, + // with an encoder created in its log and never torn down, still answered + // `state = SUNSHINE_SERVER_FREE, currentgame = 0`. Anything built on this flag alone is + // silently dead for desktop streaming. + // + // Per-process GPU video encode is the signal that actually tracks a client, and is the + // rule this project already follows operationally. serverinfo is kept as an OR because it + // still catches a launched game whose client is momentarily not encoding. + info.Streaming = IsProcessVideoEncoding(info.ProcessId) || server.Streaming; + } + + /// + /// Is this specific process feeding the GPU's video encoder right now? + /// + /// Uses the same WMI GPU engine counters reads, filtered to one PID + /// and to the video-encode engine. The instance name looks like + /// pid_10988_luid_0x00000000_0x0001132D_phys_0_eng_6_engtype_videoencode. + /// + /// ⚠️ Per PROCESS, never the GPU total. RustDesk and any other remote-desktop tool encode too, + /// so a machine-wide reading says "something is encoding", which is not the question. + /// + private bool IsProcessVideoEncoding(int pid) + { + if (pid <= 0) return false; + + try + { + using var searcher = new System.Management.ManagementObjectSearcher( + "SELECT Name, UtilizationPercentage FROM " + + "Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine"); + + var marker = $"pid_{pid}_"; + foreach (var obj in searcher.Get()) + { + var name = obj["Name"]?.ToString() ?? string.Empty; + if (!name.StartsWith(marker, StringComparison.OrdinalIgnoreCase)) continue; + if (!name.Contains("engtype_videoencode", StringComparison.OrdinalIgnoreCase)) continue; + + // A live stream sits well above this; an idle process reads 0. The threshold only + // has to separate "encoding" from "not", not measure anything. + if (Convert.ToDouble(obj["UtilizationPercentage"] ?? 0) > 1.0) return true; + } + } + catch (Exception ex) + { + // Counters unavailable — report not-encoding rather than guessing. Callers treat this + // as advisory, so a false negative costs a warning, not correctness. + _logger.LogDebug(ex, "Could not read GPU encode counters for PID {Pid}", pid); + } + + return false; } /// ApolloService state, or null when the service is not installed. diff --git a/src/MultiSeat.Service/Sessions/SeatManager.cs b/src/MultiSeat.Service/Sessions/SeatManager.cs index f638b3b..2c74b13 100644 --- a/src/MultiSeat.Service/Sessions/SeatManager.cs +++ b/src/MultiSeat.Service/Sessions/SeatManager.cs @@ -50,6 +50,12 @@ public sealed class SeatManager private readonly HidHideConfigurator _hidHide; private readonly OnConnectAppLauncher _onConnectApps; private readonly Monitoring.ApolloServerQuery _serverQuery; + + /// + /// Used only to warn when a seat teardown is about to disturb a standalone Apollo's stream. + /// See and issue #23. + /// + private readonly Monitoring.HostApolloMonitor _hostApollo; private readonly IEnumerable _emulatorSeeders; private readonly SeatLifecycleGate _lifecycleGate; @@ -71,6 +77,7 @@ public SeatManager( HidHideConfigurator hidHide, OnConnectAppLauncher onConnectApps, Monitoring.ApolloServerQuery serverQuery, + Monitoring.HostApolloMonitor hostApollo, IEnumerable emulatorSeeders, SeatLifecycleGate lifecycleGate) { @@ -91,6 +98,7 @@ public SeatManager( _hidHide = hidHide; _onConnectApps = onConnectApps; _serverQuery = serverQuery; + _hostApollo = hostApollo; _emulatorSeeders = emulatorSeeders; _lifecycleGate = lifecycleGate; } @@ -583,6 +591,11 @@ internal async Task TeardownSeatAsync(Guid seatId, TimeSpan gateTimeout, Cancell if (GetSeat(seatId) is null) return; + // Say so BEFORE the stall happens, not after, and before the gate is taken — this is + // advisory, and holding the lifecycle gate across a network query would slow every + // teardown to buy nothing. + await WarnIfStandaloneApolloStreamingAsync(seatId, ct); + // ⛔ Acquire the gate BEFORE removing the seat from _seats, not after. // // The reverse order looks harmless and is not: AcquireAsync throws TimeoutException after @@ -611,6 +624,43 @@ internal async Task TeardownSeatAsync(Guid seatId, TimeSpan gateTimeout, Cancell _logger.LogInformation("Seat {Id}: torn down", seat.Id); } + /// + /// Warn when tearing this seat down is about to interrupt someone else's stream. + /// + /// A seat IS an RDP session, and that session's display appearing and disappearing is a + /// desktop topology change. Any Apollo on the host sees it and rebuilds its capture pipeline — + /// measured at roughly 690 ms, self-recovering, on the standalone console Apollo (#23). + /// + /// ⛔ This does NOT prevent the stall, and is not trying to. The trigger is the RDP session + /// ending, which is exactly what tearing a seat down means; it was decomposed and the seat's + /// own Apollo stopping turned out not to be the cause at all. What was wrong was that it + /// happened INVISIBLY — the operator interrupted someone and had no way to know. + /// + /// ⚠️ Never blocks or fails the teardown. A seat that will not tear down because a status + /// query timed out would be a far worse bug than the one this reports. + /// + private async Task WarnIfStandaloneApolloStreamingAsync(Guid seatId, CancellationToken ct) + { + try + { + var host = await _hostApollo.CollectAsync(ct); + if (host.Detected && host.Streaming) + { + _logger.LogWarning( + "Seat {Id}: tearing down while the standalone Apollo (PID {Pid}, {Name}) is " + + "streaming. Ending this seat's RDP session changes the desktop topology, so " + + "that stream will stall for about a second while its encoder rebuilds. It " + + "recovers on its own. See issue #23.", + seatId, host.ProcessId, host.HostName ?? "unnamed"); + } + } + catch (Exception ex) + { + // Advisory only. Losing the warning is acceptable; losing the teardown is not. + _logger.LogDebug(ex, "Seat {Id}: could not check the standalone Apollo before teardown", seatId); + } + } + /// /// Teardown all seats — called on service shutdown. /// diff --git a/src/MultiSeat.Tests/Sessions/SeatTeardownGuardTests.cs b/src/MultiSeat.Tests/Sessions/SeatTeardownGuardTests.cs index 76f9263..18f0ff6 100644 --- a/src/MultiSeat.Tests/Sessions/SeatTeardownGuardTests.cs +++ b/src/MultiSeat.Tests/Sessions/SeatTeardownGuardTests.cs @@ -114,6 +114,10 @@ public void ResolveLiveSeat_AcceptsEveryNonTeardownStatus(SeatStatus status) apolloManager: null!, configBuilder: null!, portAllocator: null!, firewall: null!, audioRouter: null!, controllerManager: null!, inputRouter: null!, inputHookManager: null!, hidHide: null!, onConnectApps: null!, serverQuery: null!, + // Null on purpose. Teardown consults this only to warn about disturbing a standalone + // Apollo's stream, and that check must never fail a teardown — so the timeout path below + // exercises exactly that: the warning blows up internally and teardown proceeds regardless. + hostApollo: null!, emulatorSeeders: Array.Empty(), lifecycleGate: gate);