202 lines
9.9 KiB
C#
202 lines
9.9 KiB
C#
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using FlaUI.Core.AutomationElements;
|
|
using FlaUI.Core.Definitions;
|
|
using FlaUI.Core.Input;
|
|
using FlaUI.Core.WindowsAPI;
|
|
using FlaUI.UIA3;
|
|
using WxAgent.Core;
|
|
|
|
namespace WxAgent.Windows;
|
|
|
|
public static class WechatStabilityRunner
|
|
{
|
|
public static async Task<WechatStabilityReport> RunAsync(
|
|
TimeSpan duration,
|
|
TimeSpan sampleInterval,
|
|
string outputDirectory,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (duration <= TimeSpan.Zero)
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "duration must be positive.");
|
|
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);
|
|
Directory.CreateDirectory(directory);
|
|
var samplesPath = Path.Combine(directory, "samples.jsonl");
|
|
var checkpointPath = Path.Combine(directory, "listener-checkpoint.json");
|
|
await using var sampleStream = new FileStream(samplesPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
|
|
await using var writer = new StreamWriter(sampleStream) { AutoFlush = true };
|
|
|
|
var startedAt = DateTimeOffset.UtcNow;
|
|
long messageEvents = 0;
|
|
long reconnectEvents = 0;
|
|
long snapshotTicks = 0;
|
|
Exception? listenerError = null;
|
|
Exception? samplingError = null;
|
|
var elapsed = Stopwatch.StartNew();
|
|
var milestones = new HashSet<int>();
|
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
linked.CancelAfter(duration);
|
|
|
|
var listener = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await foreach (var messageEvent in WechatChatClient.ListenEventsAsync(duration, checkpointPath, linked.Token,
|
|
snapshotObserved: () => Interlocked.Exchange(ref snapshotTicks, DateTimeOffset.UtcNow.Ticks)))
|
|
{
|
|
if (messageEvent.Kind == MessageEventKind.MessageReceived)
|
|
Interlocked.Increment(ref messageEvents);
|
|
else if (messageEvent.Kind == MessageEventKind.Reconnected)
|
|
Interlocked.Increment(ref reconnectEvents);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (linked.IsCancellationRequested)
|
|
{
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Volatile.Write(ref listenerError, exception);
|
|
}
|
|
}, 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)
|
|
{
|
|
var sample = CaptureSample(Interlocked.Read(ref messageEvents), Interlocked.Read(ref reconnectEvents),
|
|
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 = BuildReport(TimeSpan.FromHours(hours));
|
|
await WechatStabilityAnalyzer.WriteJsonAsync(milestone, Path.Combine(directory, $"report-{hours}h.json"), linked.Token).ConfigureAwait(false);
|
|
}
|
|
await Task.Delay(sampleInterval, linked.Token).ConfigureAwait(false);
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (linked.IsCancellationRequested) { }
|
|
catch (Exception exception) { samplingError = exception; }
|
|
finally
|
|
{
|
|
linked.Cancel();
|
|
await listener.ConfigureAwait(false);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static WechatStabilitySample CaptureSample(long messageEvents, long reconnectEvents, Exception? listenerError, long snapshotTicks)
|
|
{
|
|
using var agent = Process.GetCurrentProcess();
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
|
var doctor = WechatDoctor.Run(timeout.Token);
|
|
var errors = doctor.Errors.ToList();
|
|
if (listenerError is not null) errors.Add(WxAgentErrorCode.InvalidOperationState);
|
|
return new WechatStabilitySample(
|
|
DateTimeOffset.UtcNow,
|
|
doctor.Processes.Count,
|
|
doctor.Processes.Sum(process => process.WorkingSetBytes),
|
|
doctor.Processes.Sum(process => process.HandleCount),
|
|
doctor.Processes.Sum(process => process.ThreadCount),
|
|
doctor.WindowFound,
|
|
errors.Distinct().ToArray(),
|
|
messageEvents,
|
|
reconnectEvents,
|
|
agent.WorkingSet64,
|
|
agent.HandleCount,
|
|
agent.Threads.Count,
|
|
snapshotTicks == 0 ? null : new DateTimeOffset(snapshotTicks, TimeSpan.Zero));
|
|
}
|
|
}
|
|
|
|
public static partial class WechatChatClient
|
|
{
|
|
public static async Task<WechatOperationResult> RecoverUiAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
await CommandQueue.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
using var automation = new UIA3Automation();
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var main = AttachWindow(automation, cancellationToken);
|
|
if (FindByAutomationId(main, WechatLocators.SessionList) is not null)
|
|
return WechatOperationResult.Ok("WeChat UI is already on chats");
|
|
|
|
// Escape on WeChat's main page hides the application to the tray; never press it blindly.
|
|
var tabbar = FindByAutomationId(main, WechatLocators.MainTabBar) ?? main;
|
|
var chats = tabbar.FindFirstDescendant(cf => cf.ByName("微信").And(cf.ByControlType(ControlType.Button)))
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The WeChat navigation button was not found.");
|
|
InvokeOrClick(chats);
|
|
await Task.Delay(200, cancellationToken).ConfigureAwait(false);
|
|
if (FindByAutomationId(main, WechatLocators.SessionList) is null) chats.Click();
|
|
|
|
for (var attempt = 0; attempt < 30; attempt++)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var fresh = WechatDoctor.FindWechatWindow(automation);
|
|
if (fresh is not null && FindByAutomationId(fresh, WechatLocators.SessionList) is not null)
|
|
return WechatOperationResult.Ok("WeChat UI recovered to chats");
|
|
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "WeChat did not recover to the chats page.");
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch (WxAgentException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
throw new WxAgentException(
|
|
WxAgentErrorCode.InvalidOperationState,
|
|
$"WeChat UI recovery failed: {exception.GetType().Name}: {exception.Message}",
|
|
exception);
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
|
|
private static void InvokeOrClick(AutomationElement element)
|
|
{
|
|
if (element.Patterns.SelectionItem.IsSupported) element.Patterns.SelectionItem.Pattern.Select();
|
|
if (element.Patterns.LegacyIAccessible.IsSupported) element.Patterns.LegacyIAccessible.Pattern.DoDefaultAction();
|
|
else if (element.Patterns.Invoke.IsSupported) element.Patterns.Invoke.Pattern.Invoke();
|
|
else element.Click();
|
|
}
|
|
}
|