refactor: split node agent and Go control plane
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user