ModernUO/Projects/Server/Network/NetworkCompression.cs
Kamron Batman 31cd19b05b
feat(network): grow the send buffer on demand instead of disconnecting (#2639)
## Problem

A connection's send buffer is a fixed 256 KB. A burst of world traffic (a crowded area, a mass spawn, a war) that outruns the client's acknowledgements fills it, `NetState.Send()` reports "send buffer exhausted", and the player is disconnected. Raising the size for everyone multiplies the per-connection footprint (4096 × 256 KB is already 1 GB at full occupancy, page-locked on Windows).

## What changes

- **Growth.** When a packet does not fit (the write span is too small, the packet is larger than the span, or compression returns 0), `Send()` asks the transport to grow the buffer to the next power-of-two tier and retries, up to `network.sendBufferMaxSize` (2 MB). Compression retries once per tier since its output size is not known in advance, including when the buffer is completely full. Only when growth is refused does the existing exhaustion disconnect run. The success path is unchanged.
- **Memory ceiling.** Growth is refused (with a once-a-minute warning) when the process working set exceeds `network.memoryCeilingPercent` (80) of the memory available to the process (container-aware; `0` turns the check off). The figure is sampled at startup and refreshed each maintenance tick.
- **Shrink.** A grown socket returns to the base buffer once it is drained and 30 s have passed since its last growth, attempted from the `DataSent` handler and from the 5 s alive sweep.
- **Retention.** Every minute a timer calls the transport's `Maintain()`, which trims idle tier slabs down to the peak concurrent usage of the last 15 minutes, so recurring bursts reuse buffers without allocation while rare ones give the memory back. The line logs at Debug, and only when capacity, usage, or the floor changed or a growth was refused (budget, at max, or ceiling), so an idle shard logs nothing.
- **Budget.** `network.sendBufferGrowthBudget` (256 MB) caps the tier pools' capacity; a positive value below one tier slab is raised with a warning, a negative one is clamped to 0 (growth off). Worst case is base × connections plus the budget.
- Settings are coerced with accurate warnings (power of two, minimum, 256 MB transport ceiling). `[dumpnetstates` gains the send buffer size. `dev-docs/server-requirements.md` describes the new memory story.

## Tests

`NetStateSendBufferTests` (real loopback sockets): growth instead of disconnect with a byte-exact stream, compressed growth against the compressor's own output, the grow-then-copy path, growth with a send genuinely in flight, refusal past the maximum, refusal under the ceiling, refusal on a closing socket, shrink after the hold (direct and through the alive sweep), and the setting coercions. Server.Tests 891 passed, UOContent.Tests 1052 passed against the published 1.0.12.

Reviewed per task, whole-branch, and adversarially by a second model (twice, the second time jointly with the transport branch); all findings addressed.
2026-09-12 16:35:06 -07:00

185 lines
8 KiB
C#

using System;
using System.Buffers.Binary;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Server.Network;
/// <summary>
/// Handles outgoing packet compression for the network.
/// </summary>
public static class NetworkCompression
{
// UO packets may not exceed 64kb in length
public const int BufferSize = 0x10000;
// Optimal compression ratio is 2 / 8; worst compression ratio is 11 / 8
private const int MinimalCodeLength = 2;
// Fixed overhead, in bits, per compression call
private const int TerminalCodeLength = 4;
// Compress returns 0 for longer input regardless of the output span
internal const int DefiniteOverflow = (BufferSize * 8 - TerminalCodeLength) / MinimalCodeLength;
// Packed Table: High 16 bits = Length, Low 16 bits = Value
private static readonly uint[] _packedHuffmanTable = new uint[257];
static NetworkCompression()
{
ReadOnlySpan<int> rawTable = [
0x2, 0x000, 0x5, 0x01F, 0x6, 0x022, 0x7, 0x034, 0x7, 0x075, 0x6, 0x028, 0x6, 0x03B, 0x7, 0x032,
0x8, 0x0E0, 0x8, 0x062, 0x7, 0x056, 0x8, 0x079, 0x9, 0x19D, 0x8, 0x097, 0x6, 0x02A, 0x7, 0x057,
0x8, 0x071, 0x8, 0x05B, 0x9, 0x1CC, 0x8, 0x0A7, 0x7, 0x025, 0x7, 0x04F, 0x8, 0x066, 0x8, 0x07D,
0x9, 0x191, 0x9, 0x1CE, 0x7, 0x03F, 0x9, 0x090, 0x8, 0x059, 0x8, 0x07B, 0x8, 0x091, 0x8, 0x0C6,
0x6, 0x02D, 0x9, 0x186, 0x8, 0x06F, 0x9, 0x093, 0xA, 0x1CC, 0x8, 0x05A, 0xA, 0x1AE, 0xA, 0x1C0,
0x9, 0x148, 0x9, 0x14A, 0x9, 0x082, 0xA, 0x19F, 0x9, 0x171, 0x9, 0x120, 0x9, 0x0E7, 0xA, 0x1F3,
0x9, 0x14B, 0x9, 0x100, 0x9, 0x190, 0x6, 0x013, 0x9, 0x161, 0x9, 0x125, 0x9, 0x133, 0x9, 0x195,
0x9, 0x173, 0x9, 0x1CA, 0x9, 0x086, 0x9, 0x1E9, 0x9, 0x0DB, 0x9, 0x1EC, 0x9, 0x08B, 0x9, 0x085,
0x5, 0x00A, 0x8, 0x096, 0x8, 0x09C, 0x9, 0x1C3, 0x9, 0x19C, 0x9, 0x08F, 0x9, 0x18F, 0x9, 0x091,
0x9, 0x087, 0x9, 0x0C6, 0x9, 0x177, 0x9, 0x089, 0x9, 0x0D6, 0x9, 0x08C, 0x9, 0x1EE, 0x9, 0x1EB,
0x9, 0x084, 0x9, 0x164, 0x9, 0x175, 0x9, 0x1CD, 0x8, 0x05E, 0x9, 0x088, 0x9, 0x12B, 0x9, 0x172,
0x9, 0x10A, 0x9, 0x08D, 0x9, 0x13A, 0x9, 0x11C, 0xA, 0x1E1, 0xA, 0x1E0, 0x9, 0x187, 0xA, 0x1DC,
0xA, 0x1DF, 0x7, 0x074, 0x9, 0x19F, 0x8, 0x08D, 0x8, 0x0E4, 0x7, 0x079, 0x9, 0x0EA, 0x9, 0x0E1,
0x8, 0x040, 0x7, 0x041, 0x9, 0x10B, 0x9, 0x0B0, 0x8, 0x06A, 0x8, 0x0C1, 0x7, 0x071, 0x7, 0x078,
0x8, 0x0B1, 0x9, 0x14C, 0x7, 0x043, 0x8, 0x076, 0x7, 0x066, 0x7, 0x04D, 0x9, 0x08A, 0x6, 0x02F,
0x8, 0x0C9, 0x9, 0x0CE, 0x9, 0x149, 0x9, 0x160, 0xA, 0x1BA, 0xA, 0x19E, 0xA, 0x39F, 0x9, 0x0E5,
0x9, 0x194, 0x9, 0x184, 0x9, 0x126, 0x7, 0x030, 0x8, 0x06C, 0x9, 0x121, 0x9, 0x1E8, 0xA, 0x1C1,
0xA, 0x11D, 0xA, 0x163, 0xA, 0x385, 0xA, 0x3DB, 0xA, 0x17D, 0xA, 0x106, 0xA, 0x397, 0xA, 0x24E,
0x7, 0x02E, 0x8, 0x098, 0xA, 0x33C, 0xA, 0x32E, 0xA, 0x1E9, 0x9, 0x0BF, 0xA, 0x3DF, 0xA, 0x1DD,
0xA, 0x32D, 0xA, 0x2ED, 0xA, 0x30B, 0xA, 0x107, 0xA, 0x2E8, 0xA, 0x3DE, 0xA, 0x125, 0xA, 0x1E8,
0x9, 0x0E9, 0xA, 0x1CD, 0xA, 0x1B5, 0x9, 0x165, 0xA, 0x232, 0xA, 0x2E1, 0xB, 0x3AE, 0xB, 0x3C6,
0xB, 0x3E2, 0xA, 0x205, 0xA, 0x29A, 0xA, 0x248, 0xA, 0x2CD, 0xA, 0x23B, 0xB, 0x3C5, 0xA, 0x251,
0xA, 0x2E9, 0xA, 0x252, 0x9, 0x1EA, 0xB, 0x3A0, 0xB, 0x391, 0xA, 0x23C, 0xB, 0x392, 0xB, 0x3D5,
0xA, 0x233, 0xA, 0x2CC, 0xB, 0x390, 0xA, 0x1BB, 0xB, 0x3A1, 0xB, 0x3C4, 0xA, 0x211, 0xA, 0x203,
0x9, 0x12A, 0xA, 0x231, 0xB, 0x3E0, 0xA, 0x29B, 0xB, 0x3D7, 0xA, 0x202, 0xB, 0x3AD, 0xA, 0x213,
0xA, 0x253, 0xA, 0x32C, 0xA, 0x23D, 0xA, 0x23F, 0xA, 0x32F, 0xA, 0x11C, 0xA, 0x384, 0xA, 0x31C,
0xA, 0x17C, 0xA, 0x30A, 0xA, 0x2E0, 0xA, 0x276, 0xA, 0x250, 0xB, 0x3E3, 0xA, 0x396, 0xA, 0x18F,
0xA, 0x204, 0xA, 0x206, 0xA, 0x230, 0xA, 0x265, 0xA, 0x212, 0xA, 0x23E, 0xB, 0x3AC, 0xB, 0x393,
0xB, 0x3E1, 0xA, 0x1DE, 0xB, 0x3D6, 0xA, 0x31D, 0xB, 0x3E5, 0xB, 0x3E4, 0xA, 0x207, 0xB, 0x3C7,
0xA, 0x277, 0xB, 0x3D4, 0x8, 0x0C0, 0xA, 0x162, 0xA, 0x3DA, 0xA, 0x124, 0xA, 0x1B4, 0xA, 0x264,
0xA, 0x33D, 0xA, 0x1D1, 0xA, 0x1AF, 0xA, 0x39E, 0xA, 0x24F, 0xB, 0x373, 0xA, 0x249, 0xB, 0x372,
0x9, 0x167, 0xA, 0x210, 0xA, 0x23A, 0xA, 0x1B8, 0xB, 0x3AF, 0xA, 0x18E, 0xA, 0x2EC, 0x7, 0x062,
0x4, 0x00D
];
for (var i = 0; i < 257; i++)
{
var len = (uint)rawTable[i * 2];
var val = (uint)rawTable[i * 2 + 1];
_packedHuffmanTable[i] = (len << 16) | val;
}
}
public static int Compress(ReadOnlySpan<byte> input, Span<byte> output)
{
// output.Length < 4 underflows safeOutputLength below (nuint), defeating the hot loop's
// bounds check. Reachable whenever the send buffer is nearly full.
if (input.Length > DefiniteOverflow || output.Length < 4)
{
return 0;
}
ref var inputRef = ref MemoryMarshal.GetReference(input);
ref var outputRef = ref MemoryMarshal.GetReference(output);
ref var tableRef = ref MemoryMarshal.GetReference(_packedHuffmanTable.AsSpan());
nuint i = 0;
ulong bitValue = 0;
var bitCount = 0;
nuint outputIdx = 0;
var safeOutputLength = (nuint)output.Length - 4;
var unrolledLimit = (nuint)input.Length - 1;
// Unrolled 2x hot loop using native integer indices
while (i < unrolledLimit)
{
var b1 = Unsafe.Add(ref inputRef, i);
var entry1 = Unsafe.Add(ref tableRef, b1);
var len1 = (int)(entry1 >> 16);
ulong val1 = entry1 & 0xFFFF;
bitValue = (bitValue << len1) | val1;
bitCount += len1;
var b2 = Unsafe.Add(ref inputRef, i + 1);
var entry2 = Unsafe.Add(ref tableRef, b2);
var len2 = (int)(entry2 >> 16);
ulong val2 = entry2 & 0xFFFF;
bitValue = (bitValue << len2) | val2;
bitCount += len2;
if (bitCount >= 32)
{
if (outputIdx > safeOutputLength)
{
return 0;
}
bitCount -= 32;
var word = (uint)(bitValue >> bitCount);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref outputRef, outputIdx), BinaryPrimitives.ReverseEndianness(word));
outputIdx += 4;
}
i += 2;
}
if (i < (nuint)input.Length)
{
var b = Unsafe.Add(ref inputRef, i);
var entry = Unsafe.Add(ref tableRef, b);
var len = (int)(entry >> 16);
ulong val = entry & 0xFFFF;
bitValue = (bitValue << len) | val;
bitCount += len;
if (bitCount >= 32)
{
if (outputIdx > safeOutputLength)
{
return 0;
}
bitCount -= 32;
var word = (uint)(bitValue >> bitCount);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref outputRef, outputIdx), BinaryPrimitives.ReverseEndianness(word));
outputIdx += 4;
}
}
// Terminal code logic
var termEntry = Unsafe.Add(ref tableRef, 256);
var termLen = (int)(termEntry >> 16);
ulong termVal = termEntry & 0xFFFF;
bitValue = (bitValue << termLen) | termVal;
bitCount += termLen;
var remainder = bitCount & 7;
if (remainder != 0)
{
var padding = 8 - remainder;
bitValue <<= padding;
bitCount += padding;
}
bitValue <<= 64 - bitCount;
while (bitCount > 0)
{
if (outputIdx >= (nuint)output.Length)
{
return 0;
}
Unsafe.Add(ref outputRef, outputIdx++) = (byte)(bitValue >> 56);
bitValue <<= 8;
bitCount -= 8;
}
return (int)outputIdx;
}
}