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
4 changes: 2 additions & 2 deletions src/MultiSeat.Service/Accounts/AccountManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public bool AccountExists(string username) =>
public AccountInfo CreateAccount(string username, string? password = null)
{
if (_managedAccounts.ContainsKey(username))
throw new InvalidOperationException($"Account '{username}' already exists.");
throw new ResourceConflictException($"Account '{username}' already exists.");

// Generate a strong random password if not provided
password ??= GeneratePassword();
Expand All @@ -127,7 +127,7 @@ public AccountInfo CreateAccount(string username, string? password = null)
var result = NetApi.NetUserAdd(null, 1, ref userInfo, out var paramErr);

if (result == NetApi.NERR_UserExists)
throw new InvalidOperationException($"Windows account '{username}' already exists.");
throw new ResourceConflictException($"Windows account '{username}' already exists.");

if (result != NetApi.NERR_Success)
throw new InvalidOperationException(
Expand Down
6 changes: 3 additions & 3 deletions src/MultiSeat.Service/Api/AccountEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand All @@ -38,7 +38,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand All @@ -53,7 +53,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});
}
Expand Down
22 changes: 12 additions & 10 deletions src/MultiSeat.Service/Api/SeatEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand All @@ -50,7 +50,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand Down Expand Up @@ -84,7 +84,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand All @@ -100,7 +100,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand All @@ -116,7 +116,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand All @@ -131,7 +131,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand All @@ -147,7 +147,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand All @@ -162,7 +162,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand Down Expand Up @@ -255,11 +255,13 @@ public static void Map(WebApplication app)
}
catch (ArgumentException ex)
{
// An unusable resolution genuinely IS a malformed request, so 400 is right
// here and this one deliberately does not go through ApiErrors.
return Results.BadRequest(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});

Expand Down Expand Up @@ -344,7 +346,7 @@ public static void Map(WebApplication app)
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
return ApiErrors.ToResult(ex);
}
});
}
Expand Down
75 changes: 75 additions & 0 deletions src/MultiSeat.Service/HttpStatusExceptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
namespace MultiSeat.Service;

/// <summary>
/// A request cannot currently be satisfied because server capacity is exhausted — the seat limit
/// is reached, or no port block is free. The API maps this to <b>503 Service Unavailable</b>: the
/// condition is temporary and the same request may succeed later, unlike a conflict with a
/// specific existing resource.
///
/// ⭐ All three types here derive from <see cref="InvalidOperationException"/> deliberately. Every
/// existing non-HTTP caller — worker autostart, the smoke script, tooling — already catches that,
/// and keeps catching these unchanged. The HTTP layer gets more precision without anything else
/// having to learn about it.
/// </summary>
internal sealed class CapacityExhaustedException : InvalidOperationException
{
public CapacityExhaustedException(string message) : base(message) { }
}

/// <summary>
/// A request conflicts with current server state — the account already has a live seat, the
/// Windows account already exists, or the seat is in a status that forbids the operation. The API
/// maps this to <b>409 Conflict</b>: repeating the identical request cannot succeed until
/// something changes.
///
/// ⚠️ Distinct from <see cref="CapacityExhaustedException"/> on purpose. Both used to surface as
/// 400, which told a caller "your request was malformed" when the request was fine and the server
/// was full — and gave no way to tell a retryable condition from a permanent one.
/// </summary>
internal sealed class ResourceConflictException : InvalidOperationException
{
public ResourceConflictException(string message) : base(message) { }
}

/// <summary>
/// The seat named by the request does not exist, or stopped existing while the operation waited
/// for the per-seat lifecycle gate. The API maps this to <b>404 Not Found</b>.
///
/// ⚠️ The second case is the one worth knowing about. After the PR C guards, a lifecycle
/// operation can be admitted through the gate only to find the seat torn down while it waited.
/// That is not a bad request and never was — the seat was real when the caller asked.
/// </summary>
internal sealed class SeatNotFoundException : InvalidOperationException
{
public SeatNotFoundException(string message = "Seat not found.") : base(message) { }
}

