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
2 changes: 1 addition & 1 deletion docs/steam-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,6 @@ To go on, close Steam in every seat that runs it, then run the installer again.

MultiSeat.SteamLauncher.exe --steam "<path to steam.exe>" --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.
2 changes: 2 additions & 0 deletions scripts/lib/install-lib.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
36 changes: 33 additions & 3 deletions src/MultiSeat.Service/Api/WebSocketHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IHostApplicationLifetime>().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.
}
}
});
}
Expand Down
57 changes: 57 additions & 0 deletions src/MultiSeat.Tests/Api/WebSocketShutdownTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<IServer>().Features.Get<IServerAddressesFeature>()!.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();
}
}
Loading