Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down
59 changes: 58 additions & 1 deletion src/MultiSeat.Service/Monitoring/HostApolloMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
/// Is this specific process feeding the GPU's video encoder right now?
///
/// Uses the same WMI GPU engine counters <see cref="GpuMonitor"/> reads, filtered to one PID
/// and to the video-encode engine. The instance name looks like
/// <c>pid_10988_luid_0x00000000_0x0001132D_phys_0_eng_6_engtype_videoencode</c>.
///
/// ⚠️ 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.
/// </summary>
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;
}

/// <summary>ApolloService state, or null when the service is not installed.</summary>
Expand Down
50 changes: 50 additions & 0 deletions src/MultiSeat.Service/Sessions/SeatManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ public sealed class SeatManager
private readonly HidHideConfigurator _hidHide;
private readonly OnConnectAppLauncher _onConnectApps;
private readonly Monitoring.ApolloServerQuery _serverQuery;

/// <summary>
/// Used only to warn when a seat teardown is about to disturb a standalone Apollo's stream.
/// See <see cref="WarnIfStandaloneApolloStreamingAsync"/> and issue #23.
/// </summary>
private readonly Monitoring.HostApolloMonitor _hostApollo;
private readonly IEnumerable<IEmulatorConfigSeeder> _emulatorSeeders;
private readonly SeatLifecycleGate _lifecycleGate;

Expand All @@ -71,6 +77,7 @@ public SeatManager(
HidHideConfigurator hidHide,
OnConnectAppLauncher onConnectApps,
Monitoring.ApolloServerQuery serverQuery,
Monitoring.HostApolloMonitor hostApollo,
IEnumerable<IEmulatorConfigSeeder> emulatorSeeders,
SeatLifecycleGate lifecycleGate)
{
Expand All @@ -91,6 +98,7 @@ public SeatManager(
_hidHide = hidHide;
_onConnectApps = onConnectApps;
_serverQuery = serverQuery;
_hostApollo = hostApollo;
_emulatorSeeders = emulatorSeeders;
_lifecycleGate = lifecycleGate;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -611,6 +624,43 @@ internal async Task TeardownSeatAsync(Guid seatId, TimeSpan gateTimeout, Cancell
_logger.LogInformation("Seat {Id}: torn down", seat.Id);
}

/// <summary>
/// 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.
/// </summary>
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);
}
}

/// <summary>
/// Teardown all seats — called on service shutdown.
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions src/MultiSeat.Tests/Sessions/SeatTeardownGuardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IEmulatorConfigSeeder>(),
lifecycleGate: gate);

Expand Down
Loading