fix(net): timeslice WorldSession.Tick inbound drain (#2 flood)
After a teleport ACE floods a town's CreateObjects; WorldSession.Tick drained the ENTIRE inbound queue every frame, each spawn hydrating mesh+textures under _datLock on the render thread — monopolizing the update loop for ~a minute and starving the streaming apply, so only the destination landblock loaded and neighbors trickled in (visible at high render-FPS because update/render are separate Silk.NET callbacks). Bound the per-frame drain to a ~4ms wall-clock budget once InWorld (handshake uses the blocking PumpOnce path, never Tick). A time budget self-adapts to the highly variable per-datagram cost; the tail stays queued (unbounded channel, FIFO) and drains over the next few frames. Acks are queued per packet BEFORE the heavy handler (WorldSession.cs:680), so deferring the tail only delays the tail's acks a few frames — within ACE's tolerance (holtburger defers acks on a flush cadence; verified in references/holtburger session/send.rs). Budget decision extracted as a pure testable static (4 unit tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6e78fcd7f6
commit
76c7b1594b
2 changed files with 65 additions and 3 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
|
@ -590,22 +591,50 @@ public sealed class WorldSession : IDisposable
|
||||||
_netThread.Start();
|
_netThread.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-frame inbound time budget (#2 flood timeslice). On a teleport arrival ACE floods
|
||||||
|
// a town's CreateObjects; draining them ALL in one Tick (each hydrates mesh + textures
|
||||||
|
// under _datLock on the render thread) monopolized the update loop for ~a minute and
|
||||||
|
// starved the streaming apply, so neighbor landblocks trickled in. A wall-clock budget
|
||||||
|
// self-adapts to the highly variable per-datagram cost (cheap UpdatePosition vs an
|
||||||
|
// expensive clothed CreateObject); the tail drains over the next few frames, FIFO
|
||||||
|
// preserved, nothing lost (the inbox channel is unbounded). ~4ms leaves the rest of a
|
||||||
|
// 60fps update frame for streaming + physics.
|
||||||
|
private static readonly long InboundBudgetTicks = Stopwatch.Frequency / 1000 * 4;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Non-blocking pump. Drains any datagrams buffered by the background
|
/// Non-blocking pump. Drains datagrams buffered by the background net thread (Phase A.3),
|
||||||
/// net thread (Phase A.3), decodes them, and fires events. Call once
|
/// decodes them, and fires events. Call once per game-loop frame. Once in-world the drain
|
||||||
/// per game-loop frame. Returns the number of datagrams processed.
|
/// is bounded to ~4ms so a CreateObject flood can't monopolize the frame. Returns the
|
||||||
|
/// number of datagrams processed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Tick()
|
public int Tick()
|
||||||
{
|
{
|
||||||
int processed = 0;
|
int processed = 0;
|
||||||
|
long start = Stopwatch.GetTimestamp();
|
||||||
while (_inboundQueue.Reader.TryRead(out var bytes))
|
while (_inboundQueue.Reader.TryRead(out var bytes))
|
||||||
{
|
{
|
||||||
ProcessDatagram(bytes);
|
ProcessDatagram(bytes);
|
||||||
processed++;
|
processed++;
|
||||||
|
// Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
|
||||||
|
// (the net thread that feeds _inboundQueue starts at Transition(State.InWorld)).
|
||||||
|
// Acks are queued per packet inside ProcessDatagram BEFORE the heavy handler, so
|
||||||
|
// deferring the tail only delays the tail's acks a few frames — within ACE's
|
||||||
|
// tolerance (holtburger defers acks on a flush cadence). The tail stays queued
|
||||||
|
// (unbounded channel, FIFO) and drains next frame.
|
||||||
|
if (InboundBudgetExceeded(CurrentState, start, Stopwatch.GetTimestamp(), InboundBudgetTicks))
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure, testable decision for the per-frame inbound bound: stop draining only when
|
||||||
|
/// in-world AND the elapsed Stopwatch ticks have reached the budget. Extracted so the
|
||||||
|
/// gate logic is unit-tested without the ISAAC/decode/channel machinery.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool InboundBudgetExceeded(State state, long startTicks, long nowTicks, long budgetTicks)
|
||||||
|
=> state == State.InWorld && nowTicks - startTicks >= budgetTicks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase A.3: background receive loop. Runs on a dedicated daemon
|
/// Phase A.3: background receive loop. Runs on a dedicated daemon
|
||||||
/// thread started at the end of <see cref="EnterWorld"/>. Continuously
|
/// thread started at the end of <see cref="EnterWorld"/>. Continuously
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #2 flood-timeslice: the per-frame inbound drain is bounded to a wall-clock budget once
|
||||||
|
/// in-world so a CreateObject flood can't monopolize the update loop. These cover the pure
|
||||||
|
/// gate decision (<see cref="WorldSession.InboundBudgetExceeded"/>); the FIFO tail-drain is
|
||||||
|
/// standard <c>Channel</c> behavior and is verified live.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WorldSessionInboundBudgetTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void InWorld_overBudget_stops()
|
||||||
|
=> Assert.True(WorldSession.InboundBudgetExceeded(
|
||||||
|
WorldSession.State.InWorld, startTicks: 100, nowTicks: 250, budgetTicks: 100));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InWorld_atBudgetBoundary_stops()
|
||||||
|
=> Assert.True(WorldSession.InboundBudgetExceeded(
|
||||||
|
WorldSession.State.InWorld, startTicks: 0, nowTicks: 100, budgetTicks: 100));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InWorld_underBudget_continues()
|
||||||
|
=> Assert.False(WorldSession.InboundBudgetExceeded(
|
||||||
|
WorldSession.State.InWorld, startTicks: 0, nowTicks: 99, budgetTicks: 100));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotInWorld_neverBounds_soTheHandshakeDrainsFully()
|
||||||
|
=> Assert.False(WorldSession.InboundBudgetExceeded(
|
||||||
|
WorldSession.State.Disconnected, startTicks: 0, nowTicks: 1_000_000, budgetTicks: 100));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue