Files
wx-win-agent/node-agent/WxAgent.Windows/WechatStabilityRunner.cs
T

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();
}
}