diff --git a/Pulse.Tests/DutyCycleTests.cs b/Pulse.Tests/DutyCycleTests.cs
new file mode 100644
index 0000000..479eaf4
--- /dev/null
+++ b/Pulse.Tests/DutyCycleTests.cs
@@ -0,0 +1,199 @@
+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(1, cycle.IntervalSeconds);
+ }
+
+ [Fact]
+ public void Constructor_Caps_TheBurstLength()
+ => Assert.Equal(300, 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);
+ }
+
+ /// The interval is seconds, not ticks: half-second ticks take twice as many to cross it,
+ /// and the burst starts on the one that lands exactly on it.
+ [Fact]
+ public void OnTick_CountsTheInterval_InSeconds()
+ => Assert.Equal(
+ [DutyStep.Idle, DutyStep.Idle, DutyStep.Idle, DutyStep.Start],
+ Run(new DutyCycle(5, 2), 4, 0.5));
+
+ /// 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);
+ }
+
+ /// A reload that leaves the cycle on drops the burst in progress the same way, and the
+ /// burst length it brings is the one the next burst runs.
+ [Fact]
+ public void Apply_Drops_ABurstInProgress_WhenItIsReapplied()
+ {
+ DutyCycle cycle = new(30, 1);
+ Run(cycle, 5);
+ Assert.True(cycle.InBurst);
+
+ cycle.Apply(true, 2, 1);
+
+ Assert.False(cycle.InBurst);
+ Assert.Equal([DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.LastSample], Run(cycle, 4));
+ }
+
+ /// A new interval is counted from the reload, not from whatever the old one had already
+ /// waited.
+ [Fact]
+ public void Apply_Restarts_TheInterval_OfARunningCycle()
+ {
+ DutyCycle cycle = new(5, 10);
+ Run(cycle, 6);
+
+ cycle.Apply(true, 5, 10);
+
+ Assert.All(Run(cycle, 9), step => Assert.Equal(DutyStep.Idle, step));
+ Assert.Equal(DutyStep.Start, cycle.OnTick(1.0));
+ }
+
+ /// 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(300, cycle.BurstTicks);
+ Assert.Equal(1, cycle.IntervalSeconds);
+ }
+}
diff --git a/Pulse.Tests/TickAttributionTests.cs b/Pulse.Tests/TickAttributionTests.cs
index 7707699..41baaa1 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]
@@ -273,6 +250,29 @@ public void Apply_Drops_ABurstInProgress_WhenItIsSwitchedOff()
Assert.False(attribution.Profiling);
}
+ /// What a burst had folded when it was cut short, by a reload or by switching attribution
+ /// off and on, is thrown away with it: the next burst publishes its own samples and nothing of the
+ /// ones that were dropped.
+ [Fact]
+ public void Apply_Clears_WhatTheBurstInProgressHadFolded()
+ {
+ TickAttribution attribution = new(3, 1);
+
+ // The start, the warm-up and two of the three samples: cut short just before the last.
+ for (int tick = 0; tick < 4; tick++)
+ {
+ Assert.Null(attribution.OnTick(1.0, Tick(), Owners));
+ }
+
+ attribution.Apply(true, 3, 1);
+ AttributionBurst burst = Cycle(attribution, Tick(), Owners);
+ AttributionBurst fresh = Cycle(new TickAttribution(3, 1), Tick(), Owners);
+
+ Assert.Equal(fresh.Ticks, burst.Ticks);
+ Assert.Equal(fresh.BusySeconds, burst.BusySeconds, 12);
+ Assert.Equal(fresh.Seconds, burst.Seconds);
+ }
+
/// Re-enabling starts a fresh cycle, so the stale tree the profiler left behind while
/// it was off is discarded rather than folded into the first burst.
[Fact]
@@ -290,17 +290,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..db94502
--- /dev/null
+++ b/Pulse/DutyCycle.cs
@@ -0,0 +1,126 @@
+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;
+ }
+
+ 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..88b10d4 100644
--- a/Pulse/TickAttribution.cs
+++ b/Pulse/TickAttribution.cs
@@ -8,12 +8,12 @@
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.
+/// ends.
internal sealed class TickAttribution
{
/// Everything the engine spends on itself: its own server systems, the time between
@@ -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.
- /// 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()
+ /// Nothing accumulated: what a burst gathers so far is dropped.
+ /// Not seenMods: 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 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 535d829..5a59be1 100755
--- a/tools/mutation-check.sh
+++ b/tools/mutation-check.sh
@@ -52,7 +52,7 @@ mutate() { #