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