fix: restore existing WeChat instance and add M6 validation tools

This commit is contained in:
2026-09-05 22:49:18 +08:00
parent 1bd0344e8b
commit 1e8e894dba
23 changed files with 1357 additions and 48 deletions
@@ -0,0 +1,58 @@
# M6 稳定性与兼容性——进行中
**M6 尚未完成。** 短时测试不能替代开发计划 §11.3 的 8/24/72 小时测试,也不能替代 Windows 11 和不同 DPI 的真机验收。每项未验证内容继续保留,不按“接口存在/编译通过”结项。
## 已实现并验证
- `recover-ui`:恢复原有微信实例,不启动 `Weixin.exe`、不模拟登录。
- 优先使用 FlaUI UIA3 的通知区域按钮。
- Windows 10 通知区域未通过 UIA3 暴露图标时,使用公开 MSAA 接口定位,再按真实图标边界双击。不读写 Explorer 内存、不猜测托盘回调消息。
- 不再使用 `ShowWindow` 显示灰色 Qt 外壳;不对主窗口盲按 Esc(这会再次隐藏到托盘)。
- 最小化、正常关闭到托盘两种状态均验证了:状态实际发生、恢复同一 HWND 和 PID、`MainView/session_list/chat_message_list` 存在。
- 共享窗口绑定采用以上恢复路径,供普通业务调用与监听复用。
- 监听在统一命令队列内校验目标会话,再获取快照;避免将其他会话内容标成文件传输助手消息。
- `listener-smoke`:仅向文件传输助手发送一条唯一测试消息;收到一次事件,加载 checkpoint 重启监听后不重复上报。
- UI 树遍历有深度、节点数和 runtime ID 去重保护。截断/重复节点会明确失败,保存部分快照而非栈溢出或假报完整。
- `diagnose --output <dir> --baseline <ui-tree.json>`:脱敏快照、操作系统/微信版本、会话、DPI/窗口边界、进程资源、必需控件及基线差异、关联 ID。
- `endurance`:JSONL 持续落盘;统计微信及 Agent 的内存/句柄/线程、消息事件、重连事件、最近有效快照。记录请求/实测时长;未跑满、监听提前退出、快照停滞、最终错误均不算健康。输出目录禁止覆盖。
## 当前已取得的证据
环境:`DESKTOP-EGI7QCK`,Windows 10 build 19044,微信 4.1.13.63,Session 1,窗口 DPI 96(100%)。
- Linux Core:49/49 通过;Release 全量构建 0 警告、0 错误;Windows self-contained 发布通过。
- Windows 8 个命令均 exit 0:recover、doctor、inspect、smoke、recovery-smoke、listener-smoke、30 秒 stability-smoke、diagnose。
- listener:MatchingEvents=1、EventsAfterRestart=0、Snapshots=5、CheckpointSaved=true。
- 基线对比:Added/Removed/MissingRequiredControls 均为空。
- 30 秒短测:ObservedSeconds=30.0794228、CompletedDuration=true、7 个样本、有效监听快照持续更新、Findings 为空。
- 这仅证明上述 **30 秒** 样本,不证明长时间无泄漏。
本次发布包 SHA-256:`5899FD7A07E85B9C20679E06012CB0ED494F96916A8C58F81ACFC533286CA009`。
脱敏摘要见 `evidence/M6-20260905/`;完整本地证据位于 `artifacts/m6-final-validation/m6-validation-20260905T143832/`。历史失败证据不作为通过依据。
## 尚待完成的验收
- [ ] 8/24/72 小时真实监听及长时资源趋势。
- [ ] 网络中断/恢复、20 次微信重启后的恢复(不能绕过登录或锁屏)。
- [ ] 常见弹窗、会话切换的专门恢复回归。
- [ ] 多会话/多子窗口消息归属与去重、长列表滚动场景。
- [ ] 重新核对 M2 连续 500 条发送无重复与 M3 8 小时监听证据;未找到对应证据前仍未验收。
- [ ] Windows 10 多 DPI 与 Windows 11 兼容矩阵。目前仅证明 Windows 10 / 96 DPI。
- [ ] 汇总此前阶段尚未真机覆盖的回归项;M4/M5 中仅编译/门禁验证的写操作不冒充端到端验收。
- [ ] 全部要求通过后更新阶段结论,并提交、PUSH 阶段验收记录。
用户明确跳过无麦克风环境下的 SendAudio;不扩展该豁免到其他功能。
## 重复执行
在已登录且未锁定、微信所在的用户会话执行;不要在 Session 0 运行。
```powershell
.\Invoke-M6Validation.ps1 -Executable .\WxAgent.Host.exe -OutputDirectory C:\Users\Rogee\wx-agent\validation-new
.\Invoke-Endurance.ps1 -Executable .\WxAgent.Host.exe -OutputDirectory C:\Users\Rogee\wx-agent\endurance-new -Hours 72 -SampleSeconds 60
```
脚本位于 `scripts/windows/`。每次使用新输出目录并保留 BinarySha256、请求/实测时长、退出码及全部样本。72 小时运行会在经过 8/24 小时时保存阶段快照,最终 `metrics/report.json` 才是最终结果。
所有早期调试用 `WxAgent*` 一次性定时任务已禁用,防止它们在 23:59 自动重发测试消息;后续任务仅手动触发,不配置未来重复触发器。
@@ -0,0 +1,7 @@
{
"Sent": true,
"MatchingEvents": 1,
"EventsAfterRestart": 0,
"Snapshots": 5,
"CheckpointSaved": true
}
@@ -0,0 +1,18 @@
[
{
"State": "minimized",
"TransitionObserved": true,
"SameInstance": true,
"MainViewFound": true,
"SessionsFound": true,
"MessagesFound": true
},
{
"State": "tray-hidden",
"TransitionObserved": true,
"SameInstance": true,
"MainViewFound": true,
"SessionsFound": true,
"MessagesFound": true
}
]
@@ -0,0 +1,121 @@
{
"StartedAt": "2026-09-05T14:39:32.0610704+00:00",
"FinishedAt": "2026-09-05T14:40:02.1378009+00:00",
"Samples": [
{
"Timestamp": "2026-09-05T14:39:32.2343907+00:00",
"ProcessCount": 5,
"WorkingSetBytes": 153997312,
"HandleCount": 3535,
"ThreadCount": 183,
"WindowFound": true,
"Errors": [],
"MessageEvents": 0,
"ReconnectEvents": 0,
"AgentWorkingSetBytes": 43053056,
"AgentHandleCount": 389,
"AgentThreadCount": 20,
"LastSnapshotAt": null
},
{
"Timestamp": "2026-09-05T14:39:37.4016222+00:00",
"ProcessCount": 5,
"WorkingSetBytes": 153575424,
"HandleCount": 3535,
"ThreadCount": 183,
"WindowFound": true,
"Errors": [],
"MessageEvents": 0,
"ReconnectEvents": 0,
"AgentWorkingSetBytes": 51077120,
"AgentHandleCount": 437,
"AgentThreadCount": 25,
"LastSnapshotAt": "2026-09-05T14:39:36.5158013+00:00"
},
{
"Timestamp": "2026-09-05T14:39:42.4826756+00:00",
"ProcessCount": 5,
"WorkingSetBytes": 153645056,
"HandleCount": 3535,
"ThreadCount": 183,
"WindowFound": true,
"Errors": [],
"MessageEvents": 0,
"ReconnectEvents": 0,
"AgentWorkingSetBytes": 49594368,
"AgentHandleCount": 439,
"AgentThreadCount": 25,
"LastSnapshotAt": "2026-09-05T14:39:40.6950674+00:00"
},
{
"Timestamp": "2026-09-05T14:39:47.5679204+00:00",
"ProcessCount": 5,
"WorkingSetBytes": 153632768,
"HandleCount": 3535,
"ThreadCount": 184,
"WindowFound": true,
"Errors": [],
"MessageEvents": 0,
"ReconnectEvents": 0,
"AgentWorkingSetBytes": 50147328,
"AgentHandleCount": 439,
"AgentThreadCount": 25,
"LastSnapshotAt": "2026-09-05T14:39:46.9856634+00:00"
},
{
"Timestamp": "2026-09-05T14:39:52.6503147+00:00",
"ProcessCount": 5,
"WorkingSetBytes": 153395200,
"HandleCount": 3537,
"ThreadCount": 184,
"WindowFound": true,
"Errors": [],
"MessageEvents": 0,
"ReconnectEvents": 0,
"AgentWorkingSetBytes": 51089408,
"AgentHandleCount": 439,
"AgentThreadCount": 23,
"LastSnapshotAt": "2026-09-05T14:39:51.1846333+00:00"
},
{
"Timestamp": "2026-09-05T14:39:57.7230086+00:00",
"ProcessCount": 5,
"WorkingSetBytes": 152985600,
"HandleCount": 3537,
"ThreadCount": 184,
"WindowFound": true,
"Errors": [],
"MessageEvents": 0,
"ReconnectEvents": 0,
"AgentWorkingSetBytes": 51859456,
"AgentHandleCount": 439,
"AgentThreadCount": 23,
"LastSnapshotAt": "2026-09-05T14:39:57.4318646+00:00"
},
{
"Timestamp": "2026-09-05T14:40:02.1355012+00:00",
"ProcessCount": 5,
"WorkingSetBytes": 152502272,
"HandleCount": 3537,
"ThreadCount": 184,
"WindowFound": true,
"Errors": [],
"MessageEvents": 0,
"ReconnectEvents": 0,
"AgentWorkingSetBytes": 52297728,
"AgentHandleCount": 442,
"AgentThreadCount": 23,
"LastSnapshotAt": "2026-09-05T14:40:01.6026352+00:00"
}
],
"MessageEvents": 0,
"ReconnectEvents": 0,
"WorkingSetGrowthBytes": -1495040,
"HandleGrowth": 2,
"ThreadGrowth": 1,
"Healthy": true,
"Findings": [],
"RequestedSeconds": 30,
"ObservedSeconds": 30.0794228,
"CompletedDuration": true
}
@@ -0,0 +1,56 @@
{
"BinarySha256": "5899FD7A07E85B9C20679E06012CB0ED494F96916A8C58F81ACFC533286CA009",
"Passed": true,
"OperatingSystem": "Microsoft Windows NT 10.0.19044.0",
"Results": [
{
"ExitCode": 0,
"Name": "recover",
"StartedAt": "\/Date(1788619114443)\/",
"FinishedAt": "\/Date(1788619157247)\/"
},
{
"ExitCode": 0,
"Name": "doctor",
"StartedAt": "\/Date(1788619157248)\/",
"FinishedAt": "\/Date(1788619157696)\/"
},
{
"ExitCode": 0,
"Name": "inspect",
"StartedAt": "\/Date(1788619157696)\/",
"FinishedAt": "\/Date(1788619158434)\/"
},
{
"ExitCode": 0,
"Name": "smoke",
"StartedAt": "\/Date(1788619158434)\/",
"FinishedAt": "\/Date(1788619160226)\/"
},
{
"ExitCode": 0,
"Name": "recovery",
"StartedAt": "\/Date(1788619160226)\/",
"FinishedAt": "\/Date(1788619162163)\/"
},
{
"ExitCode": 0,
"Name": "listener",
"StartedAt": "\/Date(1788619162163)\/",
"FinishedAt": "\/Date(1788619171785)\/"
},
{
"ExitCode": 0,
"Name": "stability",
"StartedAt": "\/Date(1788619171785)\/",
"FinishedAt": "\/Date(1788619202188)\/"
},
{
"ExitCode": 0,
"Name": "diagnose",
"StartedAt": "\/Date(1788619202188)\/",
"FinishedAt": "\/Date(1788619203080)\/"
}
],
"SessionId": 1
}
+37
View File
@@ -0,0 +1,37 @@
param(
[Parameter(Mandatory=$true)][string]$Executable,
[Parameter(Mandatory=$true)][string]$OutputDirectory,
[ValidateSet(8,24,72)][int]$Hours = 72,
[ValidateRange(1,3600)][int]$SampleSeconds = 60
)
$ErrorActionPreference = 'Stop'
$exe = (Resolve-Path $Executable).Path
$directory = [IO.Path]::GetFullPath($OutputDirectory)
if (Test-Path $directory) { throw 'Use a new output directory; endurance evidence is never overwritten.' }
[IO.Directory]::CreateDirectory($directory) | Out-Null
$utf8 = New-Object Text.UTF8Encoding($false)
$started = [DateTimeOffset]::UtcNow
$info = @{
StartedAt=$started.ToString('o')
ExpectedFinish=$started.AddHours($Hours).ToString('o')
Hours=$Hours
SampleSeconds=$SampleSeconds
Executable=$exe
BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash
SessionId=[Diagnostics.Process]::GetCurrentProcess().SessionId
OperatingSystem=[Environment]::OSVersion.VersionString
WritesMessages=$false
}
[IO.File]::WriteAllText("$directory\run-info.json", ($info | ConvertTo-Json), $utf8)
$code = 1
try {
& $exe endurance --hours $Hours --sample-seconds $SampleSeconds --output "$directory\metrics" |
ForEach-Object { [IO.File]::AppendAllText("$directory\console.json", $_ + [Environment]::NewLine, $utf8) }
$code = $LASTEXITCODE
} catch {
[IO.File]::WriteAllText("$directory\launcher-error.txt", $_.Exception.GetType().FullName, $utf8)
} finally {
[IO.File]::WriteAllText("$directory\exit-code.txt", [string]$code, $utf8)
[IO.File]::WriteAllText("$directory\finished-at.txt", [DateTimeOffset]::UtcNow.ToString('o'), $utf8)
}
exit $code
+39
View File
@@ -0,0 +1,39 @@
param(
[Parameter(Mandatory=$true)][string]$Executable,
[Parameter(Mandatory=$true)][string]$OutputDirectory
)
$ErrorActionPreference = 'Stop'
$exe = (Resolve-Path $Executable).Path
$directory = [IO.Path]::GetFullPath($OutputDirectory)
if (Test-Path $directory) { throw 'Use a new output directory to preserve earlier evidence.' }
[IO.Directory]::CreateDirectory($directory) | Out-Null
$utf8 = New-Object Text.UTF8Encoding($false)
$results = @()
$cases = @(
@{ Name='recover'; Args=@('recover-ui','--timeout','30') },
@{ Name='doctor'; Args=@('doctor','--timeout','30') },
@{ Name='inspect'; Args=@('inspect-ui','--output',"$directory\ui-tree.json",'--timeout','30') },
@{ Name='smoke'; Args=@('smoke','--output',"$directory\smoke-ui-tree.json",'--timeout','30') },
@{ Name='recovery'; Args=@('recovery-smoke','--timeout','60') },
@{ Name='listener'; Args=@('listener-smoke','--output',"$directory\listener",'--timeout','60') },
@{ Name='stability'; Args=@('stability-smoke','--seconds','30','--sample-seconds','5','--output',"$directory\stability",'--timeout','90') },
@{ Name='diagnose'; Args=@('diagnose','--baseline',"$directory\ui-tree.json",'--output',"$directory\diagnostics",'--timeout','60') }
)
foreach ($case in $cases) {
$started = [DateTimeOffset]::UtcNow
$arguments = $case.Args
$lines = & $exe @arguments
$code = $LASTEXITCODE
[IO.File]::WriteAllText("$directory\$($case.Name).json", ($lines -join [Environment]::NewLine), $utf8)
$results += @{ Name=$case.Name; ExitCode=$code; StartedAt=$started; FinishedAt=[DateTimeOffset]::UtcNow }
}
$summary = @{
BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash
SessionId=[Diagnostics.Process]::GetCurrentProcess().SessionId
OperatingSystem=[Environment]::OSVersion.VersionString
Results=$results
Passed=(@($results | Where-Object { $_.ExitCode -ne 0 }).Count -eq 0)
}
[IO.File]::WriteAllText("$directory\validation-summary.json", ($summary | ConvertTo-Json -Depth 8), $utf8)
if (!$summary.Passed) { exit 2 }
exit 0
+10 -2
View File
@@ -130,11 +130,19 @@ public static class VisibleMessageParser
}
}
public sealed class BoundedMessageDeduper(int capacity = 2048)
public sealed class BoundedMessageDeduper
{
private readonly int _capacity;
private readonly Queue<string> _order = new();
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
public BoundedMessageDeduper(int capacity = 2048)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be positive.");
_capacity = capacity;
}
public bool TryAdd(string fingerprint)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
@@ -144,7 +152,7 @@ public sealed class BoundedMessageDeduper(int capacity = 2048)
}
_order.Enqueue(fingerprint);
if (_order.Count > capacity)
if (_order.Count > _capacity)
{
_seen.Remove(_order.Dequeue());
}
+44
View File
@@ -0,0 +1,44 @@
namespace WxAgent.Core;
public sealed class UiTraversalGuard(int maxNodes = 10_000, int maxDepth = 40)
{
private readonly HashSet<string> _visited = new(StringComparer.Ordinal);
public int NodeCount { get; private set; }
public bool Truncated { get; private set; }
public bool CycleDetected { get; private set; }
public bool TryVisit(string runtimeId, int depth)
{
if (NodeCount >= maxNodes || depth > maxDepth)
{
Truncated = true;
return false;
}
if (runtimeId.Length > 0 && !_visited.Add(runtimeId))
{
CycleDetected = true;
return false;
}
NodeCount++;
return true;
}
}
public sealed record UiSnapshotComparison(IReadOnlyList<string> AddedAutomationIds,
IReadOnlyList<string> RemovedAutomationIds, IReadOnlyList<string> MissingRequiredControls)
{
public static UiSnapshotComparison Compare(UiNodeSnapshot baseline, UiNodeSnapshot current)
{
static IEnumerable<string> ReadIds(UiNodeSnapshot root) =>
new[] { root.AutomationId }.Concat(root.Children.SelectMany(ReadIds));
static HashSet<string> Ids(UiNodeSnapshot root) => ReadIds(root)
.Where(id => id.Length > 0 && !id.StartsWith("[id:", StringComparison.Ordinal))
.ToHashSet(StringComparer.Ordinal);
var before = Ids(baseline);
var after = Ids(current);
return new UiSnapshotComparison(
after.Except(before).Order(StringComparer.Ordinal).ToArray(),
before.Except(after).Order(StringComparer.Ordinal).ToArray(),
WechatLocators.RequiredAutomationIds.Where(id => !after.Contains(id)).ToArray());
}
}
+10
View File
@@ -3,6 +3,16 @@ namespace WxAgent.Core;
public static class WechatLocators
{
public const string MainView = "MainView";
public const string MainTabBar = "MainView.main_tabbar";
public static bool IsWechatTrayName(string name) =>
name is "微信" or "WeChat" or "Weixin"
|| name.StartsWith("微信\n", StringComparison.Ordinal)
|| name.StartsWith("微信\r", StringComparison.Ordinal);
public static bool IsNativeMainWindow(string className, string title) =>
title == "微信" && className.StartsWith("Qt", StringComparison.Ordinal)
&& className.EndsWith("QWindowIcon", StringComparison.Ordinal);
public const string SessionList = "session_list";
public const string ChatPage = "chat_message_page";
public const string MessageList = "chat_message_list";
+85
View File
@@ -0,0 +1,85 @@
using System.Text.Json;
namespace WxAgent.Core;
public sealed record WechatStabilitySample(
DateTimeOffset Timestamp,
int ProcessCount,
long WorkingSetBytes,
int HandleCount,
int ThreadCount,
bool WindowFound,
IReadOnlyList<WxAgentErrorCode> Errors,
long MessageEvents,
long ReconnectEvents,
long AgentWorkingSetBytes = 0,
int AgentHandleCount = 0,
int AgentThreadCount = 0,
DateTimeOffset? LastSnapshotAt = null);
public sealed record WechatStabilityReport(
DateTimeOffset StartedAt,
DateTimeOffset FinishedAt,
IReadOnlyList<WechatStabilitySample> Samples,
long MessageEvents,
long ReconnectEvents,
long WorkingSetGrowthBytes,
int HandleGrowth,
int ThreadGrowth,
bool Healthy,
IReadOnlyList<string> Findings,
double RequestedSeconds = 0,
double ObservedSeconds = 0,
bool CompletedDuration = false);
public static class WechatStabilityAnalyzer
{
public static WechatStabilityReport Analyze(
DateTimeOffset startedAt,
DateTimeOffset finishedAt,
IReadOnlyList<WechatStabilitySample> samples,
long messageEvents,
long reconnectEvents)
{
ArgumentNullException.ThrowIfNull(samples);
if (samples.Count == 0)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "At least one stability sample is required.");
var first = samples[0];
var last = samples[^1];
var memoryGrowth = last.WorkingSetBytes - first.WorkingSetBytes;
var handleGrowth = last.HandleCount - first.HandleCount;
var threadGrowth = last.ThreadCount - first.ThreadCount;
var findings = new List<string>();
var recoveredWindowLoss = last.WindowFound && reconnectEvents > 0;
if (samples.Any(sample => !sample.WindowFound) && !recoveredWindowLoss) findings.Add("wechat-window-unavailable");
foreach (var error in last.Errors) findings.Add("final-error:" + error);
if (last.AgentWorkingSetBytes - first.AgentWorkingSetBytes > 128L * 1024 * 1024) findings.Add("agent-working-set-growth-over-128mb");
if (last.AgentHandleCount - first.AgentHandleCount > 100) findings.Add("agent-handle-growth-over-100");
if (last.AgentThreadCount - first.AgentThreadCount > 20) findings.Add("agent-thread-growth-over-20");
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");
return new WechatStabilityReport(
startedAt,
finishedAt,
samples,
messageEvents,
reconnectEvents,
memoryGrowth,
handleGrowth,
threadGrowth,
findings.Count == 0,
findings);
}
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);
}
}
+137
View File
@@ -35,6 +35,33 @@ try
WriteJson(report);
return report.Errors.Count == 0 ? 0 : 2;
}
case "diagnose":
{
var directory = Path.GetFullPath(GetRequiredOption(args, "--output"));
Directory.CreateDirectory(directory);
var snapshot = await WechatUiInspector.CaptureAsync(Path.Combine(directory, "ui-tree.json"), cancellationToken);
var baseline = snapshot;
if (GetOption(args, "--baseline") is { } baselinePath)
{
await using var input = File.OpenRead(baselinePath);
baseline = await JsonSerializer.DeserializeAsync<UiNodeSnapshot>(input,
new JsonSerializerOptions { MaxDepth = 128 }, cancellationToken)
?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Baseline UI snapshot is empty.");
}
var report = new
{
correlationId = Guid.NewGuid().ToString("N"),
stage = "ui-compatibility-diagnostics",
capturedAt = DateTimeOffset.UtcNow,
doctor = WechatDoctor.Run(cancellationToken),
nativeWindows = WechatChatClient.InspectNativeWindows(),
comparison = UiSnapshotComparison.Compare(baseline, snapshot)
};
await File.WriteAllTextAsync(Path.Combine(directory, "diagnostics.json"),
JsonSerializer.Serialize(report, jsonOptions), cancellationToken);
WriteJson(report);
return report.doctor.Errors.Count == 0 && report.comparison.MissingRequiredControls.Count == 0 ? 0 : 2;
}
case "inspect-ui":
{
var output = GetRequiredOption(args, "--output");
@@ -62,6 +89,78 @@ try
});
return report.Errors.Count == 0 ? 0 : 2;
}
case "tray-status":
{
WriteJson(WechatChatClient.InspectTray(cancellationToken));
return 0;
}
case "window-status":
{
WriteJson(WechatChatClient.InspectNativeWindows());
return 0;
}
case "listener-smoke":
{
var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken);
WriteJson(result);
return result.Sent && result.MatchingEvents == 1 && result.EventsAfterRestart == 0
&& result.Snapshots >= 2 && result.CheckpointSaved ? 0 : 2;
}
case "recovery-smoke":
{
var checks = await WechatChatClient.RecoverySmokeAsync(cancellationToken);
WriteJson(checks);
return checks.All(check => check.TransitionObserved && check.SameInstance && check.MainViewFound
&& check.SessionsFound && check.MessagesFound) ? 0 : 2;
}
case "recover-ui":
{
var result = await WechatChatClient.RecoverUiAsync(cancellationToken);
WriteJson(result);
return result.Success ? 0 : 2;
}
case "stability-smoke":
{
var seconds = GetPositiveIntOption(args, "--seconds", 15, 300);
var sampleSeconds = GetPositiveIntOption(args, "--sample-seconds", 5, seconds);
var output = GetOption(args, "--output") ?? Path.Combine("artifacts", "m6-stability-smoke");
var recovery = await WechatChatClient.RecoverUiAsync(cancellationToken);
var report = await WechatStabilityRunner.RunAsync(
TimeSpan.FromSeconds(seconds),
TimeSpan.FromSeconds(sampleSeconds),
output,
cancellationToken);
WriteJson(new
{
scope = "M6",
recovery.Success,
report.Healthy,
sampleCount = report.Samples.Count,
report.MessageEvents,
report.ReconnectEvents,
report.WorkingSetGrowthBytes,
report.HandleGrowth,
report.ThreadGrowth,
report.Findings,
output = Path.GetFullPath(output),
privacy = "message content omitted"
});
return report.Healthy ? 0 : 2;
}
case "endurance":
{
var hours = GetPositiveIntOption(args, "--hours", 24, 72);
var sampleSeconds = GetPositiveIntOption(args, "--sample-seconds", 60, 3600);
var output = GetOption(args, "--output") ?? Path.Combine("artifacts", $"endurance-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}");
await WechatChatClient.RecoverUiAsync(cancellationToken);
var report = await WechatStabilityRunner.RunAsync(
TimeSpan.FromHours(hours),
TimeSpan.FromSeconds(sampleSeconds),
output,
cancellationToken);
WriteJson(report with { Samples = [] });
return report.Healthy ? 0 : 2;
}
case "m5-smoke":
{
var momentsWindow = await WechatChatClient.OpenMomentsAsync(cancellationToken);
@@ -372,6 +471,18 @@ static int ValidateCommandLine(string[] values)
return 30;
}
if (values[0] == "listener-smoke")
{
ValidateOptions(values, 1, ["--output", "--timeout"], []);
return 60;
}
if (values[0] == "diagnose")
{
ValidateOptions(values, 1, ["--output", "--baseline", "--timeout"], []);
return 60;
}
if (values[0] == "inspect-ui")
{
ValidateOptions(values, 1, ["--output", "--timeout"], []);
@@ -384,6 +495,24 @@ static int ValidateCommandLine(string[] values)
return 30;
}
if (values[0] is "recover-ui" or "window-status" or "tray-status" or "recovery-smoke")
{
ValidateOptions(values, 1, ["--timeout"], []);
return 30;
}
if (values[0] == "stability-smoke")
{
ValidateOptions(values, 1, ["--seconds", "--sample-seconds", "--output", "--timeout"], []);
return GetPositiveIntOption(values, "--seconds", 15, 300) + 60;
}
if (values[0] == "endurance")
{
ValidateOptions(values, 1, ["--hours", "--sample-seconds", "--output", "--timeout"], []);
return checked(GetPositiveIntOption(values, "--hours", 24, 72) * 3600 + 120);
}
if (values[0] == "m5-smoke")
{
ValidateOptions(values, 1, ["--timeout"], []);
@@ -586,10 +715,18 @@ static int CountNodes(UiNodeSnapshot node) => 1 + node.Children.Sum(CountNodes);
static void PrintHelp() => Console.WriteLine("""
WxAgent.Host commands:
doctor [--timeout 30]
diagnose --output <dir> [--baseline <ui-tree.json>] [--timeout 60]
inspect-ui --output <path> [--timeout 30]
smoke [--output <path>] [--timeout 30]
m4-smoke [--timeout 60]
m5-smoke [--timeout 60]
tray-status [--timeout 30]
window-status [--timeout 30]
listener-smoke --output <new-dir> [--timeout 60]
recovery-smoke [--timeout 30]
recover-ui [--timeout 30]
stability-smoke [--seconds 15] [--sample-seconds 5] [--output <dir>]
endurance [--hours 24] [--sample-seconds 60] [--output <dir>]
group at-all --group <name> --message <text> --confirm CONFIRM [--timeout 30]
group verify-at-all --group <name> --message <text> [--timeout 30]
chat send-url-card --to <name> --url <http-url> [--message <text>] --confirm CONFIRM [--timeout 60]
+11
View File
@@ -3,3 +3,14 @@ CloseHandle
VirtualQueryEx
ReadProcessMemory
QueryFullProcessImageName
EnumWindows
GetWindowThreadProcessId
GetWindowText
GetClassName
IsWindowVisible
IsIconic
GetDpiForWindow
GetWindowRect
AccessibleObjectFromWindow
AccessibleChildren
EnumChildWindows
+26 -20
View File
@@ -416,7 +416,8 @@ public static partial class WechatChatClient
TimeSpan duration,
string? checkpointPath,
[EnumeratorCancellation] CancellationToken cancellationToken,
IReadOnlyList<Func<MessageEvent, CancellationToken, Task>>? callbacks = null)
IReadOnlyList<Func<MessageEvent, CancellationToken, Task>>? callbacks = null,
Action? snapshotObserved = null)
{
if (duration <= TimeSpan.Zero)
{
@@ -448,33 +449,31 @@ public static partial class WechatChatClient
IReadOnlyList<ChatMessageSnapshot>? visible = null;
try
{
window = WechatDoctor.FindWechatWindow(automation);
if (window is not null)
await CommandQueue.WaitAsync(cancellationToken);
try
{
window = AttachWindow(automation);
if (!IsFileTransferAssistantOpen(window)) connected = false;
if (!connected)
{
await CommandQueue.WaitAsync(cancellationToken);
try
{
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
}
finally
{
CommandQueue.Release();
}
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
subscription?.Dispose();
var list = FindByAutomationId(window, WechatLocators.MessageList);
subscription = list?.RegisterStructureChangedEvent(
var list = FindByAutomationId(window, WechatLocators.MessageList)
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Listener message list was not found.");
subscription = list.RegisterStructureChangedEvent(
TreeScope.Subtree,
(_, _, _) => signals.Writer.TryWrite(true));
connected = true;
}
// Never attribute the currently selected contact's messages to File Transfer Assistant.
if (!IsFileTransferAssistantOpen(window))
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed during snapshot.");
visible = ReadVisible(window);
snapshotObserved?.Invoke();
}
else
finally
{
connected = false;
CommandQueue.Release();
}
}
catch (OperationCanceledException)
@@ -581,11 +580,18 @@ public static partial class WechatChatClient
private static AutomationElement AttachWindow(UIA3Automation automation)
{
var window = WechatDoctor.FindWechatWindow(automation);
if (window is null)
if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null)
{
throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window was not found in the current interactive session.");
if (!WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, CancellationToken.None);
for (var attempt = 0; attempt < 50; attempt++)
{
Thread.Sleep(100);
window = WechatDoctor.FindWechatWindow(automation);
if (window is not null && FindByAutomationId(window, WechatLocators.MainView) is not null) break;
}
}
if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat tray activation did not restore an accessible main view; no new process was started.");
window.Focus();
return window;
}
+56 -11
View File
@@ -6,7 +6,15 @@ using WxAgent.Core;
namespace WxAgent.Windows;
public sealed record ProcessDiagnostic(int Id, long WorkingSetBytes, int SessionId, string? Version, bool CanReadMemory, string? AccessError);
public sealed record ProcessDiagnostic(
int Id,
long WorkingSetBytes,
int SessionId,
string? Version,
bool CanReadMemory,
string? AccessError,
int HandleCount,
int ThreadCount);
public sealed record DoctorReport(
string OperatingSystem,
@@ -24,10 +32,25 @@ public static class WechatDoctor
{
cancellationToken.ThrowIfCancellationRequested();
var errors = new List<WxAgentErrorCode>();
var processes = Process.GetProcessesByName("Weixin")
.OrderByDescending(process => process.WorkingSet64)
.Select(process => CreateProcessDiagnostic(process, cancellationToken))
.ToArray();
using var currentProcess = Process.GetCurrentProcess();
var rawProcesses = Process.GetProcessesByName("Weixin");
ProcessDiagnostic[] processes;
try
{
var diagnostics = new List<ProcessDiagnostic>();
foreach (var process in rawProcesses)
{
cancellationToken.ThrowIfCancellationRequested();
try { diagnostics.Add(CreateProcessDiagnostic(process, cancellationToken)); }
catch (InvalidOperationException) { /* Process exited during sampling. */ }
catch (System.ComponentModel.Win32Exception) { /* Process is no longer accessible. */ }
}
processes = diagnostics.OrderByDescending(process => process.WorkingSetBytes).ToArray();
}
finally
{
foreach (var process in rawProcesses) process.Dispose();
}
if (processes.Length == 0)
{
@@ -65,7 +88,7 @@ public static class WechatDoctor
if (!windowFound && processes.Length > 0)
{
errors.Add(processes.Any(process => process.SessionId != Process.GetCurrentProcess().SessionId)
errors.Add(processes.All(process => process.SessionId != currentProcess.SessionId)
? WxAgentErrorCode.SessionLocked
: WxAgentErrorCode.WindowNotFound);
}
@@ -83,7 +106,7 @@ public static class WechatDoctor
return new DoctorReport(
Environment.OSVersion.VersionString,
Environment.UserInteractive,
Process.GetCurrentProcess().SessionId,
currentProcess.SessionId,
processes,
windowFound,
controls,
@@ -91,9 +114,27 @@ public static class WechatDoctor
errors.Distinct().ToArray());
}
internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation) =>
automation.GetDesktop().FindAllChildren(condition => condition.ByControlType(ControlType.Window))
.FirstOrDefault(element => string.Equals(element.Name, "微信", StringComparison.Ordinal));
internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation)
{
FlaUI.Core.AutomationElements.AutomationElement? fallback = null;
var mainWindows = WechatNativeWindow.Enumerate().Where(window =>
window.Visible && !window.Minimized && WechatLocators.IsNativeMainWindow(window.ClassName, window.Title));
foreach (var native in mainWindows)
{
try
{
var candidate = automation.FromHandle((nint)native.Handle);
if (candidate.FindFirstDescendant(condition => condition.ByAutomationId(WechatLocators.MainView)) is not null)
return candidate;
fallback ??= candidate;
}
catch
{
// Ignore transient or closing subwindows.
}
}
return fallback;
}
private static ProcessDiagnostic CreateProcessDiagnostic(Process process, CancellationToken cancellationToken)
{
@@ -108,6 +149,10 @@ public static class WechatDoctor
}
var access = ProcessMemoryScanner.Probe(process.Id);
return new ProcessDiagnostic(process.Id, process.WorkingSet64, process.SessionId, version, access.Success, access.Error);
var handleCount = 0;
var threadCount = 0;
try { handleCount = process.HandleCount; } catch { }
try { threadCount = process.Threads.Count; } catch { }
return new ProcessDiagnostic(process.Id, process.WorkingSet64, process.SessionId, version, access.Success, access.Error, handleCount, threadCount);
}
}
@@ -0,0 +1,58 @@
using WxAgent.Core;
namespace WxAgent.Windows;
public sealed record WechatListenerSmokeResult(bool Sent, int MatchingEvents, int EventsAfterRestart,
int Snapshots, bool CheckpointSaved);
public static partial class WechatChatClient
{
public static async Task<WechatListenerSmokeResult> ListenerSmokeAsync(string outputDirectory, CancellationToken cancellationToken)
{
var directory = Path.GetFullPath(outputDirectory);
Directory.CreateDirectory(directory);
var checkpoint = Path.Combine(directory, "listener-checkpoint.json");
if (File.Exists(checkpoint))
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Use a new listener smoke directory.");
var marker = "wx-agent-m6-listener-" + Guid.NewGuid().ToString("N");
var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var matched = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var matches = 0;
var snapshots = 0;
using var listening = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
listening.CancelAfter(TimeSpan.FromSeconds(30));
var reader = Task.Run(async () =>
{
try
{
await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(30), checkpoint, listening.Token,
snapshotObserved: () => { Interlocked.Increment(ref snapshots); ready.TrySetResult(); }))
{
if (item.Message?.Text != marker) continue;
Interlocked.Increment(ref matches);
matched.TrySetResult();
}
}
catch (OperationCanceledException) when (listening.IsCancellationRequested) { }
}, CancellationToken.None);
try
{
await ready.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
await SendTextAsync(marker, cancellationToken).ConfigureAwait(false); // One send only, through the same command queue.
await matched.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false);
}
finally
{
listening.Cancel();
await reader.ConfigureAwait(false);
}
var afterRestart = 0;
await foreach (var item in ListenEventsAsync(TimeSpan.FromSeconds(4), checkpoint, cancellationToken,
snapshotObserved: () => Interlocked.Increment(ref snapshots)))
{
if (item.Message?.Text == marker) afterRestart++;
}
return new WechatListenerSmokeResult(true, matches, afterRestart, snapshots, File.Exists(checkpoint));
}
}
+52
View File
@@ -0,0 +1,52 @@
using System.Diagnostics;
using Windows.Win32;
using WxAgent.Core;
namespace WxAgent.Windows;
public sealed record WechatWindowDiagnostic(long Handle, int ProcessId, string ClassName, string Title,
bool Visible, bool Minimized, uint Dpi, int Width, int Height);
internal static class WechatNativeWindow
{
internal static unsafe IReadOnlyList<WechatWindowDiagnostic> Enumerate()
{
using var current = Process.GetCurrentProcess();
var processIds = new HashSet<int>();
foreach (var process in Process.GetProcessesByName("Weixin"))
{
using (process)
{
try { if (process.SessionId == current.SessionId) processIds.Add(process.Id); }
catch (InvalidOperationException) { }
}
}
var result = new List<WechatWindowDiagnostic>();
PInvoke.EnumWindows((hwnd, _) =>
{
uint pid;
PInvoke.GetWindowThreadProcessId(hwnd, &pid);
if (!processIds.Contains((int)pid)) return true;
Span<char> className = stackalloc char[256];
Span<char> title = stackalloc char[512];
var classLength = PInvoke.GetClassName(hwnd, className);
var titleLength = PInvoke.GetWindowText(hwnd, title);
PInvoke.GetWindowRect(hwnd, out var rect);
result.Add(new WechatWindowDiagnostic((long)(nint)hwnd.Value, (int)pid,
new string(className[..classLength]), new string(title[..titleLength]),
PInvoke.IsWindowVisible(hwnd), PInvoke.IsIconic(hwnd), PInvoke.GetDpiForWindow(hwnd),
rect.right - rect.left, rect.bottom - rect.top));
return true;
}, default);
return result;
}
internal static WechatWindowDiagnostic Sanitize(WechatWindowDiagnostic window) =>
window with { Title = UiSnapshotSanitizer.SanitizeName(window.Title) };
}
public static partial class WechatChatClient
{
public static IReadOnlyList<WechatWindowDiagnostic> InspectNativeWindows() =>
WechatNativeWindow.Enumerate().Select(WechatNativeWindow.Sanitize).ToArray();
}
@@ -0,0 +1,45 @@
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Definitions;
using WxAgent.Core;
namespace WxAgent.Windows;
public sealed record WechatRecoveryCheck(string State, bool TransitionObserved, bool SameInstance,
bool MainViewFound, bool SessionsFound, bool MessagesFound);
public static partial class WechatChatClient
{
public static async Task<IReadOnlyList<WechatRecoveryCheck>> RecoverySmokeAsync(CancellationToken cancellationToken)
{
var checks = new List<WechatRecoveryCheck>();
foreach (var state in new[] { "minimized", "tray-hidden" })
{
var identity = await WithMainWindowAsync(async (main, _) =>
{
await OpenFileTransferAssistantCoreAsync(main, cancellationToken).ConfigureAwait(false);
var native = main.Properties.NativeWindowHandle.Value;
var processId = main.Properties.ProcessId.Value;
if (state == "minimized") main.AsWindow().Patterns.Window.Pattern.SetWindowVisualState(WindowVisualState.Minimized);
else main.AsWindow().Close(); // Normal WeChat close-to-tray, not SW_HIDE or process termination.
return (Handle: native.ToInt64(), ProcessId: processId);
}, cancellationToken).ConfigureAwait(false);
var transitioned = false;
for (var attempt = 0; attempt < 30; attempt++)
{
var native = WechatNativeWindow.Enumerate().FirstOrDefault(window => window.Handle == identity.Handle && window.ProcessId == identity.ProcessId);
transitioned = native is not null && (state == "minimized" ? native.Minimized : !native.Visible);
if (transitioned) break;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
await RecoverUiAsync(cancellationToken).ConfigureAwait(false);
checks.Add(await WithMainWindowAsync((main, _) => Task.FromResult(new WechatRecoveryCheck(
state, transitioned, main.Properties.NativeWindowHandle.Value.ToInt64() == identity.Handle
&& main.Properties.ProcessId.Value == identity.ProcessId,
FindByAutomationId(main, WechatLocators.MainView) is not null,
FindByAutomationId(main, WechatLocators.SessionList) is not null,
FindByAutomationId(main, WechatLocators.MessageList) is not null)), cancellationToken).ConfigureAwait(false));
}
return checks;
}
}
@@ -0,0 +1,192 @@
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 || duration / sampleInterval > 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>();
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);
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 };
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);
}
}
catch (OperationCanceledException) when (linked.IsCancellationRequested) { }
catch (Exception exception) { samplingError = exception; }
finally
{
linked.Cancel();
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 };
using var reportTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await WechatStabilityAnalyzer.WriteJsonAsync(report, Path.Combine(directory, "report.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);
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();
}
}
+109
View File
@@ -0,0 +1,109 @@
using System.Runtime.InteropServices;
using FlaUI.Core.Input;
using FlaUI.Core.Definitions;
using FlaUI.UIA3;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.Accessibility;
using WxAgent.Core;
namespace WxAgent.Windows;
public sealed record WechatTrayDiagnostic(int ToolbarCount, int IconCount, int WechatIconCount);
internal static class WechatTray
{
internal static bool TryActivateUia(UIA3Automation automation)
{
var regions = automation.GetDesktop().FindAllChildren(cf => cf.ByClassName("Shell_TrayWnd"))
.SelectMany(window => window.FindAllDescendants(cf => cf.ByClassName("TrayNotifyWnd")));
var matches = regions.SelectMany(region => region.FindAllDescendants(cf => cf.ByControlType(ControlType.Button)))
.Where(icon => WechatLocators.IsWechatTrayName(icon.Name)
&& !icon.IsOffscreen && !icon.BoundingRectangle.IsEmpty).ToArray();
if (matches.Length == 0) return false;
if (matches.Length != 1)
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Multiple WeChat notification icons found; refusing an ambiguous target.");
matches[0].DoubleClick();
return true;
}
// On Windows 10 the notification toolbar may expose no buttons through UIA3.
// Use its public MSAA accessibility API, never Explorer process memory or tray callback message guesses.
internal static unsafe WechatTrayDiagnostic Inspect(bool activate, CancellationToken cancellationToken)
{
var toolbars = new List<HWND>();
PInvoke.EnumWindows((root, _) =>
{
var className = ReadClass(root);
if (className is not ("Shell_TrayWnd" or "NotifyIconOverflowWindow")) return true;
PInvoke.EnumChildWindows(root, (child, _) =>
{
if (ReadClass(child) == "ToolbarWindow32") toolbars.Add(child);
return true;
}, default);
return true;
}, default);
var icons = 0;
var matches = new List<(IAccessible Owner, object Child)>();
var owners = new List<IAccessible>();
try
{
foreach (var toolbar in toolbars)
{
cancellationToken.ThrowIfCancellationRequested();
void* pointer;
var hr = PInvoke.AccessibleObjectFromWindow(toolbar, 0xfffffffc, typeof(IAccessible).GUID, out pointer);
if (hr.Value < 0 || pointer == null) continue;
IAccessible owner;
try { owner = (IAccessible)Marshal.GetObjectForIUnknown((nint)pointer); }
finally { Marshal.Release((nint)pointer); }
owners.Add(owner);
var count = Math.Clamp(owner.accChildCount, 0, 256);
var children = new object[count];
PInvoke.AccessibleChildren(owner, 0, children, out var obtained);
for (var index = 0; index < obtained; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var child = children[index];
if (child is not int) continue;
icons++;
var name = owner.get_accName(child);
string text;
try { text = name.ToString() ?? string.Empty; }
finally { if (name.Value != null) Marshal.FreeBSTR((nint)name.Value); }
if (WechatLocators.IsWechatTrayName(text)) matches.Add((owner, child));
}
}
if (activate)
{
if (matches.Count != 1)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound,
$"Expected one WeChat notification icon; found {matches.Count}. No application was started.");
var match = matches[0];
match.Owner.accLocation(out var left, out var top, out var width, out var height, match.Child);
if (width <= 0 || height <= 0 || (Convert.ToInt32(match.Owner.get_accState(match.Child)) & 0x10000) != 0)
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The WeChat tray icon is in the hidden overflow area; reveal it before restoring.");
// Qt opens the original main window on a tray double-click, not MSAA's single default action.
Mouse.MoveTo(left + width / 2, top + height / 2);
Mouse.DoubleClick(MouseButton.Left);
}
return new WechatTrayDiagnostic(toolbars.Count, icons, matches.Count);
}
finally
{
foreach (var owner in owners) Marshal.ReleaseComObject(owner);
}
}
private static string ReadClass(HWND hwnd)
{
Span<char> text = stackalloc char[256];
return new string(text[..PInvoke.GetClassName(hwnd, text)]);
}
}
public static partial class WechatChatClient
{
public static WechatTrayDiagnostic InspectTray(CancellationToken cancellationToken = default) =>
WechatTray.Inspect(false, cancellationToken);
}
+22 -15
View File
@@ -16,31 +16,38 @@ public static class WechatUiInspector
var window = WechatDoctor.FindWechatWindow(automation)
?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat window was not found in the current interactive session.");
var snapshot = Capture(window, cancellationToken);
var guard = new UiTraversalGuard();
var snapshot = Capture(window, cancellationToken, guard, 0);
var fullPath = Path.GetFullPath(outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await using var stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous);
await JsonSerializer.SerializeAsync(stream, snapshot, new JsonSerializerOptions { WriteIndented = true }, cancellationToken);
await JsonSerializer.SerializeAsync(stream, snapshot, new JsonSerializerOptions { WriteIndented = true, MaxDepth = 128 }, cancellationToken);
if (guard.Truncated || guard.CycleDetected)
throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "UI snapshot was saved partially: traversal limit or repeated runtime ID detected.");
return snapshot;
}
private static UiNodeSnapshot Capture(AutomationElement element, CancellationToken cancellationToken)
private static UiNodeSnapshot Capture(
AutomationElement element,
CancellationToken cancellationToken,
UiTraversalGuard guard,
int depth)
{
cancellationToken.ThrowIfCancellationRequested();
var identity = SafeRead(() => string.Join(',', element.Properties.RuntimeId.ValueOrDefault ?? []));
var canDescend = guard.TryVisit(identity, depth);
var children = new List<UiNodeSnapshot>();
AutomationElement[] rawChildren;
try
if (canDescend)
{
rawChildren = element.FindAllChildren();
}
catch
{
rawChildren = [];
}
AutomationElement[] rawChildren;
try { rawChildren = element.FindAllChildren(); }
catch { rawChildren = []; }
foreach (var child in rawChildren)
{
children.Add(Capture(child, cancellationToken));
foreach (var child in rawChildren)
{
if (guard.Truncated) break;
children.Add(Capture(child, cancellationToken, guard, depth + 1));
}
}
string bounds;
@@ -55,7 +62,7 @@ public static class WechatUiInspector
}
return new UiNodeSnapshot(
element.ControlType.ToString(),
SafeRead(() => element.ControlType.ToString()),
UiSnapshotSanitizer.SanitizeName(SafeRead(() => element.Name)),
UiSnapshotSanitizer.SanitizeAutomationId(SafeRead(() => element.AutomationId)),
SafeRead(() => element.IsEnabled, false),
@@ -0,0 +1,47 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class UiDiagnosticsTests
{
[Fact]
public void TraversalGuard_StopsCyclesBeforeRecursing()
{
var guard = new UiTraversalGuard();
Assert.True(guard.TryVisit("root", 0));
Assert.True(guard.TryVisit("child", 1));
Assert.False(guard.TryVisit("root", 2));
Assert.True(guard.CycleDetected);
Assert.Equal(2, guard.NodeCount);
}
[Fact]
public void TraversalGuard_BoundsDepthAndNodesEvenWithMissingRuntimeIds()
{
var depth = new UiTraversalGuard(maxDepth: 2);
Assert.True(depth.TryVisit("", 0));
Assert.False(depth.TryVisit("", 3));
Assert.True(depth.Truncated);
var count = new UiTraversalGuard(maxNodes: 2);
Assert.True(count.TryVisit("", 0));
Assert.True(count.TryVisit("", 1));
Assert.False(count.TryVisit("", 2));
Assert.Equal(2, count.NodeCount);
}
[Fact]
public void SnapshotComparison_DetectsRequiredControlRegressionWithoutLeakingDynamicIds()
{
var baseline = Root(WechatLocators.RequiredAutomationIds);
var missing = WechatLocators.MessageList;
var current = Root(WechatLocators.RequiredAutomationIds.Where(id => id != missing).Append("[id:private-hash]").Append("new_static_control"));
var report = UiSnapshotComparison.Compare(baseline, current);
Assert.Equal([missing], report.MissingRequiredControls);
Assert.Equal([missing], report.RemovedAutomationIds);
Assert.Equal(["new_static_control"], report.AddedAutomationIds);
}
private static UiNodeSnapshot Root(IEnumerable<string> ids) =>
new("Window", "", "", true, "0,0,800,600", ids.Select(id => new UiNodeSnapshot("Pane", "", id, true, "", [])).ToArray());
}
@@ -0,0 +1,117 @@
using WxAgent.Core;
using Xunit;
namespace WxAgent.Core.Tests;
public sealed class WechatStabilityTests
{
[Fact]
public void Deduper_HandlesTenThousandMessagesAndEvictsOldEntries()
{
var deduper = new BoundedMessageDeduper(2048);
for (var index = 0; index < 10_000; index++)
Assert.True(deduper.TryAdd($"message-{index}"));
Assert.False(deduper.TryAdd("message-9999"));
Assert.True(deduper.TryAdd("message-0"));
}
[Theory]
[InlineData("Qt51514QWindowIcon", "微信", true)]
[InlineData("Qt6QWindowIcon", "微信", true)]
[InlineData("Qt51514QWindowIcon", "文件传输助手", false)]
[InlineData("Qt51514WxTrayIconMessageWindowClass", "微信", false)]
[InlineData("Notepad", "微信", false)]
public void NativeWindowMatching_RejectsSubwindowsAndOtherApplications(string className, string title, bool expected)
{
Assert.Equal(expected, WechatLocators.IsNativeMainWindow(className, title));
}
[Theory]
[InlineData("微信", true)]
[InlineData("微信\n收到新消息", true)]
[InlineData("WeChat", true)]
[InlineData("企业微信", false)]
[InlineData("微信测试脚本", false)]
public void TrayMatching_IsExplicit(string name, bool expected) =>
Assert.Equal(expected, WechatLocators.IsWechatTrayName(name));
[Fact]
public void StabilityAnalyzer_DetectsAgentLeakAndFinalError()
{
var now = DateTimeOffset.UtcNow;
var first = Sample(now, 100, 10, 1);
var last = first with { Timestamp = now.AddHours(1), AgentHandleCount = 101, Errors = [WxAgentErrorCode.SessionLocked] };
var report = WechatStabilityAnalyzer.Analyze(now, last.Timestamp, [first, last], 0, 0);
Assert.False(report.Healthy);
Assert.Contains("agent-handle-growth-over-100", report.Findings);
Assert.Contains("final-error:SessionLocked", report.Findings);
}
[Fact]
public void Deduper_RejectsInvalidCapacity()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new BoundedMessageDeduper(0));
}
[Fact]
public void StabilityAnalyzer_ReportsHealthyBoundedGrowth()
{
var started = DateTimeOffset.UtcNow;
var samples = new[]
{
Sample(started, 100_000_000, 100, 20),
Sample(started.AddMinutes(1), 110_000_000, 120, 22)
};
var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(1), samples, 5, 1);
Assert.True(report.Healthy);
Assert.Equal(10_000_000, report.WorkingSetGrowthBytes);
Assert.Empty(report.Findings);
}
[Fact]
public void StabilityAnalyzer_AllowsTransientWindowLossAfterReconnect()
{
var started = DateTimeOffset.UtcNow;
var samples = new[]
{
Sample(started, 100_000_000, 100, 20),
Sample(started.AddMinutes(1), 0, 0, 0, windowFound: false),
Sample(started.AddMinutes(2), 105_000_000, 110, 21)
};
var report = WechatStabilityAnalyzer.Analyze(started, started.AddMinutes(2), samples, 0, 1);
Assert.True(report.Healthy);
Assert.DoesNotContain("wechat-window-unavailable", report.Findings);
}
[Fact]
public void StabilityAnalyzer_FlagsWindowLossAndResourceGrowth()
{
var started = DateTimeOffset.UtcNow;
var samples = new[]
{
Sample(started, 100_000_000, 100, 20),
Sample(started.AddHours(1), 400_000_000, 700, 150, windowFound: false)
};
var report = WechatStabilityAnalyzer.Analyze(started, started.AddHours(1), samples, 0, 0);
Assert.False(report.Healthy);
Assert.Contains("wechat-window-unavailable", report.Findings);
Assert.Contains("working-set-growth-over-256mb", report.Findings);
Assert.Contains("handle-growth-over-500", report.Findings);
Assert.Contains("thread-growth-over-100", report.Findings);
}
private static WechatStabilitySample Sample(
DateTimeOffset timestamp,
long memory,
int handles,
int threads,
bool windowFound = true) =>
new(timestamp, 1, memory, handles, threads, windowFound, [], 0, 0);
}