From 01d9ee11adebc8cc4acee43b8a45c605d0e00d65 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:34:38 +0200 Subject: [PATCH 1/3] Extract the duty cycle from TickAttribution into DutyCycle The idle, warm-up and burst schedule that drove per-mod attribution now lives in a class of its own, so a second feature can run on the same schedule instead of keeping a copy that would drift. DutyCycle owns the clamp, the interval, the warm-up and the burst length, and says what each tick is for: idle, start, warm-up, sample or last sample. TickAttribution folds a tree on the sample steps and keeps the rest of what it had, with the same constructor, the same Apply and the same properties, so AttributionMetrics and the commands are untouched. Behaviour is unchanged. The schedule tests moved to DutyCycleTests, where they run against the schedule alone, and the attribution tests that check what attribution does with each step stay where they were. The two mutation patterns that pointed at the moved code now point at DutyCycle.cs. --- Pulse.Tests/DutyCycleTests.cs | 162 ++++++++++++++++++++++++++++ Pulse.Tests/TickAttributionTests.cs | 44 +------- Pulse/DutyCycle.cs | 129 ++++++++++++++++++++++ Pulse/TickAttribution.cs | 92 +++++----------- tools/mutation-check.sh | 10 +- 5 files changed, 329 insertions(+), 108 deletions(-) create mode 100644 Pulse.Tests/DutyCycleTests.cs create mode 100644 Pulse/DutyCycle.cs diff --git a/Pulse.Tests/DutyCycleTests.cs b/Pulse.Tests/DutyCycleTests.cs new file mode 100644 index 0000000..8cdf13c --- /dev/null +++ b/Pulse.Tests/DutyCycleTests.cs @@ -0,0 +1,162 @@ +using Xunit; + +namespace Pulse.Tests; + +/// The schedule alone: when a burst starts, how many ticks it lasts, when it ends, and +/// what a switch or a reload does to one in progress. No profiler, no trees and no owners: +/// TickAttributionTests covers what attribution does with each step. +public class DutyCycleTests +{ + /// Ticks the cycle times and returns what each tick was. + private static DutyStep[] Run(DutyCycle cycle, int ticks, double elapsedSeconds = 1.0) + { + DutyStep[] steps = new DutyStep[ticks]; + for (int tick = 0; tick < ticks; tick++) + { + steps[tick] = cycle.OnTick(elapsedSeconds); + } + + return steps; + } + + [Fact] + public void Constructor_Floors_TheIntervalAndTheBurstLength() + { + DutyCycle cycle = new(0, 0); + + Assert.Equal(1, cycle.BurstTicks); + Assert.Equal(DutyCycle.MinimumIntervalSeconds, cycle.IntervalSeconds); + } + + [Fact] + public void Constructor_Caps_TheBurstLength() + => Assert.Equal(DutyCycle.MaximumBurstTicks, new DutyCycle(100000, 10).BurstTicks); + + [Fact] + public void Constructor_Keeps_AConfiguredDutyCycle() + { + DutyCycle cycle = new(30, 10); + + Assert.True(cycle.Enabled); + Assert.Equal(30, cycle.BurstTicks); + Assert.Equal(10, cycle.IntervalSeconds); + } + + [Fact] + public void OnTick_StaysIdle_UntilTheIntervalHasPassed() + { + DutyCycle cycle = new(5, 10); + + for (int tick = 0; tick < 9; tick++) + { + Assert.Equal(DutyStep.Idle, cycle.OnTick(1.0)); + Assert.False(cycle.InBurst); + } + + Assert.Equal(DutyStep.Start, cycle.OnTick(1.0)); + Assert.True(cycle.InBurst); + } + + /// Whatever the caller switches on at the start is switched on part-way through that + /// tick, so what it reads on the next one describes a tick only partly covered. That one is the + /// warm-up, and it is not a sample. + [Fact] + public void OnTick_WarmsUp_OnTheTickAfterTheBurstStarts() + { + DutyCycle cycle = new(1, 1); + + Assert.Equal(DutyStep.Start, cycle.OnTick(1.0)); + Assert.Equal(DutyStep.WarmUp, cycle.OnTick(1.0)); + Assert.True(cycle.InBurst); + Assert.Equal(DutyStep.LastSample, cycle.OnTick(1.0)); + } + + [Fact] + public void OnTick_Takes_BurstTicksSamples_ThenEndsTheBurst() + { + DutyCycle cycle = new(3, 1); + + Assert.Equal([DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.Sample], Run(cycle, 4)); + Assert.True(cycle.InBurst); + + Assert.Equal(DutyStep.LastSample, cycle.OnTick(1.0)); + Assert.False(cycle.InBurst); + } + + /// The interval counts from the end of a burst, and the next burst has the same shape + /// as the first: its own warm-up, then its own samples. + [Fact] + public void OnTick_Runs_ASecondBurstAfterTheNextInterval() + { + DutyCycle cycle = new(2, 2); + DutyStep[] oneBurst = [DutyStep.Idle, DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.LastSample]; + + Assert.Equal(oneBurst, Run(cycle, 5)); + Assert.Equal(oneBurst, Run(cycle, 5)); + } + + [Fact] + public void OnTick_StaysIdle_WhileDisabled() + { + DutyCycle cycle = new(1, 1, enabled: false); + + Assert.All(Run(cycle, 100), step => Assert.Equal(DutyStep.Idle, step)); + Assert.False(cycle.Enabled); + Assert.False(cycle.InBurst); + } + + /// The whole point of arming a cycle on a server that did not ask for it: it can be + /// switched on later, and then it runs exactly as if the config had said so. + [Fact] + public void Apply_Starts_TheCycle_OnAServerThatBootedWithItOff() + { + DutyCycle cycle = new(2, 1, enabled: false); + cycle.OnTick(1.0); + + cycle.Apply(true, 2, 1); + + Assert.Equal([DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.LastSample], Run(cycle, 4)); + } + + /// Switching it off part-way through a burst drops the burst instead of finishing it: + /// the cycle is out of the burst at once, and no later tick is a sample. + [Fact] + public void Apply_Drops_ABurstInProgress_WhenItIsSwitchedOff() + { + DutyCycle cycle = new(30, 1); + Run(cycle, 5); + Assert.True(cycle.InBurst); + + cycle.Apply(false, 30, 1); + + Assert.False(cycle.Enabled); + Assert.False(cycle.InBurst); + Assert.All(Run(cycle, 100), step => Assert.Equal(DutyStep.Idle, step)); + Assert.False(cycle.InBurst); + } + + /// Switching back on starts a fresh cycle, so the burst begins with its warm-up again + /// rather than carrying on from where the old one stopped. + [Fact] + public void Apply_WarmsUpAgain_WhenItIsSwitchedBackOn() + { + DutyCycle cycle = new(2, 1); + Run(cycle, 3); + cycle.Apply(false, 2, 1); + + cycle.Apply(true, 2, 1); + + Assert.Equal([DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.LastSample], Run(cycle, 4)); + } + + [Fact] + public void Apply_Takes_ANewDutyCycle_AndClampsItTheSameWay() + { + DutyCycle cycle = new(30, 10); + + cycle.Apply(true, 100000, 0); + + Assert.Equal(DutyCycle.MaximumBurstTicks, cycle.BurstTicks); + Assert.Equal(DutyCycle.MinimumIntervalSeconds, cycle.IntervalSeconds); + } +} diff --git a/Pulse.Tests/TickAttributionTests.cs b/Pulse.Tests/TickAttributionTests.cs index 7707699..929ad52 100644 --- a/Pulse.Tests/TickAttributionTests.cs +++ b/Pulse.Tests/TickAttributionTests.cs @@ -6,6 +6,9 @@ namespace Pulse.Tests; +/// Attribution's own arithmetic, and what it does with each step of its duty cycle: which +/// tick's tree it folds, when it publishes, what it drops when the cycle is restarted. The schedule +/// alone is DutyCycleTests's. public class TickAttributionTests { /// One profiled tick as the engine leaves it: a thousand ticks of wall time, four @@ -60,19 +63,8 @@ private static AttributionBurst Cycle(TickAttribution attribution, ProfileEntryR private static double Share(AttributionBurst burst, string modid) => burst.Seconds.Single(entry => entry.Key == modid).Value / burst.BusySeconds; - [Fact] - public void Constructor_Floors_TheIntervalAndTheBurstLength() - { - TickAttribution attribution = new(0, 0); - - Assert.Equal(1, attribution.BurstTicks); - Assert.Equal(TickAttribution.MinimumIntervalSeconds, attribution.IntervalSeconds); - } - - [Fact] - public void Constructor_Caps_TheBurstLength() - => Assert.Equal(TickAttribution.MaximumBurstTicks, new TickAttribution(100000, 10).BurstTicks); - + /// The cycle's own clamping is DutyCycleTests's. What this checks is that the two + /// numbers reach the cycle the right way round, and come back out of it. [Fact] public void Constructor_Keeps_AConfiguredDutyCycle() { @@ -82,21 +74,6 @@ public void Constructor_Keeps_AConfiguredDutyCycle() Assert.Equal(10, attribution.IntervalSeconds); } - [Fact] - public void OnTick_LeavesTheProfilerOff_UntilTheIntervalHasPassed() - { - TickAttribution attribution = new(5, 10); - - for (int tick = 0; tick < 9; tick++) - { - Assert.Null(attribution.OnTick(1.0, Tick(), Owners)); - Assert.False(attribution.Profiling); - } - - Assert.Null(attribution.OnTick(1.0, Tick(), Owners)); - Assert.True(attribution.Profiling); - } - /// The tick that turns the profiler on never got its Begin(), so the tree it ends with /// is whatever the last burst left behind. Folding it would count that stale tick again. [Fact] @@ -290,17 +267,6 @@ public void Apply_Discards_TheStaleSample_WhenItIsSwitchedBackOn() Assert.Equal(1, burst.Ticks); } - [Fact] - public void Apply_Takes_ANewDutyCycle_AndClampsItTheSameWay() - { - TickAttribution attribution = new(30, 10); - - attribution.Apply(true, 100000, 0); - - Assert.Equal(TickAttribution.MaximumBurstTicks, attribution.BurstTicks); - Assert.Equal(TickAttribution.MinimumIntervalSeconds, attribution.IntervalSeconds); - } - /// What /pulse attribution status reports, and it has to match the tick counter /// on the wire: both count the ticks completed bursts folded. [Fact] diff --git a/Pulse/DutyCycle.cs b/Pulse/DutyCycle.cs new file mode 100644 index 0000000..51f68a9 --- /dev/null +++ b/Pulse/DutyCycle.cs @@ -0,0 +1,129 @@ +namespace Pulse; + +/// The schedule of a measurement too costly to leave running: idle for an interval, then +/// a burst of consecutive ticks, then idle again. +/// Only the schedule. It is told how long each tick took and says what this tick is for +/// (); what a burst switches on, what it reads and where it publishes is the +/// caller's, and nothing here knows about meters, the server or the engine. That is what makes the +/// whole schedule drivable from a unit test, and what lets every measurement that runs in bursts +/// share it instead of writing it out again. +/// A burst starts on one tick, spends the next as a warm-up, and takes +/// samples after that. The warm-up exists because whatever the caller +/// switches on at the start is switched on part-way through that tick, and what the caller reads at +/// the start of the next one describes the tick before: a tick only partly covered, so it never +/// counts. Every sample counts toward the burst whatever the caller managed to read on it, so a +/// burst always ends. +internal sealed class DutyCycle +{ + /// Shortest interval between bursts. The duty cycle is the whole reason a measurement + /// like this is affordable, so it stays a duty cycle. + public const int MinimumIntervalSeconds = 1; + + /// Longest burst. Ten seconds at the default tick rate, which is already far more than + /// tick composition varies over. + public const int MaximumBurstTicks = 300; + + private double idleSeconds; + private int burstTicksElapsed; + private bool warm; + + public DutyCycle(int burstTicks, int intervalSeconds, bool enabled = true) + => Apply(enabled, burstTicks, intervalSeconds); + + /// Whether the cycle runs at all. Off, every tick is . + public bool Enabled { get; private set; } + + /// Samples per burst, not counting the warm-up. + public int BurstTicks { get; private set; } + + /// Seconds between the end of one burst and the start of the next. + public int IntervalSeconds { get; private set; } + + /// Whether a burst is running as of the last tick: true after a start, a warm-up and a + /// sample, false after an idle tick and after the last sample, which ends the burst. + public bool InBurst { get; private set; } + + /// Takes a cycle, clamped the way the config file's is, and starts it over. + /// Restarting rather than adjusting in place is what makes switching this off + /// mid-burst safe: the burst in progress is dropped instead of finished, and a later switch-on + /// begins from idle with the whole interval still to wait. A new interval or burst length + /// applied to a running cycle restarts it the same way. + public void Apply(bool enabled, int burstTicks, int intervalSeconds) + { + Enabled = enabled; + BurstTicks = Math.Clamp(burstTicks, 1, MaximumBurstTicks); + IntervalSeconds = Math.Max(MinimumIntervalSeconds, intervalSeconds); + Restart(); + } + + /// Advances the schedule by one tick of and says + /// what the caller does with it. + public DutyStep OnTick(double elapsedSeconds) + { + if (!Enabled) + { + return DutyStep.Idle; + } + + if (!InBurst) + { + idleSeconds += elapsedSeconds; + if (idleSeconds < IntervalSeconds) + { + return DutyStep.Idle; + } + + idleSeconds = 0; + burstTicksElapsed = 0; + warm = false; + InBurst = true; + return DutyStep.Start; + } + + if (!warm) + { + warm = true; + return DutyStep.WarmUp; + } + + if (++burstTicksElapsed < BurstTicks) + { + return DutyStep.Sample; + } + + Restart(); + return DutyStep.LastSample; + } + + /// Back to idle, with the interval counted from now. + private void Restart() + { + InBurst = false; + idleSeconds = 0; + burstTicksElapsed = 0; + warm = false; + } +} + +/// What one tick of a asks of whoever owns the work. +internal enum DutyStep +{ + /// Nothing: the cycle is off, or still waiting out its interval. + Idle, + + /// The interval has passed and a burst starts on this tick: whatever the burst needs + /// switched on gets switched on now. + Start, + + /// The tick after the start. What the caller reads on it describes the tick the burst + /// started in, which was only partly covered, so it is not a sample. + WarmUp, + + /// A tick of the burst to read a sample on. + Sample, + + /// The burst's last sample, read the same way as any other. The burst is over by the + /// time this is returned: the cycle is idle again and is + /// false. + LastSample, +} diff --git a/Pulse/TickAttribution.cs b/Pulse/TickAttribution.cs index 4c686c9..2cc7553 100644 --- a/Pulse/TickAttribution.cs +++ b/Pulse/TickAttribution.cs @@ -8,9 +8,9 @@ namespace Pulse; -/// The duty cycle and the arithmetic behind per-mod tick attribution: when the engine's -/// frame profiler should be running, and how one profiled tick's mark tree becomes seconds per -/// mod. +/// The arithmetic behind per-mod tick attribution, on the schedule of a +/// : when the engine's frame profiler should be running, and how one +/// profiled tick's mark tree becomes seconds per mod. /// Knows nothing about meters, the server or the profiler flag itself. It is handed the /// previous tick's completed tree and says whether the profiler should be on when the current tick /// ends, which is what makes the whole duty cycle drivable from a unit test. @@ -30,14 +30,6 @@ internal sealed class TickAttribution /// PropertyName(). public const string BehaviorPrefix = "done-behavior-"; - /// Shortest interval between bursts. The duty cycle is the whole reason this is - /// affordable, so it stays a duty cycle. - public const int MinimumIntervalSeconds = 1; - - /// Longest burst. Ten seconds of profiling at the default tick rate, which is already - /// far more than tick composition varies over. - public const int MaximumBurstTicks = 300; - /// The engine's bucket for the throttle sleep, charged in ServerMain.Process /// (1.22.7:1553). It is the one root mark that is not work, so it is what busy time is measured /// against rather than attributed. @@ -50,31 +42,31 @@ internal sealed class TickAttribution /// other mark in the tree is the engine's own. private static readonly string[] OwnedPrefixes = ["gmle", "gmlb", "dce", "dcb", "sdcb", BehaviorPrefix]; + private readonly DutyCycle cycle; + private readonly Dictionary ticksByMod = []; /// Every mod that has appeared in any burst so far, so one that goes quiet publishes a /// zero instead of freezing its gauge at the share it had when it stopped. private readonly HashSet seenMods = []; - private double idleSeconds; - private int burstTicksElapsed; private int sampled; private long busyTicks; private long dropped; - private bool warm; public TickAttribution(int burstTicks, int intervalSeconds, bool enabled = true) - => Apply(enabled, burstTicks, intervalSeconds); + => cycle = new DutyCycle(burstTicks, intervalSeconds, enabled); /// Whether the duty cycle runs at all. - public bool Enabled { get; private set; } + public bool Enabled => cycle.Enabled; - public int BurstTicks { get; private set; } + public int BurstTicks => cycle.BurstTicks; - public int IntervalSeconds { get; private set; } + public int IntervalSeconds => cycle.IntervalSeconds; - /// Whether the engine's frame profiler has to be enabled when the current tick ends. - public bool Profiling { get; private set; } + /// Whether the engine's frame profiler has to be enabled when the current tick ends: + /// exactly while the duty cycle is inside a burst. + public bool Profiling => cycle.InBurst; /// Ticks folded into a completed burst since the server booted, which is the number /// pulse_attribution_ticks_total reports. @@ -86,10 +78,8 @@ public TickAttribution(int burstTicks, int intervalSeconds, bool enabled = true) /// back off on the next tick, and a later switch-on begins from a clean burst. public void Apply(bool enabled, int burstTicks, int intervalSeconds) { - Enabled = enabled; - BurstTicks = Math.Clamp(burstTicks, 1, MaximumBurstTicks); - IntervalSeconds = Math.Max(MinimumIntervalSeconds, intervalSeconds); - Restart(); + cycle.Apply(enabled, burstTicks, intervalSeconds); + ClearBurst(); } /// Advances the duty cycle by one tick, folding when @@ -97,48 +87,25 @@ public void Apply(bool enabled, int burstTicks, int intervalSeconds) /// one. public AttributionBurst? OnTick(double elapsedSeconds, ProfileEntryRange? previousTick, OwnerLookup owner) { - if (!Enabled) - { - return null; - } + DutyStep step = cycle.OnTick(elapsedSeconds); - if (!Profiling) + // Idle, the start and the warm-up carry nothing to fold. On the warm-up, the previous tick + // is the one the profiler was switched on part-way through: it never got its Begin(), and + // the tree it ended with is whatever the last burst left in the profiler. One stale sample + // per burst, discarded here rather than folded. + if (step is not (DutyStep.Sample or DutyStep.LastSample)) { - idleSeconds += elapsedSeconds; - if (idleSeconds < IntervalSeconds) - { - return null; - } - - idleSeconds = 0; - burstTicksElapsed = 0; - warm = false; - Profiling = true; - return null; - } - - // The profiler was switched on part-way through the previous tick, so that tick never got - // its Begin() and the tree it ended with is whatever the last burst left in the profiler. - // One stale sample per burst, discarded here rather than folded. - if (!warm) - { - warm = true; return null; } + // The cycle counted this sample whether or not there was a tree to read, so a burst always + // ends and the profiler always goes back off. if (previousTick != null) { Fold(previousTick, owner); } - // Counted whether or not there was a tree to read, so a burst always ends and the profiler - // always goes back off. - if (++burstTicksElapsed < BurstTicks) - { - return null; - } - - return Take(); + return step == DutyStep.LastSample ? Take() : null; } /// Folds one completed tick's tree into the burst. @@ -228,7 +195,8 @@ private void Add(string modid, long ticks) ticksByMod[modid] = accumulated + ticks; } - /// Closes the burst and starts the next one empty. + /// Closes the burst and starts the next one empty. The duty cycle has already gone + /// back to idle by the time this runs, so the profiler is off from the next tick. private AttributionBurst Take() { double frequency = Stopwatch.Frequency; @@ -241,20 +209,16 @@ private AttributionBurst Take() AttributionBurst burst = new(seconds, busyTicks / frequency, sampled, dropped); TicksProfiled += sampled; - Restart(); + ClearBurst(); return burst; } - /// Back to idle with nothing accumulated, and the profiler off from the next tick. + /// Nothing accumulated: what a burst gathers so far is dropped. /// Everything a burst gathers is dropped here, but seenMods is not: a mod that /// has been measured once keeps publishing a zero rather than freezing its gauge, whether the /// burst ended on its own or an operator cut it short. - private void Restart() + private void ClearBurst() { - Profiling = false; - idleSeconds = 0; - burstTicksElapsed = 0; - warm = false; ticksByMod.Clear(); busyTicks = 0; sampled = 0; diff --git a/tools/mutation-check.sh b/tools/mutation-check.sh index bbdffb5..c0e2196 100755 --- a/tools/mutation-check.sh +++ b/tools/mutation-check.sh @@ -52,7 +52,7 @@ mutate() { #