diff --git a/docs/steam-isolation.md b/docs/steam-isolation.md index fe1f722..5edb270 100644 --- a/docs/steam-isolation.md +++ b/docs/steam-isolation.md @@ -107,6 +107,6 @@ To go on, close Steam in every seat that runs it, then run the installer again. MultiSeat.SteamLauncher.exe --steam "" --forward -- -shutdown -A plain `steam -shutdown` does not reach an isolated Steam. The installer never closes Steam for you. +A plain `steam -shutdown` does not reach an isolated Steam. The `-shutdown` command only hands the request to Steam and exits 0 right away. Steam itself does not exit while a game is running in that seat, so close the game first. The installer never closes Steam for you. `-Uninstall` does not need the check. It does not delete the install folder; it only removes the service and leaves the folder for you to delete. diff --git a/scripts/lib/install-lib.ps1 b/scripts/lib/install-lib.ps1 index a602889..781e91c 100644 --- a/scripts/lib/install-lib.ps1 +++ b/scripts/lib/install-lib.ps1 @@ -708,6 +708,8 @@ function Format-SteamInUseMessage { $lines += 'or run this as the Windows user that started that Steam:' $lines += " `"$launcher`" --steam `"$steamExe`" --forward -- -shutdown" $lines += 'A plain "steam -shutdown" does not reach an isolated Steam, so do not use it.' + $lines += 'The -shutdown command only hands the request to Steam and exits 0 right away.' + $lines += 'Steam itself does not exit while a game is running in that seat, so close the game first.' $lines += 'This installer does not close Steam for you.' return ($lines -join "`n") } diff --git a/src/MultiSeat.Service/Api/WebSocketHub.cs b/src/MultiSeat.Service/Api/WebSocketHub.cs index f01735a..056df45 100644 --- a/src/MultiSeat.Service/Api/WebSocketHub.cs +++ b/src/MultiSeat.Service/Api/WebSocketHub.cs @@ -45,22 +45,52 @@ public static void Map(WebApplication app) var clientId = Guid.NewGuid().ToString("N"); _clients.TryAdd(clientId, new Client(ws)); + // A receive that ignores shutdown holds Kestrel's graceful stop open until its + // timeout, so the read loop watches ApplicationStopping as well as the request. + var stopping = context.RequestServices.GetRequiredService().ApplicationStopping; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, stopping); + var shuttingDown = false; + try { // Keep connection alive — read loop (we only push, but must drain client frames) var buffer = new byte[256]; while (ws.State == WebSocketState.Open) { - var result = await ws.ReceiveAsync(buffer, CancellationToken.None); + var result = await ws.ReceiveAsync(buffer, cts.Token); if (result.MessageType == WebSocketMessageType.Close) break; } } + catch (OperationCanceledException) + { + shuttingDown = true; + } + catch (WebSocketException) + { + // The client vanished without a close handshake; nothing left to close. + } finally { _clients.TryRemove(clientId, out _); - if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived) - await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); + try + { + using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + if (shuttingDown && ws.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + // Output only: do not wait for a client that may never answer. + await ws.CloseOutputAsync(WebSocketCloseStatus.EndpointUnavailable, + "server stopping", closeTimeout.Token); + } + else if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", closeTimeout.Token); + } + } + catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) + { + // Best effort: the socket is already gone or the peer is not answering. + } } }); } diff --git a/src/MultiSeat.Tests/Api/WebSocketShutdownTests.cs b/src/MultiSeat.Tests/Api/WebSocketShutdownTests.cs new file mode 100644 index 0000000..aabec77 --- /dev/null +++ b/src/MultiSeat.Tests/Api/WebSocketShutdownTests.cs @@ -0,0 +1,57 @@ +using System.Diagnostics; +using System.Net.WebSockets; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MultiSeat.Service.Api; +using Xunit; + +namespace MultiSeat.Tests.Api; + +/// +/// Stopping the API must not wait on an open dashboard socket. Kestrel's graceful stop waits for +/// in-flight requests, and a /ws/seats receive that ignores shutdown is one that never ends, so +/// the service stop sat silent for ~28 s after "Input router stopped" (2026-10-21 event log). +/// ApiServer.Build needs the whole host, so this runs the real auth middleware and the real +/// WebSocketHub route on a loopback port, the same way UpdateEndpointsTests does. +/// +public class WebSocketShutdownTests +{ + [Fact] + public async Task StopAsync_WithAnOpenDashboardSocket_CompletesPromptly() + { + const string key = "test-key"; + var builder = WebApplication.CreateBuilder(); + builder.Logging.ClearProviders(); + var app = builder.Build(); + app.Urls.Add("http://127.0.0.1:0"); + app.UseWebSockets(); + ApiServer.UseApiKeyAuth(app, new ApiAuthState(true, key)); + WebSocketHub.Map(app); + await app.StartAsync(); + + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.First(); + var wsUri = new Uri(address.Replace("http://", "ws://") + "/ws/seats?key=" + key); + + using var client = new ClientWebSocket(); + using var connectTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await client.ConnectAsync(wsUri, connectTimeout.Token); + Assert.Equal(WebSocketState.Open, client.State); + + // The client never closes and never reads: an idle dashboard tab. + var sw = Stopwatch.StartNew(); + var stop = app.StopAsync(); + var finished = await Task.WhenAny(stop, Task.Delay(TimeSpan.FromSeconds(60))); + sw.Stop(); + + Assert.True(ReferenceEquals(finished, stop), + "ApiServer StopAsync was still waiting after 60 s with one open /ws/seats socket."); + await stop; + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), + $"StopAsync took {sw.Elapsed.TotalSeconds:F1} s with one open /ws/seats socket; expected under 5 s."); + + await app.DisposeAsync(); + } +}