From e0d1ea10226233a7c9ea8b6d940f4fdac01da397 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 5 Sep 2026 22:55:56 +0800 Subject: [PATCH] fix: preserve endurance failures and validate milestone reports --- src/WxAgent.Core/WechatStability.cs | 27 ++++++++- src/WxAgent.Windows/WechatStabilityRunner.cs | 43 +++++++------ .../WechatStabilityTests.cs | 60 +++++++++++++++++++ 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/src/WxAgent.Core/WechatStability.cs b/src/WxAgent.Core/WechatStability.cs index 8915d74..c879e41 100644 --- a/src/WxAgent.Core/WechatStability.cs +++ b/src/WxAgent.Core/WechatStability.cs @@ -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); } } } diff --git a/src/WxAgent.Windows/WechatStabilityRunner.cs b/src/WxAgent.Windows/WechatStabilityRunner.cs index 67e897c..ff16ce3 100644 --- a/src/WxAgent.Windows/WechatStabilityRunner.cs +++ b/src/WxAgent.Windows/WechatStabilityRunner.cs @@ -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(); + 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; } diff --git a/tests/WxAgent.Core.Tests/WechatStabilityTests.cs b/tests/WxAgent.Core.Tests/WechatStabilityTests.cs index 71a5c1a..90dc9ea 100644 --- a/tests/WxAgent.Core.Tests/WechatStabilityTests.cs +++ b/tests/WxAgent.Core.Tests/WechatStabilityTests.cs @@ -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(() => + 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,