fix: preserve endurance failures and validate milestone reports

This commit is contained in:
2026-09-05 22:55:56 +08:00
parent 1e8e894dba
commit e0d1ea1022
3 changed files with 110 additions and 20 deletions
+24 -3
View File
@@ -60,8 +60,8 @@ public static class WechatStabilityAnalyzer
if (memoryGrowth > 256L * 1024 * 1024) findings.Add("working-set-growth-over-256mb");
if (handleGrowth > 500) findings.Add("handle-growth-over-500");
if (threadGrowth > 100) findings.Add("thread-growth-over-100");
if (samples.Any(sample => sample.LastSnapshotAt is { } lastSnapshot
&& sample.Timestamp - lastSnapshot > TimeSpan.FromMinutes(5))) findings.Add("listener-snapshot-gap-over-5min");
if (samples.Any(sample => sample.Timestamp - (sample.LastSnapshotAt ?? startedAt) > TimeSpan.FromMinutes(5)))
findings.Add("listener-snapshot-gap-over-5min");
return new WechatStabilityReport(
startedAt,
@@ -76,10 +76,31 @@ public static class WechatStabilityAnalyzer
findings);
}
public static WechatStabilityReport CheckRun(
WechatStabilityReport report, TimeSpan requested, TimeSpan observed, bool cancelled,
string? listenerFailure = null, string? samplingFailure = null)
{
var findings = report.Findings.ToList();
var completed = observed + TimeSpan.FromSeconds(1) >= requested && !cancelled;
if (!completed) findings.Add("duration-incomplete");
if (listenerFailure is not null) findings.Add("listener-failed:" + listenerFailure);
if (samplingFailure is not null) findings.Add("sampling-failed:" + samplingFailure);
if (report.Samples[^1].LastSnapshotAt is not { } snapshot
|| report.FinishedAt - snapshot > TimeSpan.FromSeconds(10)) findings.Add("listener-snapshot-stale");
return report with { Healthy = findings.Count == 0, Findings = findings, RequestedSeconds = requested.TotalSeconds,
ObservedSeconds = observed.TotalSeconds, CompletedDuration = completed };
}
public static async Task WriteJsonAsync(WechatStabilityReport report, string path, CancellationToken cancellationToken)
{
var fullPath = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllTextAsync(fullPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
var temporary = fullPath + ".tmp";
try
{
await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), cancellationToken);
File.Move(temporary, fullPath, overwrite: true);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
}
+26 -17
View File
@@ -19,7 +19,7 @@ public static class WechatStabilityRunner
{
if (duration <= TimeSpan.Zero)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "duration must be positive.");
if (sampleInterval < TimeSpan.FromSeconds(1) || sampleInterval > duration || duration / sampleInterval > 10_000)
if (sampleInterval < TimeSpan.FromSeconds(1) || sampleInterval > duration || Math.Ceiling(duration / sampleInterval) + 1 > 10_000)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "sampleInterval must be at least one second, no longer than duration, and produce at most 10000 samples.");
var directory = Path.GetFullPath(outputDirectory);
@@ -63,6 +63,11 @@ public static class WechatStabilityRunner
}, CancellationToken.None);
var samples = new List<WechatStabilitySample>();
WechatStabilityReport BuildReport(TimeSpan requested) => WechatStabilityAnalyzer.CheckRun(
WechatStabilityAnalyzer.Analyze(startedAt, DateTimeOffset.UtcNow, samples.ToArray(),
Interlocked.Read(ref messageEvents), Interlocked.Read(ref reconnectEvents)),
requested, elapsed.Elapsed, cancellationToken.IsCancellationRequested,
Volatile.Read(ref listenerError)?.GetType().Name, samplingError?.GetType().Name);
try
{
while (!linked.IsCancellationRequested)
@@ -71,15 +76,14 @@ public static class WechatStabilityRunner
Volatile.Read(ref listenerError), Interlocked.Read(ref snapshotTicks));
samples.Add(sample);
await writer.WriteLineAsync(JsonSerializer.Serialize(sample)).ConfigureAwait(false);
if (listener.IsCompleted && elapsed.Elapsed + TimeSpan.FromSeconds(1) < duration)
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener stopped before endurance duration elapsed.");
foreach (var hours in new[] { 8, 24, 72 })
{
if (elapsed.Elapsed < TimeSpan.FromHours(hours) || !milestones.Add(hours)) continue;
var milestone = WechatStabilityAnalyzer.Analyze(startedAt, DateTimeOffset.UtcNow, samples.ToArray(), messageEvents, reconnectEvents)
with { RequestedSeconds = hours * 3600, ObservedSeconds = elapsed.Elapsed.TotalSeconds, CompletedDuration = true };
var milestone = BuildReport(TimeSpan.FromHours(hours));
await WechatStabilityAnalyzer.WriteJsonAsync(milestone, Path.Combine(directory, $"report-{hours}h.json"), linked.Token).ConfigureAwait(false);
}
if (listener.IsCompleted && elapsed.Elapsed + TimeSpan.FromSeconds(1) < duration)
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener stopped before endurance duration elapsed.");
await Task.Delay(sampleInterval, linked.Token).ConfigureAwait(false);
}
}
@@ -91,20 +95,25 @@ public static class WechatStabilityRunner
await listener.ConfigureAwait(false);
}
samples.Add(CaptureSample(messageEvents, reconnectEvents, listenerError, snapshotTicks));
await writer.WriteLineAsync(JsonSerializer.Serialize(samples[^1])).ConfigureAwait(false);
var report = WechatStabilityAnalyzer.Analyze(startedAt, DateTimeOffset.UtcNow, samples, messageEvents, reconnectEvents);
var findings = report.Findings.ToList();
var completed = elapsed.Elapsed + TimeSpan.FromSeconds(1) >= duration && !cancellationToken.IsCancellationRequested;
if (!completed) findings.Add("duration-incomplete");
if (listenerError is not null) findings.Add("listener-failed:" + listenerError.GetType().Name);
if (samplingError is not null) findings.Add("sampling-failed:" + samplingError.GetType().Name);
if (samples[^1].LastSnapshotAt is null || DateTimeOffset.UtcNow - samples[^1].LastSnapshotAt > TimeSpan.FromSeconds(10))
findings.Add("listener-snapshot-stale");
report = report with { Healthy = findings.Count == 0, Findings = findings, RequestedSeconds = duration.TotalSeconds,
ObservedSeconds = elapsed.Elapsed.TotalSeconds, CompletedDuration = completed };
try { samples.Add(CaptureSample(messageEvents, reconnectEvents, listenerError, snapshotTicks)); }
catch (Exception exception)
{
samplingError ??= exception;
// Keep the failure explicit, even if the first and final probes both fail.
samples.Add(new WechatStabilitySample(DateTimeOffset.UtcNow, 0, 0, 0, 0, false,
[WxAgentErrorCode.InvalidOperationState], messageEvents, reconnectEvents));
}
try { await writer.WriteLineAsync(JsonSerializer.Serialize(samples[^1])).ConfigureAwait(false); }
catch (Exception exception) { samplingError ??= exception; }
var report = BuildReport(duration);
using var reportTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await WechatStabilityAnalyzer.WriteJsonAsync(report, Path.Combine(directory, "report.json"), reportTimeout.Token).ConfigureAwait(false);
foreach (var hours in new[] { 8, 24, 72 })
{
if (elapsed.Elapsed < TimeSpan.FromHours(hours) || !milestones.Add(hours)) continue;
await WechatStabilityAnalyzer.WriteJsonAsync(BuildReport(TimeSpan.FromHours(hours)),
Path.Combine(directory, $"report-{hours}h.json"), reportTimeout.Token).ConfigureAwait(false);
}
cancellationToken.ThrowIfCancellationRequested();
return report;
}
@@ -107,6 +107,66 @@ public sealed class WechatStabilityTests
Assert.Contains("thread-growth-over-100", report.Findings);
}
[Theory]
[InlineData(30, false, 0, true)]
[InlineData(15, false, 0, false)]
[InlineData(30, true, 0, false)]
[InlineData(30, false, 11, false)]
public void RunChecks_ApplyDurationCancellationAndHeartbeatToEveryReport(int seconds, bool cancelled, int snapshotAge, bool healthy)
{
var finish = DateTimeOffset.UtcNow;
var last = Sample(finish, 100, 10, 1) with { LastSnapshotAt = finish.AddSeconds(-snapshotAge) };
var report = WechatStabilityAnalyzer.Analyze(finish.AddSeconds(-seconds), finish, [last], 0, 0);
var checkedReport = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(seconds), cancelled);
Assert.Equal(healthy, checkedReport.Healthy);
Assert.Equal(seconds >= 29 && !cancelled, checkedReport.CompletedDuration);
}
[Fact]
public void RunChecks_RejectMissingHeartbeatAndBackgroundFailures()
{
var finish = DateTimeOffset.UtcNow;
var report = WechatStabilityAnalyzer.Analyze(finish.AddHours(-8), finish, [Sample(finish, 0, 0, 0)], 0, 0);
report = WechatStabilityAnalyzer.CheckRun(report, TimeSpan.FromHours(8), TimeSpan.FromHours(8), false,
"InvalidOperationException", "IOException");
Assert.False(report.Healthy);
Assert.Contains("listener-snapshot-stale", report.Findings);
Assert.Contains("listener-failed:InvalidOperationException", report.Findings);
Assert.Contains("sampling-failed:IOException", report.Findings);
}
[Fact]
public void StabilityAnalyzer_DoesNotHideMissingHeartbeatBeforeRecovery()
{
var started = DateTimeOffset.UtcNow;
var missing = Sample(started.AddMinutes(6), 100, 10, 1);
var recovered = missing with { Timestamp = started.AddMinutes(7), LastSnapshotAt = started.AddMinutes(7) };
var report = WechatStabilityAnalyzer.Analyze(started, recovered.Timestamp, [missing, recovered], 0, 1);
Assert.Contains("listener-snapshot-gap-over-5min", report.Findings);
Assert.False(report.Healthy);
}
[Fact]
public async Task ReportWrite_PreservesExistingEvidenceWhenCancelled()
{
var directory = Path.Combine(Path.GetTempPath(), "wx-stability-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
try
{
var path = Path.Combine(directory, "report.json");
await File.WriteAllTextAsync(path, "previous evidence");
var now = DateTimeOffset.UtcNow;
var report = WechatStabilityAnalyzer.Analyze(now, now, [Sample(now, 0, 0, 0)], 0, 0);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
WechatStabilityAnalyzer.WriteJsonAsync(report, path, cancelled.Token));
Assert.Equal("previous evidence", await File.ReadAllTextAsync(path));
Assert.False(File.Exists(path + ".tmp"));
}
finally { Directory.Delete(directory, recursive: true); }
}
private static WechatStabilitySample Sample(
DateTimeOffset timestamp,
long memory,