From 76c7b1594bc481c42486633e789aeb09acb55051 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 15:39:50 +0200 Subject: [PATCH] fix(net): timeslice WorldSession.Tick inbound drain (#2 flood) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core.Net/WorldSession.cs | 35 +++++++++++++++++-- .../WorldSessionInboundBudgetTests.cs | 33 +++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 6508084d..a427854a 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Diagnostics; using System.Net; using System.Threading.Channels; using AcDream.Core.Combat; @@ -590,22 +591,50 @@ public sealed class WorldSession : IDisposable _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; + /// - /// Non-blocking pump. Drains any datagrams buffered by the background - /// net thread (Phase A.3), decodes them, and fires events. Call once - /// per game-loop frame. Returns the number of datagrams processed. + /// Non-blocking pump. Drains datagrams buffered by the background net thread (Phase A.3), + /// decodes them, and fires events. Call once per game-loop frame. Once in-world the drain + /// is bounded to ~4ms so a CreateObject flood can't monopolize the frame. Returns the + /// number of datagrams processed. /// public int Tick() { int processed = 0; + long start = Stopwatch.GetTimestamp(); while (_inboundQueue.Reader.TryRead(out var bytes)) { ProcessDatagram(bytes); 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; } + /// + /// 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. + /// + internal static bool InboundBudgetExceeded(State state, long startTicks, long nowTicks, long budgetTicks) + => state == State.InWorld && nowTicks - startTicks >= budgetTicks; + /// /// Phase A.3: background receive loop. Runs on a dedicated daemon /// thread started at the end of . Continuously diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs new file mode 100644 index 00000000..aee35646 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs @@ -0,0 +1,33 @@ +using AcDream.Core.Net; +using Xunit; + +namespace AcDream.Core.Net.Tests; + +/// +/// #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 (); the FIFO tail-drain is +/// standard Channel behavior and is verified live. +/// +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)); +}