/// <summary>
/// Maps the exception types above onto HTTP results, so every endpoint answers the same way and a
/// new endpoint cannot quietly disagree with the others.
/// </summary>
internal static class ApiErrors
{
/// <summary>
/// ⚠️ Order matters: all three types derive from <see cref="InvalidOperationException"/>, so
/// the specific cases must be tested before the fallback. A plain InvalidOperationException
/// still means 400 — a genuinely bad request, such as an unusable resolution.
/// </summary>
public static IResult ToResult(InvalidOperationException ex) => ex switch
{
SeatNotFoundException => Results.NotFound(new { error = ex.Message }),
ResourceConflictException => Results.Conflict(new { error = ex.Message }),
CapacityExhaustedException => Results.Json(new { error = ex.Message }, statusCode: 503),
_ => Results.BadRequest(new { error = ex.Message })
};

/// <summary>The status <see cref="ToResult"/> would produce. Exists so tests can assert the
/// mapping without standing up the HTTP pipeline.</summary>
public static int StatusFor(InvalidOperationException ex) => ex switch
{
SeatNotFoundException => StatusCodes.Status404NotFound,
ResourceConflictException => StatusCodes.Status409Conflict,
CapacityExhaustedException => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status400BadRequest
};
}
28 changes: 14 additions & 14 deletions src/MultiSeat.Service/Sessions/SeatManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ public async Task<SeatInfo> ProvisionSeatAsync(SeatRequest request, Cancellation
// Count only live seats — Error/Idle entries hold no resources (their ports and
// sessions were already released on failure) and must not block new provisioning.
if (ActiveSeatCount >= _options.MaxSeats)
throw new InvalidOperationException($"Maximum seat count ({_options.MaxSeats}) reached.");
throw new CapacityExhaustedException($"Maximum seat count ({_options.MaxSeats}) reached.");

if (!_accounts.AccountExists(request.AccountName))
throw new InvalidOperationException($"Account '{request.AccountName}' does not exist. Create it first via /api/accounts.");
Expand Down Expand Up @@ -231,7 +231,7 @@ public async Task<SeatInfo> ProvisionSeatAsync(SeatRequest request, Cancellation
// seat Guid, so two provisions of the same account hold different gates. This lock
// covers only the ownership decision; it is released before any provisioning work.
if (!TryRegisterSeat(_seats, _accountOwnershipLock, seat))
throw new InvalidOperationException(
throw new ResourceConflictException(
$"Account '{request.AccountName}' already has a seat — tear it down first.");

await BroadcastState(seat);
Expand Down Expand Up @@ -543,7 +543,7 @@ await File.WriteAllTextAsync(rustDeskConfig,
public async Task LaunchAppInSeatAsync(Guid seatId, LaunchAppRequest request, CancellationToken ct)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

// Without the gate, a teardown could remove the seat — disconnecting and logging off its
// session — between the status check and the process creation below, orphaning the app in
Expand All @@ -552,10 +552,10 @@ public async Task LaunchAppInSeatAsync(Guid seatId, LaunchAppRequest request, Ca

var seat = LiveSeatAfterGate(seatId, "app launch");
if (seat is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

if (seat.Status is not SeatStatus.Ready and not SeatStatus.Streaming)
throw new InvalidOperationException($"Seat is in {seat.Status} state — cannot launch apps.");
throw new ResourceConflictException($"Seat is in {seat.Status} state — cannot launch apps.");

await _processInjector.LaunchInSessionAsync(
seat.SessionId, seat.AccountName,
Expand Down Expand Up @@ -688,7 +688,7 @@ public async Task<SeatServices> GetSeatServicesAsync(Guid seatId, CancellationTo
public async Task StopApollo(Guid seatId)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

// Mutates ApolloProcessId and the ApolloManager instance record.
using var lease = await _lifecycleGate.AcquireAsync(seatId, CancellationToken.None);
Expand All @@ -706,7 +706,7 @@ public async Task StopApollo(Guid seatId)
public async Task StartApolloAsync(Guid seatId, CancellationToken ct)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

// Per-seat lifecycle gate: starts Apollo and mutates ApolloProcessId.
using var lease = await _lifecycleGate.AcquireAsync(seatId, ct);
Expand Down Expand Up @@ -735,7 +735,7 @@ public async Task StartApolloAsync(Guid seatId, CancellationToken ct)
public async Task RestartApolloAsync(Guid seatId, CancellationToken ct)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

// Per-seat lifecycle gate: Stop + Start is one compound mutation, not two.
using var lease = await _lifecycleGate.AcquireAsync(seatId, ct);
Expand Down Expand Up @@ -901,7 +901,7 @@ public async Task ApplyDisplayIsolationAsync(SeatInfo seat, CancellationToken ct
public async Task ResetAudioAsync(Guid seatId)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

// Nothing to reset under per-session audio: MultiSeat assigns no device, and the
// session's Remote Audio endpoint lives and dies with the session itself. Re-assigning
Expand Down Expand Up @@ -942,7 +942,7 @@ public async Task ResetAudioAsync(Guid seatId)
public void ApplyAudioDefaults(Guid seatId)
{
var seat = GetSeat(seatId)
?? throw new InvalidOperationException("Seat not found.");
?? throw new SeatNotFoundException();
ApplyAudioDefaults(seat);
}

Expand Down Expand Up @@ -980,7 +980,7 @@ public async Task SetNvencPresetAsync(Guid seatId, NvencQualityPreset preset,
SeatPresetStore presetStore, CancellationToken ct)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

// Per-seat lifecycle gate: KillForReconnect + Start mutate ApolloProcessId.
using var lease = await _lifecycleGate.AcquireAsync(seatId, ct);
Expand Down Expand Up @@ -1029,7 +1029,7 @@ public async Task SetResolutionAsync(Guid seatId, int width, int height,
SeatPresetStore presetStore, CancellationToken ct)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

// Per-seat lifecycle gate: rebuilds the session: SessionId, mstsc and ApolloProcessId all change.
using var lease = await _lifecycleGate.AcquireAsync(seatId, ct);
Expand Down Expand Up @@ -1092,7 +1092,7 @@ public async Task SetResolutionAsync(Guid seatId, int width, int height,
public async Task ResetDisplayAsync(Guid seatId, CancellationToken ct)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

// A concurrent teardown between the destroy and create below — teardown releases the
// display assignment and cleans the seat's Apollo config — would re-register a display
Expand All @@ -1118,7 +1118,7 @@ public async Task ResetDisplayAsync(Guid seatId, CancellationToken ct)
public async Task ResetControllerAsync(Guid seatId)
{
if (GetSeat(seatId) is null)
throw new InvalidOperationException("Seat not found.");
throw new SeatNotFoundException();

if (!_options.EnableViGEmController)
{
Expand Down
23 changes: 20 additions & 3 deletions src/MultiSeat.Service/Streaming/ApolloManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ public async Task<int> StartAsync(SeatInfo seat, CancellationToken ct)

_instances[seat.Id] = instance;

// Also record it on the seat, which outlives this dictionary. _instances is in-memory
// only, so after a service restart it is empty while the seat and its Apollo are both
// still very much alive — and that is precisely when a kill would otherwise have nothing
// but a bare PID to go on.
seat.ApolloIdentity = instance.Identity;

_logger.LogInformation(
"Seat {Id}: Apollo started (PID {Pid}) — Moonlight can connect on port {Port}",
seat.Id, pid, seat.PortBase + 1);
Expand Down Expand Up @@ -192,9 +198,20 @@ public void Stop(SeatInfo seat)
return;
}

// No identity available. This is the path after a service restart, where the instance
// record is gone and SeatInfo carries only a bare PID. Carrying the identity on SeatInfo
// would close it, but that is a contract change and belongs to PR D (issue #29).
// The instance record is gone — the normal state after a service restart. The seat itself
// still carries the identity, so this stays a verified kill rather than a hopeful one.
if (seat.ApolloIdentity is { } seatIdentity)
{
var outcome = TryKillIdentifiedProcess(
seatIdentity, $"stop for seat {seat.Id} (identity from seat)", waitMs: 5000);
_logger.LogInformation(
"Seat {Id}: Apollo stop via seat identity — {Outcome} (PID {Pid})",
seat.Id, outcome, seatIdentity.ProcessId);
return;
}

// Neither source has an identity, which means the start time was unreadable at launch.
// Fall back to the name check — weaker, and the last resort rather than the default.
if (seat.ApolloProcessId <= 0) return;
KillUnidentifiedApollo(seat.Id, seat.ApolloProcessId, "stop", waitMs: 5000);
}
Expand Down
2 changes: 1 addition & 1 deletion src/MultiSeat.Service/Streaming/PortAllocator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public int Allocate()
lock (_lock)
{
if (_available.Count == 0)
throw new InvalidOperationException("No port blocks available. All seats occupied.");
throw new CapacityExhaustedException("No port blocks available. All seats occupied.");

var port = _available.Min;
_available.Remove(port);
Expand Down
Loading
Loading