feat(admin): replace dead ThreadPool counters with a network readout (#2663)

## Summary

The admin gump's **Performance** page still rendered `ThreadPool` worker / IOCP quotas — a RunUO leftover from `Socket.BeginReceive` days. Both figures are dead: IORingGroup drives RIO / io_uring / epoll / kqueue synchronously on the game loop, so the IOCP figure reads 0% forever, and the real off-loop work (serialization workers, password checks, advanced search, console input) runs on dedicated threads the worker figure never sees.

This replaces the block with a read-only network readout.

## Engine

- `NetState.GetNetworkStats()` — new public accessor returning a `NetworkStats` snapshot (ModernUO-owned record types in `Network/NetworkStats.cs`, so UOContent takes no dependency on transport types):
  - live: connected / max sockets, authenticated count, per-tier growth usage, the three loop queues (throttled, flush pending, pending disconnects)
  - configured: base / pre-auth / max buffer sizes, growth budget, memory ceiling, last-read available memory
  - `LastSweep`: everything the once-a-minute maintenance sweep saw, including the refusal counters it resets
- The sweep already kept four of its results (`_lastTier*`, `_lastBaseCapacityBytes`) for change detection; those fold into one `SendBufferSweep` snapshot. Sweep logic and logging are unchanged.
- The accessor never calls `Maintain()`, so opening the page cannot rotate the retention window.
- Tick age is compared by subtraction; "has a sweep run" is a `bool`, not a zero-tick sentinel.

## Gump

```
Event Loop:    Healthy
Connections:   123 / 4096 (117 authenticated)
Loop Queues:   throttled 0, flush 3, closing 0
Send Buffers:  [scrollable]
  Base: 64.0 KB recv, 256.0 KB send; pre-auth 4.0 KB / 4.0 KB
  Base pools: 10.2 MB
  Growth tiers (live):
    512.0 KB: 3 / 32 in use, floor 5
    1.0 MB: 0 / 0 in use, floor 0
    2.0 MB: 0 / 0 in use, floor 0
  Budget: 16.0 MB of 256.0 MB, max 2.0 MB per socket
  Ceiling: 80% of 7.8 GB available
  Last sweep 42s ago: released 0 tier, 0 base
  Refused: budget 0, at max 0, ceiling 0
```
This commit is contained in:
Kamron Batman 2026-09-21 15:57:23 -07:00 • committed by GitHub
parent 24bcfee554
commit 29a3ab1bd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 280 additions and 30 deletions

View file

@ -234,27 +234,16 @@ namespace Server.Gumps
AddLabel(20, 130, LabelHue, "Event Loop:");
AddLabel(40, 150, LabelHue, loopStatus);
using var sb = ValueStringBuilder.Create();
var net = NetState.GetNetworkStats();
ThreadPool.GetAvailableThreads(out var curUser, out var curIOCP);
ThreadPool.GetMaxThreads(out var maxUser, out var maxIOCP);
AddLabel(20, 170, LabelHue, "Connections:");
AddLabel(150, 170, LabelHue, $"{net.Connected} / {net.MaxConnections} ({net.Authenticated} authenticated)");
sb.Append("Worker Threads:<br>Capacity: ");
sb.Append(maxUser);
sb.Append("<br>Available: ");
sb.Append(curUser);
sb.Append("<br>Usage: ");
sb.Append((maxUser - curUser) * 100 / maxUser);
sb.Append("%<br><br>IOCP Threads:<br>Capacity: ");
sb.Append(maxIOCP);
sb.Append("<br>Available: ");
sb.Append(curIOCP);
sb.Append("<br>Usage: ");
sb.Append((maxIOCP - curIOCP) * 100 / maxIOCP);
sb.Append('%');
AddLabel(20, 190, LabelHue, "Loop Queues:");
AddLabel(150, 190, LabelHue, $"throttled {net.Throttled}, flush {net.FlushPending}, closing {net.PendingDisconnects}");
AddLabel(20, 200, LabelHue, "Pooling:");
AddHtml(20, 220, 380, 150, sb.ToString(), true, true);
AddLabel(20, 210, LabelHue, "Send Buffers:");
AddHtml(20, 230, 380, 180, FormatSendBuffers(net), true, true);
AddPageButton(200, 20, GetButtonID(0, 0), "General", AdminGumpPage.Information_General);
AddPageButton(200, 40, GetButtonID(0, 5), "Performance", AdminGumpPage.Information_Perf);
@ -1364,6 +1353,48 @@ namespace Server.Gumps
public static string FormatTimeSpan(TimeSpan ts) =>
$"{ts.Days:D2}:{ts.Hours % 24:D2}:{ts.Minutes % 60:D2}:{ts.Seconds % 60:D2}";
private static string FormatSendBuffers(NetworkStats net)
{
using var sb = ValueStringBuilder.Create();
sb.Append($"Base: {FormatByteAmount(net.RecvBufferSize)} recv, {FormatByteAmount(net.SendBufferSize)} send");
if (net.InitialRecvBufferSize > 0)
{
sb.Append($"; pre-auth {FormatByteAmount(net.InitialRecvBufferSize)} / {FormatByteAmount(net.InitialSendBufferSize)}");
}
var sweep = net.LastSweep;
if (sweep.Ran)
{
sb.Append($"<br>Base pools: {FormatByteAmount(sweep.BaseCapacityBytes)}");
}
long tierBytes = 0;
sb.Append("<br><br>Growth tiers (live):");
for (var i = 0; i < net.Tiers.Length; i++)
{
var tier = net.Tiers[i];
tierBytes += (long)tier.Capacity * tier.BufferSize;
sb.Append($"<br> {FormatByteAmount(tier.BufferSize)}: {tier.InUse} / {tier.Capacity} in use, floor {tier.RetainFloor}");
}
sb.Append($"<br>Budget: {FormatByteAmount(tierBytes)} of {FormatByteAmount(net.SendBufferGrowthBudget)}, max {FormatByteAmount(net.MaxSendBufferSize)} per socket");
sb.Append($"<br>Ceiling: {net.MemoryCeilingPercent}% of {FormatByteAmount(net.AvailableMemoryBytes)} available");
if (sweep.Ran)
{
var ageSeconds = (Core.TickCount - sweep.Tick) / 1000;
sb.Append($"<br><br>Last sweep {ageSeconds}s ago: released {sweep.TierBuffersReleased} tier, {sweep.BaseBuffersReleased} base");
sb.Append($"<br>Refused: budget {sweep.BudgetRefusals}, at max {sweep.CapRefusals}, ceiling {sweep.CeilingRefusals}");
}
else
{
sb.Append("<br><br>First sweep pending");
}
return sb.ToString();
}
public static string FormatByteAmount(long totalBytes)
{
return totalBytes switch