diff --git a/src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs b/src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs
index 0b88c304..8290a2d2 100644
--- a/src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs
+++ b/src/AcDream.Headless/Diagnostics/HeadlessDiagnosticWriter.cs
@@ -79,6 +79,8 @@ internal sealed class HeadlessDiagnosticWriter
{
ArgumentException.ThrowIfNullOrWhiteSpace(state);
HeadlessProcessContentSnapshot? content = snapshot.Content;
+ HeadlessProcessResourceEnvelopeSnapshot envelope =
+ snapshot.Envelope;
Write(new
{
kind = "resources",
@@ -111,6 +113,54 @@ internal sealed class HeadlessDiagnosticWriter
content.Value.MappedVirtualBytes,
content.Value.IsConverged,
},
+ resourceEnvelope = new
+ {
+ envelope.Ceilings.Profile,
+ envelope.HasRateSample,
+ envelope.WithinCeilings,
+ formula = new
+ {
+ envelope.Ceilings.SharedPrivateBytes,
+ envelope.Ceilings.PrivateBytesPerSession,
+ envelope.Ceilings.SharedWorkingSetBytes,
+ envelope.Ceilings.WorkingSetBytesPerSession,
+ envelope.Ceilings.SharedManagedLiveBytes,
+ envelope.Ceilings.ManagedLiveBytesPerSession,
+ envelope.Ceilings.SharedManagedHeapBytes,
+ envelope.Ceilings.ManagedHeapBytesPerSession,
+ envelope.Ceilings.SharedCpuCorePercent,
+ envelope.Ceilings.CpuCorePercentPerSession,
+ envelope.Ceilings.SharedHandleCount,
+ envelope.Ceilings.HandleCountPerSession,
+ envelope.Ceilings.SharedDescriptorCount,
+ envelope.Ceilings.DescriptorCountPerSession,
+ envelope.Ceilings.SharedSocketCount,
+ envelope.Ceilings.SocketCountPerSession,
+ },
+ limits = new
+ {
+ envelope.MaximumPrivateBytes,
+ envelope.MaximumWorkingSetBytes,
+ envelope.MaximumManagedLiveBytes,
+ envelope.MaximumManagedHeapBytes,
+ envelope.MaximumCpuCorePercent,
+ envelope.MaximumThreadCount,
+ envelope.MaximumHandleCount,
+ envelope.MaximumDescriptorCount,
+ envelope.MaximumSocketCount,
+ envelope.MaximumWaitsPerSecond,
+ envelope.MaximumCatchUpsPerSecond,
+ envelope.MaximumMeanLatenessMilliseconds,
+ envelope.MaximumLatenessMilliseconds,
+ },
+ observedRates = new
+ {
+ envelope.WaitsPerSecond,
+ envelope.TurnsPerSecond,
+ envelope.CatchUpsPerSecond,
+ },
+ envelope.Violations,
+ },
});
}
diff --git a/src/AcDream.Headless/Diagnostics/HeadlessProcessResourceEnvelope.cs b/src/AcDream.Headless/Diagnostics/HeadlessProcessResourceEnvelope.cs
new file mode 100644
index 00000000..6f5442a1
--- /dev/null
+++ b/src/AcDream.Headless/Diagnostics/HeadlessProcessResourceEnvelope.cs
@@ -0,0 +1,396 @@
+using AcDream.Headless.Hosting;
+
+namespace AcDream.Headless.Diagnostics;
+
+internal readonly record struct HeadlessProcessResourceCeilings(
+ string Profile,
+ long SharedPrivateBytes,
+ long PrivateBytesPerSession,
+ long SharedWorkingSetBytes,
+ long WorkingSetBytesPerSession,
+ long SharedManagedLiveBytes,
+ long ManagedLiveBytesPerSession,
+ long SharedManagedHeapBytes,
+ long ManagedHeapBytesPerSession,
+ double SharedCpuCorePercent,
+ double CpuCorePercentPerSession,
+ int MaximumThreadCount,
+ int SharedHandleCount,
+ int HandleCountPerSession,
+ int SharedDescriptorCount,
+ int DescriptorCountPerSession,
+ int SharedSocketCount,
+ int SocketCountPerSession,
+ double MaximumWaitsPerSecond,
+ double MaximumCatchUpsPerSecond,
+ double MaximumMeanLatenessMilliseconds,
+ double MaximumLatenessMilliseconds)
+{
+ internal const long MiB = 1024L * 1024L;
+
+ ///
+ /// Conservative K4 Linux envelope derived from the fixed-binary native
+ /// two-session Rynthid baseline and the automated 1/5/10/30-root gates.
+ /// It is diagnostic-only: violations are observable but never alter
+ /// simulation, network, or teardown behavior.
+ ///
+ internal static HeadlessProcessResourceCeilings K4Linux { get; } =
+ new(
+ Profile: "k4-linux-30-session",
+ SharedPrivateBytes: 224L * MiB,
+ PrivateBytesPerSession: 64L * MiB,
+ SharedWorkingSetBytes: 224L * MiB,
+ WorkingSetBytesPerSession: 64L * MiB,
+ SharedManagedLiveBytes: 64L * MiB,
+ ManagedLiveBytesPerSession: 48L * MiB,
+ SharedManagedHeapBytes: 64L * MiB,
+ ManagedHeapBytesPerSession: 48L * MiB,
+ SharedCpuCorePercent: 2d,
+ CpuCorePercentPerSession: 3d,
+ MaximumThreadCount: 48,
+ SharedHandleCount: 128,
+ HandleCountPerSession: 3,
+ SharedDescriptorCount: 128,
+ DescriptorCountPerSession: 3,
+ SharedSocketCount: 1,
+ SocketCountPerSession: 2,
+ MaximumWaitsPerSecond: 250d,
+ MaximumCatchUpsPerSecond: 1d,
+ MaximumMeanLatenessMilliseconds: 10d,
+ MaximumLatenessMilliseconds: 750d);
+
+ internal long MaximumPrivateBytes(int sessionCount) =>
+ Scale(SharedPrivateBytes, PrivateBytesPerSession, sessionCount);
+
+ internal long MaximumWorkingSetBytes(int sessionCount) =>
+ Scale(SharedWorkingSetBytes, WorkingSetBytesPerSession, sessionCount);
+
+ internal long MaximumManagedLiveBytes(int sessionCount) =>
+ Scale(
+ SharedManagedLiveBytes,
+ ManagedLiveBytesPerSession,
+ sessionCount);
+
+ internal long MaximumManagedHeapBytes(int sessionCount) =>
+ Scale(
+ SharedManagedHeapBytes,
+ ManagedHeapBytesPerSession,
+ sessionCount);
+
+ internal double MaximumCpuCorePercent(int sessionCount) =>
+ SharedCpuCorePercent
+ + CpuCorePercentPerSession * Math.Max(0, sessionCount);
+
+ internal int MaximumHandleCount(int sessionCount) =>
+ Scale(SharedHandleCount, HandleCountPerSession, sessionCount);
+
+ internal int MaximumDescriptorCount(int sessionCount) =>
+ Scale(
+ SharedDescriptorCount,
+ DescriptorCountPerSession,
+ sessionCount);
+
+ internal int MaximumSocketCount(int sessionCount) =>
+ Scale(SharedSocketCount, SocketCountPerSession, sessionCount);
+
+ private static long Scale(
+ long shared,
+ long perSession,
+ int sessionCount) =>
+ checked(shared + perSession * Math.Max(0, sessionCount));
+
+ private static int Scale(
+ int shared,
+ int perSession,
+ int sessionCount) =>
+ checked(shared + perSession * Math.Max(0, sessionCount));
+}
+
+internal readonly record struct HeadlessProcessResourceEnvelopeSnapshot(
+ HeadlessProcessResourceCeilings Ceilings,
+ bool HasRateSample,
+ bool WithinCeilings,
+ long MaximumPrivateBytes,
+ long MaximumWorkingSetBytes,
+ long MaximumManagedLiveBytes,
+ long MaximumManagedHeapBytes,
+ double MaximumCpuCorePercent,
+ int MaximumThreadCount,
+ int MaximumHandleCount,
+ int MaximumDescriptorCount,
+ int MaximumSocketCount,
+ double MaximumWaitsPerSecond,
+ double MaximumCatchUpsPerSecond,
+ double MaximumMeanLatenessMilliseconds,
+ double MaximumLatenessMilliseconds,
+ double WaitsPerSecond,
+ double TurnsPerSecond,
+ double CatchUpsPerSecond,
+ string[] Violations);
+
+///
+/// Stateful low-frequency evaluator for the committed K4 resource envelope.
+/// It owns only prior cumulative counters needed to calculate interval rates.
+///
+internal sealed class HeadlessProcessResourceEnvelopeEvaluator
+{
+ private readonly HeadlessProcessResourceCeilings _ceilings;
+ private long _previousWaitCount;
+ private long _previousTurnCount;
+ private long _previousCatchUpCount;
+ private bool _hasPreviousCounters;
+
+ internal HeadlessProcessResourceEnvelopeEvaluator(
+ HeadlessProcessResourceCeilings? ceilings = null)
+ {
+ _ceilings = ceilings
+ ?? HeadlessProcessResourceCeilings.K4Linux;
+ }
+
+ internal HeadlessProcessResourceEnvelopeSnapshot Evaluate(
+ string state,
+ in HeadlessProcessUsageSnapshot process,
+ in HeadlessManagedUsageSnapshot managed,
+ in HeadlessSessionUsageSnapshot sessions,
+ in HeadlessSchedulerSnapshot scheduler,
+ HeadlessProcessContentSnapshot? content)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(state);
+ int sessionCount = sessions.ConfiguredCount;
+ long maximumPrivateBytes =
+ _ceilings.MaximumPrivateBytes(sessionCount);
+ long maximumWorkingSetBytes =
+ _ceilings.MaximumWorkingSetBytes(sessionCount);
+ long maximumManagedLiveBytes =
+ _ceilings.MaximumManagedLiveBytes(sessionCount);
+ long maximumManagedHeapBytes =
+ _ceilings.MaximumManagedHeapBytes(sessionCount);
+ double maximumCpuCorePercent =
+ _ceilings.MaximumCpuCorePercent(sessionCount);
+ int maximumHandleCount =
+ _ceilings.MaximumHandleCount(sessionCount);
+ int maximumDescriptorCount =
+ _ceilings.MaximumDescriptorCount(sessionCount);
+ int maximumSocketCount =
+ _ceilings.MaximumSocketCount(sessionCount);
+
+ bool hasRateSample =
+ _hasPreviousCounters
+ && process.SampleIntervalMilliseconds > 0d;
+ double intervalSeconds =
+ process.SampleIntervalMilliseconds / 1000d;
+ double waitsPerSecond = hasRateSample
+ ? Delta(scheduler.WaitCount, _previousWaitCount)
+ / intervalSeconds
+ : 0d;
+ double turnsPerSecond = hasRateSample
+ ? Delta(scheduler.TurnCount, _previousTurnCount)
+ / intervalSeconds
+ : 0d;
+ double catchUpsPerSecond = hasRateSample
+ ? Delta(
+ scheduler.CatchUpCollapseCount,
+ _previousCatchUpCount)
+ / intervalSeconds
+ : 0d;
+ _previousWaitCount = scheduler.WaitCount;
+ _previousTurnCount = scheduler.TurnCount;
+ _previousCatchUpCount = scheduler.CatchUpCollapseCount;
+ _hasPreviousCounters = true;
+
+ var violations = new List();
+ AddIfAbove(
+ violations,
+ "private-bytes",
+ process.PrivateBytes,
+ maximumPrivateBytes);
+ AddIfAbove(
+ violations,
+ "working-set-bytes",
+ process.WorkingSetBytes,
+ maximumWorkingSetBytes);
+ AddIfAbove(
+ violations,
+ "managed-live-bytes",
+ managed.LiveBytes,
+ maximumManagedLiveBytes);
+ AddIfAbove(
+ violations,
+ "managed-heap-bytes",
+ managed.HeapSizeBytes,
+ maximumManagedHeapBytes);
+ if (process.SampleIntervalMilliseconds > 0d)
+ {
+ AddIfAbove(
+ violations,
+ "cpu-core-percent",
+ process.CpuCorePercent,
+ maximumCpuCorePercent);
+ }
+ AddIfAbove(
+ violations,
+ "thread-count",
+ process.ThreadCount,
+ _ceilings.MaximumThreadCount);
+ AddIfSupportedAbove(
+ violations,
+ "handle-count",
+ process.HandleCount,
+ maximumHandleCount);
+ AddIfSupportedAbove(
+ violations,
+ "descriptor-count",
+ process.FileDescriptorCount,
+ maximumDescriptorCount);
+ AddIfSupportedAbove(
+ violations,
+ "socket-count",
+ process.SocketDescriptorCount,
+ maximumSocketCount);
+ if (hasRateSample)
+ {
+ AddIfAbove(
+ violations,
+ "scheduler-waits-per-second",
+ waitsPerSecond,
+ _ceilings.MaximumWaitsPerSecond);
+ AddIfAbove(
+ violations,
+ "scheduler-catchups-per-second",
+ catchUpsPerSecond,
+ _ceilings.MaximumCatchUpsPerSecond);
+ }
+ AddIfAbove(
+ violations,
+ "scheduler-mean-lateness-milliseconds",
+ scheduler.MeanLatenessMilliseconds,
+ _ceilings.MaximumMeanLatenessMilliseconds);
+ AddIfAbove(
+ violations,
+ "scheduler-maximum-lateness-milliseconds",
+ scheduler.MaximumLatenessMilliseconds,
+ _ceilings.MaximumLatenessMilliseconds);
+ if (sessions.FaultedCount != 0
+ || scheduler.FaultedSessionCount != 0)
+ {
+ violations.Add("faulted-session-debt");
+ }
+
+ if (string.Equals(
+ state,
+ "disposed",
+ StringComparison.Ordinal))
+ {
+ EvaluateTerminal(
+ violations,
+ sessions,
+ content);
+ }
+ else
+ {
+ EvaluateRunning(
+ violations,
+ sessions,
+ scheduler,
+ content);
+ }
+
+ return new HeadlessProcessResourceEnvelopeSnapshot(
+ _ceilings,
+ hasRateSample,
+ violations.Count == 0,
+ maximumPrivateBytes,
+ maximumWorkingSetBytes,
+ maximumManagedLiveBytes,
+ maximumManagedHeapBytes,
+ maximumCpuCorePercent,
+ _ceilings.MaximumThreadCount,
+ maximumHandleCount,
+ maximumDescriptorCount,
+ maximumSocketCount,
+ _ceilings.MaximumWaitsPerSecond,
+ _ceilings.MaximumCatchUpsPerSecond,
+ _ceilings.MaximumMeanLatenessMilliseconds,
+ _ceilings.MaximumLatenessMilliseconds,
+ waitsPerSecond,
+ turnsPerSecond,
+ catchUpsPerSecond,
+ [.. violations]);
+ }
+
+ private static void EvaluateRunning(
+ List violations,
+ in HeadlessSessionUsageSnapshot sessions,
+ in HeadlessSchedulerSnapshot scheduler,
+ HeadlessProcessContentSnapshot? content)
+ {
+ if (scheduler.SessionCount != sessions.ConfiguredCount)
+ violations.Add("scheduler-session-count");
+ if (sessions.HostLeaseCount != sessions.ConfiguredCount)
+ violations.Add("runtime-host-lease-debt");
+ if (sessions.InWorldCount + sessions.PendingReconnectCount
+ < sessions.ConfiguredCount)
+ {
+ violations.Add("unaccounted-session-lifecycle");
+ }
+ if (content is { } current
+ && (current.LeaseCount != sessions.ConfiguredCount
+ || current.IsDisposeRequested
+ || current.IsDisposed))
+ {
+ violations.Add("shared-content-lease-debt");
+ }
+ }
+
+ private static void EvaluateTerminal(
+ List violations,
+ in HeadlessSessionUsageSnapshot sessions,
+ HeadlessProcessContentSnapshot? content)
+ {
+ if (sessions.InWorldCount != 0
+ || sessions.PendingReconnectCount != 0
+ || sessions.ConvergedRuntimeCount
+ != sessions.ConfiguredCount
+ || sessions.EntityCount != 0
+ || sessions.InventoryObjectCount != 0
+ || sessions.HostLeaseCount != 0)
+ {
+ violations.Add("runtime-terminal-debt");
+ }
+ if (content is { IsConverged: false })
+ violations.Add("shared-content-terminal-debt");
+ }
+
+ private static long Delta(long current, long previous) =>
+ Math.Max(0L, current - previous);
+
+ private static void AddIfSupportedAbove(
+ List violations,
+ string name,
+ int value,
+ int ceiling)
+ {
+ if (value >= 0)
+ AddIfAbove(violations, name, value, ceiling);
+ }
+
+ private static void AddIfAbove(
+ List violations,
+ string name,
+ long value,
+ long ceiling)
+ {
+ if (value > ceiling)
+ violations.Add(name);
+ }
+
+ private static void AddIfAbove(
+ List violations,
+ string name,
+ double value,
+ double ceiling)
+ {
+ if (!double.IsFinite(value) || value > ceiling)
+ violations.Add(name);
+ }
+}
diff --git a/src/AcDream.Headless/Diagnostics/HeadlessProcessResourceSampler.cs b/src/AcDream.Headless/Diagnostics/HeadlessProcessResourceSampler.cs
index b28a119d..5e092b0f 100644
--- a/src/AcDream.Headless/Diagnostics/HeadlessProcessResourceSampler.cs
+++ b/src/AcDream.Headless/Diagnostics/HeadlessProcessResourceSampler.cs
@@ -48,7 +48,8 @@ internal readonly record struct HeadlessProcessResourceSnapshot(
HeadlessManagedUsageSnapshot Managed,
HeadlessSessionUsageSnapshot Sessions,
HeadlessSchedulerSnapshot Scheduler,
- HeadlessProcessContentSnapshot? Content);
+ HeadlessProcessContentSnapshot? Content,
+ HeadlessProcessResourceEnvelopeSnapshot Envelope);
///
/// Captures one low-frequency process-wide resource sample. The sampler owns
@@ -58,6 +59,7 @@ internal sealed class HeadlessProcessResourceSampler : IDisposable
{
private readonly TimeProvider _timeProvider;
private readonly Process _process;
+ private readonly HeadlessProcessResourceEnvelopeEvaluator _envelope;
private long _sequence;
private long _previousTimestamp;
private long _previousProcessorTicks;
@@ -70,14 +72,17 @@ internal sealed class HeadlessProcessResourceSampler : IDisposable
{
_timeProvider = timeProvider ?? TimeProvider.System;
_process = process ?? Process.GetCurrentProcess();
+ _envelope = new HeadlessProcessResourceEnvelopeEvaluator();
}
internal HeadlessProcessResourceSnapshot Capture(
+ string state,
IReadOnlyList sessions,
HeadlessSchedulerSnapshot scheduler,
HeadlessProcessContentSnapshot? content)
{
ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentException.ThrowIfNullOrWhiteSpace(state);
ArgumentNullException.ThrowIfNull(sessions);
long now = _timeProvider.GetTimestamp();
@@ -179,6 +184,14 @@ internal sealed class HeadlessProcessResourceSampler : IDisposable
entityCount,
inventoryObjectCount,
hostLeaseCount);
+ HeadlessProcessResourceEnvelopeSnapshot envelope =
+ _envelope.Evaluate(
+ state,
+ process,
+ managed,
+ sessionUsage,
+ scheduler,
+ content);
return new HeadlessProcessResourceSnapshot(
checked(++_sequence),
now,
@@ -186,7 +199,8 @@ internal sealed class HeadlessProcessResourceSampler : IDisposable
managed,
sessionUsage,
scheduler,
- content);
+ content,
+ envelope);
}
public void Dispose()
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
index 629c1a86..4008d259 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
@@ -233,6 +233,7 @@ internal sealed class HeadlessProcessHost : IDisposable
HeadlessProcessContentOwner? content)
{
HeadlessProcessResourceSnapshot snapshot = resources.Capture(
+ state,
_sessions,
scheduler,
content?.CaptureSnapshot());
diff --git a/tests/AcDream.Headless.Tests/HeadlessProcessResourceEnvelopeTests.cs b/tests/AcDream.Headless.Tests/HeadlessProcessResourceEnvelopeTests.cs
new file mode 100644
index 00000000..4d04b384
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessProcessResourceEnvelopeTests.cs
@@ -0,0 +1,322 @@
+using AcDream.Headless.Diagnostics;
+using AcDream.Headless.Hosting;
+
+namespace AcDream.Headless.Tests;
+
+public sealed class HeadlessProcessResourceEnvelopeTests
+{
+ [Fact]
+ public void K4ProfileScalesOnlyPerSessionDimensions()
+ {
+ HeadlessProcessResourceCeilings ceilings =
+ HeadlessProcessResourceCeilings.K4Linux;
+
+ Assert.Equal(
+ 288L * HeadlessProcessResourceCeilings.MiB,
+ ceilings.MaximumPrivateBytes(1));
+ Assert.Equal(
+ 352L * HeadlessProcessResourceCeilings.MiB,
+ ceilings.MaximumPrivateBytes(2));
+ Assert.Equal(
+ 2144L * HeadlessProcessResourceCeilings.MiB,
+ ceilings.MaximumPrivateBytes(30));
+ Assert.Equal(8d, ceilings.MaximumCpuCorePercent(2));
+ Assert.Equal(92d, ceilings.MaximumCpuCorePercent(30));
+ Assert.Equal(
+ 160L * HeadlessProcessResourceCeilings.MiB,
+ ceilings.MaximumManagedHeapBytes(2));
+ Assert.Equal(48, ceilings.MaximumThreadCount);
+ Assert.Equal(5, ceilings.MaximumSocketCount(2));
+ Assert.Equal(61, ceilings.MaximumSocketCount(30));
+ }
+
+ [Fact]
+ public void FixedBinaryTwoSessionRynthidSamplePasses()
+ {
+ var evaluator =
+ new HeadlessProcessResourceEnvelopeEvaluator();
+ _ = evaluator.Evaluate(
+ "running-start",
+ Process(
+ privateBytes: 207_966_208L,
+ workingSetBytes: 190_509_056L),
+ Managed(71_036_416L),
+ RunningSessions(2),
+ Scheduler(2),
+ RunningContent(2));
+
+ HeadlessProcessResourceEnvelopeSnapshot result =
+ evaluator.Evaluate(
+ "periodic",
+ Process(
+ privateBytes: 313_847_808L,
+ workingSetBytes: 302_817_280L,
+ cpuCorePercent: 6.2811d,
+ sampleIntervalMilliseconds: 30_000d,
+ threadCount: 17,
+ handleCount: 115,
+ descriptorCount: 116,
+ socketCount: 3),
+ Managed(122_179_536L),
+ RunningSessions(2),
+ Scheduler(
+ 2,
+ waitCount: 2918L,
+ turnCount: 3974L,
+ catchUpCount: 2L,
+ lateCount: 3974L,
+ meanLatenessMilliseconds: 3.31d,
+ maximumLatenessMilliseconds: 302.16d),
+ RunningContent(2));
+
+ Assert.True(result.HasRateSample);
+ Assert.True(result.WithinCeilings);
+ Assert.Empty(result.Violations);
+ Assert.Equal(97.2667d, result.WaitsPerSecond, precision: 4);
+ Assert.Equal(132.4667d, result.TurnsPerSecond, precision: 4);
+ Assert.Equal(0.0667d, result.CatchUpsPerSecond, precision: 4);
+ }
+
+ [Fact]
+ public void EveryResourceAndRuntimeDebtIsNamed()
+ {
+ var evaluator =
+ new HeadlessProcessResourceEnvelopeEvaluator();
+ _ = evaluator.Evaluate(
+ "running-start",
+ Process(1L, 1L),
+ Managed(1L),
+ RunningSessions(2),
+ Scheduler(2),
+ RunningContent(2));
+ HeadlessProcessResourceCeilings ceilings =
+ HeadlessProcessResourceCeilings.K4Linux;
+ var sessions = new HeadlessSessionUsageSnapshot(
+ ConfiguredCount: 2,
+ InWorldCount: 0,
+ FaultedCount: 1,
+ PendingReconnectCount: 0,
+ ConvergedRuntimeCount: 0,
+ EntityCount: 0,
+ InventoryObjectCount: 0,
+ HostLeaseCount: 0);
+
+ HeadlessProcessResourceEnvelopeSnapshot result =
+ evaluator.Evaluate(
+ "periodic",
+ Process(
+ ceilings.MaximumPrivateBytes(2) + 1L,
+ ceilings.MaximumWorkingSetBytes(2) + 1L,
+ cpuCorePercent:
+ ceilings.MaximumCpuCorePercent(2) + 0.01d,
+ sampleIntervalMilliseconds: 30_000d,
+ threadCount: ceilings.MaximumThreadCount + 1,
+ handleCount:
+ ceilings.MaximumHandleCount(2) + 1,
+ descriptorCount:
+ ceilings.MaximumDescriptorCount(2) + 1,
+ socketCount:
+ ceilings.MaximumSocketCount(2) + 1),
+ Managed(
+ ceilings.MaximumManagedLiveBytes(2) + 1L),
+ sessions,
+ Scheduler(
+ sessionCount: 1,
+ waitCount: 7501L,
+ turnCount: 1L,
+ catchUpCount: 31L,
+ lateCount: 1L,
+ meanLatenessMilliseconds: 10.01d,
+ maximumLatenessMilliseconds: 750.01d,
+ faultedCount: 1),
+ new HeadlessProcessContentSnapshot(
+ 0,
+ IsDisposeRequested: true,
+ IsDisposed: false,
+ MappedVirtualBytes: 1L));
+
+ Assert.False(result.WithinCeilings);
+ string[] expected =
+ [
+ "private-bytes",
+ "working-set-bytes",
+ "managed-live-bytes",
+ "managed-heap-bytes",
+ "cpu-core-percent",
+ "thread-count",
+ "handle-count",
+ "descriptor-count",
+ "socket-count",
+ "scheduler-waits-per-second",
+ "scheduler-catchups-per-second",
+ "scheduler-mean-lateness-milliseconds",
+ "scheduler-maximum-lateness-milliseconds",
+ "faulted-session-debt",
+ "scheduler-session-count",
+ "runtime-host-lease-debt",
+ "unaccounted-session-lifecycle",
+ "shared-content-lease-debt",
+ ];
+ Assert.Equal(expected, result.Violations);
+ }
+
+ [Fact]
+ public void DisposedSampleRequiresExactRuntimeAndContentConvergence()
+ {
+ var failing =
+ new HeadlessProcessResourceEnvelopeEvaluator();
+ HeadlessProcessResourceEnvelopeSnapshot debt =
+ failing.Evaluate(
+ "disposed",
+ Process(1L, 1L),
+ Managed(1L),
+ RunningSessions(2),
+ Scheduler(2),
+ RunningContent(2));
+
+ Assert.False(debt.WithinCeilings);
+ Assert.Contains("runtime-terminal-debt", debt.Violations);
+ Assert.Contains(
+ "shared-content-terminal-debt",
+ debt.Violations);
+
+ var passing =
+ new HeadlessProcessResourceEnvelopeEvaluator();
+ HeadlessProcessResourceEnvelopeSnapshot converged =
+ passing.Evaluate(
+ "disposed",
+ Process(1L, 1L),
+ Managed(1L),
+ new HeadlessSessionUsageSnapshot(
+ ConfiguredCount: 2,
+ InWorldCount: 0,
+ FaultedCount: 0,
+ PendingReconnectCount: 0,
+ ConvergedRuntimeCount: 2,
+ EntityCount: 0,
+ InventoryObjectCount: 0,
+ HostLeaseCount: 0),
+ Scheduler(2),
+ new HeadlessProcessContentSnapshot(
+ 0,
+ IsDisposeRequested: true,
+ IsDisposed: true,
+ MappedVirtualBytes: 0L));
+
+ Assert.True(converged.WithinCeilings);
+ Assert.Empty(converged.Violations);
+ }
+
+ [Fact]
+ public void NonFiniteResourceRatesFailClosed()
+ {
+ var evaluator =
+ new HeadlessProcessResourceEnvelopeEvaluator();
+
+ HeadlessProcessResourceEnvelopeSnapshot result =
+ evaluator.Evaluate(
+ "periodic",
+ Process(
+ 1L,
+ 1L,
+ cpuCorePercent: double.NaN,
+ sampleIntervalMilliseconds: 30_000d),
+ Managed(1L),
+ RunningSessions(1),
+ Scheduler(1),
+ RunningContent(1));
+
+ Assert.False(result.WithinCeilings);
+ Assert.Contains("cpu-core-percent", result.Violations);
+ }
+
+ private static HeadlessProcessUsageSnapshot Process(
+ long privateBytes,
+ long workingSetBytes,
+ double cpuCorePercent = 0d,
+ double sampleIntervalMilliseconds = 0d,
+ int threadCount = 16,
+ int handleCount = 113,
+ int descriptorCount = 112,
+ int socketCount = 3) =>
+ new(
+ WorkingSetBytes: workingSetBytes,
+ PrivateBytes: privateBytes,
+ VirtualBytes: 30_000_000_000L,
+ TotalProcessorTimeTicks: 0L,
+ SampleIntervalMilliseconds: sampleIntervalMilliseconds,
+ CpuCorePercent: cpuCorePercent,
+ CpuMachinePercent: cpuCorePercent / 16d,
+ ProcessorCount: 16,
+ ThreadCount: threadCount,
+ HandleCount: handleCount,
+ FileDescriptorCount: descriptorCount,
+ SocketDescriptorCount: socketCount);
+
+ private static HeadlessManagedUsageSnapshot Managed(
+ long liveBytes) =>
+ new(
+ LiveBytes: liveBytes,
+ HeapSizeBytes: liveBytes,
+ FragmentedBytes: 0L,
+ CommittedBytes: liveBytes,
+ TotalAllocatedBytes: liveBytes,
+ MemoryLoadBytes: liveBytes,
+ HighMemoryLoadThresholdBytes: long.MaxValue,
+ PauseTimePercentage: 0d,
+ Gen0Collections: 0,
+ Gen1Collections: 0,
+ Gen2Collections: 0);
+
+ private static HeadlessSessionUsageSnapshot RunningSessions(
+ int sessionCount) =>
+ new(
+ ConfiguredCount: sessionCount,
+ InWorldCount: sessionCount,
+ FaultedCount: 0,
+ PendingReconnectCount: 0,
+ ConvergedRuntimeCount: 0,
+ EntityCount: sessionCount,
+ InventoryObjectCount: sessionCount,
+ HostLeaseCount: sessionCount);
+
+ private static HeadlessSchedulerSnapshot Scheduler(
+ int sessionCount,
+ long waitCount = 0L,
+ long turnCount = 0L,
+ long catchUpCount = 0L,
+ long lateCount = 0L,
+ double meanLatenessMilliseconds = 0d,
+ double maximumLatenessMilliseconds = 0d,
+ int faultedCount = 0)
+ {
+ const long frequency = TimeSpan.TicksPerSecond;
+ long totalLatenessTicks = checked((long)(
+ meanLatenessMilliseconds
+ * TimeSpan.TicksPerMillisecond
+ * lateCount));
+ long maximumLatenessTicks = checked((long)(
+ maximumLatenessMilliseconds
+ * TimeSpan.TicksPerMillisecond));
+ return new HeadlessSchedulerSnapshot(
+ SessionCount: sessionCount,
+ WaitCount: waitCount,
+ TurnCount: turnCount,
+ CatchUpCollapseCount: catchUpCount,
+ LateDeadlineCount: lateCount,
+ TotalLatenessTicks: totalLatenessTicks,
+ MaximumLatenessTicks: maximumLatenessTicks,
+ TimestampFrequency: frequency,
+ ActiveSessionCount: sessionCount - faultedCount,
+ FaultedSessionCount: faultedCount,
+ NextDeadline: 0L);
+ }
+
+ private static HeadlessProcessContentSnapshot RunningContent(
+ int sessionCount) =>
+ new(
+ LeaseCount: sessionCount,
+ IsDisposeRequested: false,
+ IsDisposed: false,
+ MappedVirtualBytes: 29_908_271_024L);
+}
diff --git a/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs b/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
index a0a418d8..532251a7 100644
--- a/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessProcessSchedulerTests.cs
@@ -345,6 +345,14 @@ public sealed class HeadlessProcessSchedulerTests
"\"configuredCount\":2",
diagnostics.ToString(),
StringComparison.Ordinal);
+ Assert.Contains(
+ "\"resourceEnvelope\":",
+ diagnostics.ToString(),
+ StringComparison.Ordinal);
+ Assert.Contains(
+ "\"profile\":\"k4-linux-30-session\"",
+ diagnostics.ToString(),
+ StringComparison.Ordinal);
host.Dispose();
@@ -356,6 +364,10 @@ public sealed class HeadlessProcessSchedulerTests
"\"convergedRuntimeCount\":2",
diagnostics.ToString(),
StringComparison.Ordinal);
+ Assert.Contains(
+ "\"violations\":",
+ diagnostics.ToString(),
+ StringComparison.Ordinal);
}
[Fact]