Files
StellarX-kaifa/开发记录/模块/Module-20261001-0002-阶段0A输入链路被动探测规格.md

793 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 新增功能模块 / 模块重构
> 适用场景:新增模块、重大模块重构、核心架构能力演进。
> 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。
## 基本信息
- 模块 ID:Module-20261001-0002
- 模块名称:阶段 0A 输入链路被动探测规格
- 状态:已完成(0A 核心与微软拼音/搜狗人工验证已完成;0B 为后续独立阶段)
- 类型:新增模块 / 诊断基础设施
- 所属系统 / 子系统:StellarX Core / Window / Input Probe
- 版本 / 分支:`master`,阶段 0A 实现基线 `c218c25`
- 环境:Windows + Visual Studio 2022 + MSVC v143 + EasyX + IMM32
- 负责人:Codex 协作设计
## 背景与目标
- 背景:
- 通用输入、焦点和键盘路由设计需要建立在 EasyX 真实消息链路之上。
- 当前尚不确定 EasyX 是否以及何时调用 `TranslateMessage`,WndProc 与 EasyX `EX_KEY` 是否重复报告,普通循环和模态循环是否一致,以及 IME 结果消息是否形成回声。
- 在没有证据前直接实现消费策略、去重状态机或 TextBox 输入链,会把推断写成框架行为。
- 当前痛点:
- 调试器逐条查看消息无法稳定覆盖完整输入批次和模态重入。
- 直接记录文字原文存在隐私风险。
- 在 WndProc 内同步写盘会扰动需要观察的消息时序。
- “为了探测额外读取 EasyX 消息”本身会改变被测对象。
- 目标:
- 定义阶段 0A 的版本化日志 schema、观察点、字段、缓冲、flush 和隐私规则。
- 被动回答 WndProc、EasyX、IMM32 和宿主事件循环之间的顺序与重复关系。
- 为阶段 0B 的逐消息消费实验和阶段 1 InputCollector 提供共同输入。
- 保证探测开启时业务语义透明,并把不可避免的时序扰动降到最低。
- 非目标:
- 第一切片不实现 IMM 结果串摘要、在线回声关联和真实中文 IME 结论;这些仍需后续切片与人工输入验证。
- 0A 不消费任何消息,不修改任何 WndProc 参数或返回值。
- 0A 不新增焦点、键盘路由、TextBox 编辑或 IME 控制行为。
- 0A 不验证消费消息的影响;该问题属于阶段 0B。
- 日志不作为用户输入内容审计或遥测系统。
## 模块边界
- 职责:
- 定义探测会话、消息记录、来源后端和关联字段。
- 定义 Key lParam、IME flags、窗口属性和 EasyX 消息的解析结果。
- 定义文本负载的隐私化摘要和在线回声关联结果。
- 定义固定内存缓冲、丢失记录、逻辑 flush 与耐久 flush 边界。
- 定义 JSON Lines 输出格式、文件生命周期和 schema 版本。
- 不负责什么:
- 不决定生产输入队列的数据结构。
- 不决定最终消费白名单。
- 不在 WndProc 中执行磁盘 I/O、JSON 序列化或阻塞等待。
- 不为观察 EasyX 后端而额外调用 `peekmessage`。
- 不使用日志内容还原用户输入原文。
- 外部依赖:
- 现有 Window WndProc 子类化链。
- 现有 EasyX `peekmessage` / `ExMessage` 消费位置。
- IMM32 只读查询。
- Windows 高精度计时和安全随机数能力。
- 对外能力 / API:
- 本文只定义内部诊断格式,不新增框架公共 API。
- 探测器必须由测试/诊断构建开关显式启用,正式默认关闭。
- 关键数据 / 状态:
- `probeSessionId`、`schemaVersion`、全会话 `sequence`。
- `hwndInstanceId`、`wndProcCallId`、`parentSequence`、`callDepth`。
- 固定容量记录缓冲、flush 请求原因和丢失范围。
- 会话级随机摘要密钥,仅驻留内存,不写入日志。
## 设计说明
### 1. 语义透明原则
阶段 0A 的“被动”定义为业务语义透明,而不是宣称完全没有时间成本。
启用 0A 后必须保持:
1. 传给旧窗口过程的 `hwnd/message/wParam/lParam` 原样不变。
2. 每条原生消息调用旧窗口过程的次数不变。
3. WndProc 对调用方返回的值不变。
4. 原有 EasyX 消息读取次数、过滤条件和顺序不变。
5. 不额外调用 `TranslateMessage`、`DispatchMessage`、`PeekMessage` 或 EasyX `peekmessage`。
6. 不调用会改变 IME 状态的 API,包括 `ImmNotifyIME`、`ImmSetCompositionWindow` 和 `ImmSetCandidateWindow`。
7. 只允许执行读取型窗口/IME 查询和内存记录。
8. 日志失败不能改变窗口、输入或控件行为。
探测记录不可避免会产生少量 CPU、内存和时间开销,因此所有结论都要标注“探测构建观察结果”,必要时与关闭探测的基线行为对照。
### 2. 观察后端与观察点
`sourceBackend` 表示记录来自哪个观察后端,不等同于系统消息的真实起源。
| `sourceBackend` | 观察点 | 允许动作 | 禁止动作 |
|---|---|---|---|
| `Win32WndProc` | 现有 WndProc 入口、旧过程返回后 | 解析参数、配对进入/离开、记录返回值 | 修改参数、吞消息、改变返回值 |
| `EasyXExKey` | 现有主循环已经取出 `EX_KEY` 的位置 | 记录现有 `ExMessage` | 为探测新增 `peekmessage` |
| `Imm32Query` | 处理现有 IME 消息时的只读查询 | 查询长度、状态、组合/结果摘要 | 完成、取消、转换或移动 IME UI |
| `HostEventLoop` | 一次宿主消息/输入批次返回后的安全点 | flush、记录批次边界 | 在仍持有控件借用指针时写盘 |
如果同一输入同时被 WndProc 和 EasyX 报告,它们是两条独立观察记录。只有存在可靠证据时才通过关联字段声明二者匹配,不能仅因消息名称相同就认定是同一事件。
### 3. 会话与序列
#### 3.1 会话
每次启用探测并打开日志文件形成一个会话:
```text
probeSessionId = 随机 UUID
schemaVersion = 1
startedAtUtc = ISO-8601 UTC 时间
processId
uiThreadId
qpcFrequency
```
会话开始时写入一条 `session_start`,正常关闭时写入 `session_end`。
#### 3.2 `sequence`
- `sequence` 是会话内跨所有后端严格递增的 `uint64_t`。
- 0 保留为无记录/未关联值,第一条记录从 1 开始。
- 嵌套 WndProc 调用仍按实际观察顺序分配 sequence。
- sequence 只描述探测器观察顺序,不等同于 Windows 消息队列序号。
- 溢出视为会话不可继续,停止探测但不能改变业务行为。
#### 3.3 调用配对
每次 WndProc 进入分配 `wndProcCallId`:
```text
WndProc enter:
recordType = wndproc_enter
wndProcCallId = 新 ID
callDepth = 当前重入深度
旧窗口过程返回:
recordType = wndproc_leave
wndProcCallId = 对应 enter 的 ID
forwardResult = 原样结果
```
如果旧窗口过程同步触发嵌套消息,内层记录会自然出现在外层 enter/leave 之间。
### 4. 通用记录字段
以下字段构成 schema v1 的公共部分。
| 字段 | 类型 | 必需 | 说明 |
|---|---|---:|---|
| `schemaVersion` | integer | 是 | 初始为 1 |
| `recordType` | string | 是 | 记录类型 |
| `sessionId` | string | 是 | 探测会话 UUID |
| `sequence` | uint64 | 是 | 全会话严格递增序号 |
| `sourceBackend` | string | 是 | 观察后端 |
| `observationPoint` | string | 是 | 具体入口/离开/批次边界 |
| `qpcTicks` | uint64 | 是 | `QueryPerformanceCounter` 原始值 |
| `threadId` | uint32 | 是 | 当前线程 ID |
| `processId` | uint32 | 是 | 当前进程 ID |
| `hwnd` | string/null | 视情况 | 十六进制句柄,仅用于本次会话诊断 |
| `hwndInstanceId` | uint64/null | 视情况 | 探测器为一次 HWND 生命周期分配的 ID |
| `wndProcCallId` | uint64/null | 否 | WndProc enter/leave 配对 |
| `callDepth` | uint32 | 否 | WndProc 重入深度 |
| `parentSequence` | uint64/null | 否 | IMM 查询等子记录关联的父消息 |
| `dispatchDepth` | uint32/null | 否 | 当前框架分发深度,未知则 null |
| `modalDepth` | uint32/null | 否 | 当前模态深度,未知则 null |
| `imeState` | string | 否 | `unknown/idle/composing` |
| `compositionId` | uint64 | 是 | 0 表示无活动 IME 组合 |
`hwnd` 可能被系统重用,因此分析生命周期时必须使用 `hwndInstanceId`,不能只比较句柄值。
### 5. 原生消息字段
适用于 `Win32WndProc` 记录。
| 字段 | 类型 | 说明 |
|---|---|---|
| `messageId` | uint32 | 原始消息编号 |
| `messageName` | string | 已知消息符号名,未知时使用 `UNKNOWN_0xNNNN` |
| `messageCategory` | string | `key/text/ime/focus/window/mouse/other` |
| `wParamPolicy` | string | `raw/parsed/redacted/not_applicable` |
| `wParamHex` | string/null | 仅允许非文本、非敏感消息记录 |
| `lParamPolicy` | string | `raw_and_parsed/parsed_only/redacted/not_applicable` |
| `lParamHex` | string/null | 只允许无指针、无文本含义的消息记录 |
| `forwarded` | boolean | 是否按原链调用旧窗口过程 |
| `forwardResultHex` | string/null | leave 记录中的旧过程返回值 |
| `messageTime` | uint32/null | 只有来源明确时记录 |
| `messageTimeSource` | string | `msg/easyx/stale_or_unknown/not_available` |
约束:
- `WM_CHAR`、`WM_UNICHAR`、`WM_IME_CHAR` 等文本消息不得记录原始 `wParamHex`。
- lParam 为指针的消息不得记录指针值或盲目解引用,只记录允许的解析结果和是否为空。
- WndProc 本身没有 `MSG.time`;不得把 `GetMessageTime()` 的潜在陈旧值伪装成当前消息时间。
- 对 `SendMessage` 产生的同步消息,`messageTime` 通常应为 null/unknown。
### 6. Key lParam 解析
对 `WM_KEYDOWN/UP`、`WM_SYSKEYDOWN/UP` 记录:
| 字段 | 来源 |
|---|---|
| `virtualKey` | `wParam`,作为键值而非文字记录 |
| `action` | 消息类型决定 `down/up` |
| `repeatCount` | lParam bits 0–15 |
| `scanCode` | bits 16–23 |
| `extended` | bit 24 |
| `altContext` | bit 29 |
| `wasDown` | bit 30 |
| `transition` | bit 31 |
| `systemKey` | 是否为 `WM_SYSKEY*` |
| `imeComposingAtObservation` | 观察瞬间的探测状态 |
修饰键状态可额外记录:
```text
leftShift/rightShift
leftCtrl/rightCtrl
leftAlt/rightAlt
capsLock/numLock/scrollLock
```
必须注明这些值来自观察时的 `GetKeyState` 快照,不保证是消息生成瞬间的硬件状态。
### 7. IME 字段
#### 7.1 `WM_IME_COMPOSITION`
解析并记录布尔 flags:
```text
hasGcsCompStr
hasGcsCompAttr
hasGcsCompClause
hasGcsCursorPos
hasGcsResultStr
hasGcsResultClause
hasCsInsertChar
hasCsNoMoveCaret
hasAnyGcsFlag
```
允许记录:
```text
compositionUtf16Length
compositionScalarCount
compositionDigest
resultUtf16Length
resultScalarCount
resultDigest
cursorPosition
immQueryStatus
```
禁止记录组合串、读取串、结果串和候选文字原文。
#### 7.2 `WM_IME_STARTCOMPOSITION`
- 记录进入前后的探测 IME 状态。
- 记录新分配的探测 `compositionId`。
- 该 ID 只用于关联日志,不代表阶段 1 最终实现已经落地。
#### 7.3 缺失 START 的容错
Idle 状态直接观察到 `WM_IME_COMPOSITION` 时:
- 惰性分配新的非零 `compositionId`。
- 写入 `compositionIdAllocation = lazy_missing_start`。
- 禁止沿用已 Commit/Cancel/End 的旧 ID。
#### 7.4 `WM_IME_ENDCOMPOSITION`
- 记录本次组合是否观察到 result/cancel。
- 设置 `flushRequested=true`,原因为 `ime_end`。
- 不在 WndProc 内执行实际写盘。
#### 7.5 `WM_IME_SETCONTEXT`
- 记录 `activate` 和 `ISC_SHOWUI*` flags。
- 记录转发前后 flags 一致。
- 记录旧窗口过程返回值。
- 0A 不允许修改 flags。
### 8. 窗口和字符集字段
每个 `hwndInstanceId` 首次出现时记录一次窗口快照:
```text
isWindowUnicode
threadId
processId
windowClassDigest
dpiForWindow
dpiAwarenessContext
```
窗口类名默认也使用会话摘要,不记录业务窗口标题。
`WM_INPUTLANGCHANGEREQUEST` 记录:
```text
requestFlags
requestedInputLanguageHandle
requestedAnsiCodePage
inputLanguageSysCharset
inputLanguageForward
inputLanguageBackward
```
- 该消息表示输入布局切换请求,不等于切换已经完成。
- 0A 只观察并原样转发,不因请求更新活动代码页,也不清理当前回声状态。
- `requestFlags` 与 `requestedInputLanguageHandle` 分别是通用 `wParamHex`、`lParamHex` 的具名副本,便于分析器在不反推参数语义的情况下直接使用。
- `requestedAnsiCodePage` 只描述 `lParam` 中目标 HKL 的只读解析结果。
`WM_INPUTLANGCHANGE` 记录:
```text
inputLanguageHandle
charset
oldAnsiCodePage
newAnsiCodePage
pendingLeadPresentBefore
pendingLeadCleared
```
0A 尚未接管 ANSI 解码时,`pendingLead*` 可以是 `not_applicable`;字段保留给阶段 1 共用 schema。
### 9. EasyX 后端字段
只在现有业务循环已经成功取出 EasyX 消息时记录,不允许为了探测额外读取队列。
| 字段 | 说明 |
|---|---|
| `easyxMessageType` | EasyX 消息类别 |
| `easyxVkCode` | 键盘虚拟键,不记录字符原文 |
| `easyxDown` | 按下/释放状态 |
| `easyxCtrl/Shift/Alt` | EasyX 提供的修饰状态 |
| `easyxMessageTime` | EasyX 提供时记录,否则 null |
| `existingDispatchDecision` | 当前代码原本如何处理该消息 |
`existingDispatchDecision` 只描述观察到的旧行为,0A 不能把该消息改为“丢弃”或“进入新业务队列”。
### 10. 文本隐私与关联
#### 10.1 默认禁止记录的内容
- `WM_CHAR/WM_UNICHAR/WM_IME_CHAR` 原始字符值。
- `GCS_COMPSTR/GCS_RESULTSTR` 原文。
- 拼音串、候选词、剪贴板和 TextBox 内容。
- 窗口标题或可能包含用户数据的控件文本。
#### 10.2 允许的文本元数据
```text
textSource
utf16Length
scalarCount
isValidUtf16
sessionDigest
correlationStatus
correlationTargetSequence
matchedUnitCount
```
#### 10.3 会话摘要
- 会话开始时生成随机密钥。
- 使用带密钥摘要,例如 HMAC-SHA-256。
- 日志只保存截断后的 128-bit 十六进制摘要。
- 密钥只驻留内存,不写日志、不跨会话复用。
- 摘要只用于同一会话内比较相等性,不能跨会话追踪用户输入。
#### 10.4 在线回声关联
探测器可以在内存中短暂保留本次 `GCS_RESULTSTR`,在线比较后续 `WM_IME_CHAR/WM_CHAR` 序列。日志只输出:
```text
matched
partial
mismatched
expired
```
及匹配数量、目标 sequence 和摘要,不输出原文。
不默认记录“前 2–3 个码点”,因为短前缀仍可能泄露姓名、拼音或密码信息。
如果摘要和在线关联不足以定位问题,只允许在显式测试开关下启用明文模式,并满足:
1. UI 明确显示正在记录测试文本。
2. 只输入预先约定的非敏感测试串。
3. 明文模式不能进入正式构建默认配置。
4. 日志文件名明确包含 `PLAINTEXT-TEST`。
### 11. 来源关联
WndProc 和 EasyX 记录的匹配只能是显式结论:
```text
correlationStatus:
none
candidate
matched
ambiguous
rejected
correlationConfidence:
exact
high
low
```
匹配可参考:
- HWND 实例。
- 消息类别和虚拟键。
- Down/Up 状态。
- scan code。
- 消息时间(仅来源可靠时)。
- sequence 邻近关系。
如果同时存在多个候选,必须标为 `ambiguous`,不能强行一一配对。
### 12. 内存缓冲
#### 12.1 捕获结构
- WndProc 内只写紧凑的内部 `ProbeRecord`,不构造 JSON 字符串。
- 缓冲预分配,避免在消息热路径频繁扩容。
- 文本只计算长度、有效性、摘要和在线关联结果。
- JSON 序列化和文件编码在安全点执行。
#### 12.2 默认容量
schema v1 建议默认:
```text
softLimit = 3072 records
hardLimit = 4096 records
```
容量必须可由测试配置覆盖,但不能在正式路径无限增长。
达到 softLimit:
- 设置 `flushRequested=true`。
- 原因为 `soft_limit`。
- 继续记录到 hardLimit。
达到 hardLimit 且仍未到安全点:
- 保留最近记录,覆盖最旧未写记录。
- 维护 `lostFirstSequence/lostLastSequence/lostCount`。
- 下一次实际 flush 时先输出一条 `records_lost` 元记录。
- 禁止静默覆盖。
选择保留最近记录是为了在异常退出前尽量留下最接近故障的消息,同时通过 loss 元记录明确标注因果链已不完整。
### 13. Flush 模型
#### 13.1 两类 flush
区分:
1. `buffer_flush`:把内存记录序列化并写入文件,由操作系统缓存。
2. `durable_flush`:在 buffer_flush 后调用耐久化能力,降低进程崩溃后的日志丢失。
#### 13.2 WndProc 内只请求
以下观察点只设置请求,不直接 I/O:
| 触发点 | 请求原因 | 建议级别 |
|---|---|---|
| `WM_IME_ENDCOMPOSITION` | `ime_end` | durable |
| `WM_KILLFOCUS` / 窗口失活 | `window_deactivate` | durable |
| softLimit | `soft_limit` | buffer |
| 最大滞留时间到期 | `max_latency` | buffer |
| `WM_CLOSE/WM_DESTROY` 观察 | `window_shutdown` | durable |
#### 13.3 实际安全点
实际 flush 只能发生在:
```text
WndProc 已返回
&& 当前宿主消息/输入批次已经结束
&& 当前 mutation transaction 已提交
&& 当前没有跨 flush 保存的 Control* 借用视图
&& 当前循环是该 Window 的活动 drainer
```
现阶段尚未实现通用 drainer 时,0A 必须选择现有主循环已经完成一次消息/事件分发的位置,不能在控件回调、旧窗口过程调用或嵌套 WndProc 栈中写盘。
#### 13.4 flush 时机
- 每个宿主输入批次结束:如缓冲非空,执行 buffer_flush。
- 收到 durable 请求:在下一个安全点执行 buffer_flush + durable_flush。
- 最大滞留时间默认 500ms,到安全点后执行 buffer_flush。
- 正常关闭:完成最终 durable_flush,并写 `session_end`。
- 异常崩溃:不在异常处理器中尝试复杂 flush,允许最多丢失尚未到安全点的当前批次。
#### 13.5 写入失败
写入失败时:
- 保存错误码和失败计数到内存状态。
- 禁止重试死循环或阻塞 WndProc。
- 禁止退回记录明文的控制台输出。
- 后续安全点可以进行有上限的重试。
- 达到重试上限后禁用本次会话文件输出,但业务输入继续原样运行。
### 14. 日志文件
建议路径:
```text
TestResults/InputProbe/
```
建议文件名:
```text
input-probe-YYYYMMDD-HHMMSS-p<PID>-<sessionId>.jsonl
```
格式:
- UTF-8,无 BOM。
- JSON Lines,一行一条完整 JSON 对象。
- 换行使用 `\n`。
- schemaVersion 必须出现在每条记录中,便于截断文件恢复分析。
- 未知字段读取方忽略;语义变化必须提升 schemaVersion。
### 15. JSONL 示例
以下示例只展示格式,不代表真实运行结果。
```json
{"schemaVersion":1,"recordType":"session_start","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":1,"sourceBackend":"HostEventLoop","observationPoint":"probe_start","qpcTicks":518820000,"threadId":10420,"processId":18192,"hwnd":null,"hwndInstanceId":null,"compositionId":0,"startedAtUtc":"2026-10-01T08:00:00Z","qpcFrequency":10000000}
{"schemaVersion":1,"recordType":"wndproc_enter","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":42,"sourceBackend":"Win32WndProc","observationPoint":"enter","qpcTicks":518821240,"threadId":10420,"processId":18192,"hwnd":"0x00000000000A1042","hwndInstanceId":1,"wndProcCallId":19,"callDepth":1,"compositionId":3,"messageId":271,"messageName":"WM_IME_COMPOSITION","messageCategory":"ime","wParamPolicy":"redacted","wParamHex":null,"lParamPolicy":"raw_and_parsed","lParamHex":"0x00000800","hasGcsResultStr":true,"hasAnyGcsFlag":true}
{"schemaVersion":1,"recordType":"imm_query","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":43,"sourceBackend":"Imm32Query","observationPoint":"gcs_result","qpcTicks":518821260,"threadId":10420,"processId":18192,"hwnd":"0x00000000000A1042","hwndInstanceId":1,"parentSequence":42,"compositionId":3,"textSource":"GCS_RESULTSTR","utf16Length":2,"scalarCount":2,"isValidUtf16":true,"sessionDigest":"9e471d4d3a5732aa4fd27dba86aa4c11","correlationStatus":"none"}
{"schemaVersion":1,"recordType":"wndproc_leave","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":44,"sourceBackend":"Win32WndProc","observationPoint":"after_old_proc","qpcTicks":518821300,"threadId":10420,"processId":18192,"hwnd":"0x00000000000A1042","hwndInstanceId":1,"wndProcCallId":19,"callDepth":1,"compositionId":3,"messageId":271,"messageName":"WM_IME_COMPOSITION","forwarded":true,"forwardResultHex":"0x0000000000000000"}
{"schemaVersion":1,"recordType":"flush_boundary","sessionId":"7f472b6e-1d2a-4f8f-8df5-bdb72a8d52f7","sequence":45,"sourceBackend":"HostEventLoop","observationPoint":"input_batch_end","qpcTicks":518821400,"threadId":10420,"processId":18192,"hwnd":"0x00000000000A1042","hwndInstanceId":1,"compositionId":3,"flushReason":"input_batch_end","flushLevel":"buffer","bufferedRecordCount":44,"lostCount":0}
```
示例中的摘要不可反推出原始文字,会话密钥也不得写入文件。
### 16. 记录类型清单
schema v1 至少定义:
```text
session_start
session_end
window_attach
window_detach
wndproc_enter
wndproc_leave
easyx_key
imm_query
host_batch_begin
host_batch_end
flush_boundary
records_lost
probe_error
```
不得用一个含义模糊的 `event` 类型承载所有数据。
### 17. 配置边界
0A 配置只允许控制:
- 是否启用探测。
- 输出目录。
- soft/hard limit。
- 最大滞留时间。
- 是否启用显式明文测试模式。
- 是否记录非输入类窗口消息。
0A 配置不得包含“消费消息”选项。任何消费规则都属于 0B,并使用独立配置结构和明显的实验标识。
### 18. 0A 需要回答的问题
1. EasyX 主循环中的键盘消息是否经过当前 WndProc。
2. WndProc 和 EasyX `EX_KEY` 是否重复报告同一输入。
3. WndProc、EasyX、IMM 查询和宿主批次的真实顺序。
4. `WM_KEYDOWN` 后是否出现 `WM_CHAR/WM_SYSCHAR`。
5. 窗口实际为 ANSI 还是 Unicode。
6. `WM_UNICHAR` 是否出现、从哪个观察链出现。
7. `WM_IME_STARTCOMPOSITION` 是否总在 COMPOSITION 前出现。
8. `GCS_RESULTSTR`、`WM_IME_CHAR`、`WM_CHAR` 是否重复以及如何分段。
9. ANSI `WM_CHAR` 是否以 lead/trail 分开发送。
10. `WM_GETDLGCODE` 是否到达当前窗口过程。
11. 普通循环和 Dialog 模态循环是否经过同一链路。
12. Window 激活/失活与 IME End/Cancel 的顺序。
13. 不同输入法的消息序列差异。
14. 是否存在无法通过现有观察点可靠关联的消息。
“消费某类消息是否影响 EasyX/IME”不属于 0A 结论,由 0B 单变量实验回答。
### 19. 0B 和阶段 1 的输入契约
0B 复用:
- 同一 schema 和 sequence。
- 同一来源后端。
- 同一 Key/IME 解析结果。
- 同一缓冲、隐私和 flush 规则。
- 额外增加 `experimentId/ruleId/action`,不得改写 0A 字段语义。
阶段 1 复用:
- 已验证的消息来源和顺序。
- HWND A/W 属性。
- Key lParam 解析。
- IME result/echo 边界。
- ANSI/DBCS 实际分段。
- 需要保留或丢弃 EasyX `EX_KEY` 的证据。
### 20. 第一切片落地边界(2026-10-03)
第一切片已经实现以下能力:
- 编译期开关:`/p:StellarXInputProbe=true`;默认值为 `false`。
- 运行期开关:`STELLARX_INPUT_PROBE=1`;只有编译期和运行期同时开启才创建探测会话。
- 输出目录:默认 `TestResults/InputProbe/`,可用 `STELLARX_INPUT_PROBE_DIR` 覆盖。
- 不新增公共 API;关闭编译开关时内部调用全部内联为空操作。
- 在现有 WndProc、普通事件循环和 Dialog 模态循环的原路径上被动观察,不额外读取 EasyX 队列,不改变消息参数、转发路径和返回值。
- 记录 WndProc enter/leave、EasyX `EX_KEY`、宿主批次边界、Key lParam 解析、IME flags/组合状态、窗口 A/W 属性、DPI 和线程快照。
- 使用固定容量环形缓冲;WndProc 只尝试获取 SRW 独占锁,竞争时不等待并登记丢失;宿主安全点在锁内复制快照,JSON 序列化和文件 I/O 在锁外执行。
- 每个普通或模态宿主批次结束后尝试安全点 flush;`WM_IME_ENDCOMPOSITION`、失焦、失活和关闭消息只请求耐久 flush,不在 WndProc 内写盘。
- 文本类消息不记录原始 `wParam`,当前第一切片也不读取或保存组合串、结果串原文。
第一切片当时尚未实现的后续项,当前进度如下:
- `ImmGetCompositionStringW` 的长度、UTF-16 有效性、scalar count 和会话摘要记录。
- `GCS_RESULTSTR` 与后续 `WM_IME_CHAR/WM_CHAR` 的在线回声关联。
- 真实中文 IME 覆盖:微软拼音和搜狗均已完成人工执行。
- 0B 的逐消息消费实验仍未开始;当前实现没有任何消费配置。
### 21. 第二切片落地边界(2026-10-03)
第二切片新增:
- `WM_IME_COMPOSITION + GCS_RESULTSTR` 时调用 `ImmGetCompositionStringW`,记录字节长度、UTF-16 code unit 数、Unicode scalar count 和 UTF-16 合法性。
- 会话启动时生成只驻留内存的随机 HMAC 密钥;结果串只写入截断至 128 bit 的 HMAC-SHA-256 摘要,不记录原文,也不记录密钥。
- 对超过 64 KiB、奇数字节长度、无 IME context、空结果和读取失败分别记录明确状态。
- 为每次结果建立短期内存回声状态;Unicode 窗口比较 UTF-16 units,ANSI 窗口按活动输入布局的 ANSI 代码页生成预期字节。
- `WM_IME_CHAR` 与 `WM_CHAR` 使用独立 cursor,分别输出 `candidate/matched/partial/mismatched/expired/unrepresentable`,不预设两条通道必然都出现。
- ANSI `WM_IME_CHAR` 按高字节 lead、低字节 trail 展开;ANSI `WM_CHAR` 按单字节连续比较。
- 新结果、新组合、失焦、窗口失活、输入语言切换和关闭会结束当前回声窗口并写隐私安全的汇总记录;IME End 在旧窗口过程返回后的 leave 阶段收口,避免漏掉 END 处理期间嵌套产生的字符回声。
- `WM_INPUTLANGCHANGE` 更新活动布局代码页;实测中文布局为 CP936。
独立 `EasyX.ThreadProbe` 不链接 StellarX,已经确认:EasyX HWND/WndProc 属于内部窗口线程,`peekmessage` 属于 main/宿主线程,双线程是 EasyX 固有模型而非 UiFixture 假象。该结论已写回 v0.5 设计稿。
第二切片仍不负责:
- 生产 `NativeInputQueue`、焦点系统或 TextBox 输入。
- 消费、拦截或改写任何 IME/字符消息。
- 用自动化伪造真实 IME 结果;真正的提交、候选、取消和输入法切换必须由人工运行微软拼音与搜狗。
### 22. 微软拼音实测与关闭链修复(2026-10-04)
微软拼音真实人工会话已经完成,结果目录为 `TestResults/InputProbe/manual-20261004-194521-908-MicrosoftPinyin`:
- JSONL 共 4609 条,全部可解析,`sequence` 严格递增,文字原始 `wParam` 泄露为 0。
- WndProc 线程 ID 为 27744,宿主线程 ID 为 33148;线程 ID 仅代表本次会话。
- 观察到 6 次 `WM_IME_STARTCOMPOSITION`、35 次 `WM_IME_COMPOSITION`、6 次 `WM_IME_ENDCOMPOSITION`。
- 3 次 `GCS_RESULTSTR` 查询成功;每次为 2 个 UTF-16 code units、2 个 Unicode scalar,当前 CP936 回声预期为 4 个字节且未使用默认替代字符。
- 3 次 `WM_IME_CHAR` 回声均为 4/4 `matched`;对应 3 次 `WM_CHAR` 通道均为 0/4 `expired`。当前微软拼音 + ANSI EasyX 窗口组合下,应优先防止 `GCS_RESULTSTR` 与 `WM_IME_CHAR` 重复提交,不能假定还会出现 `WM_CHAR` 回声。
- `WM_INPUTLANGCHANGE` 观察数为 0,因此“切换中英文一次”没有形成该消息;该会话不能据此验证输入布局切换清理路径。
- 共有 5 条 `records_lost`,原因均为 `wndproc_lock_contention`;所有丢失均被显式记账,不能把本次日志称为零丢失。
本次真实关闭还暴露 `BUG-20261004-0001`:WndProc 已观察到 `WM_CLOSE -> WM_DESTROY -> WM_NCDESTROY`,但宿主 `peekmessage` 没有取得 `WM_CLOSE`,造成窗口消失后进程继续运行。修复后由 WndProc 在完成 `WM_NCDESTROY` 转发与探测 leave 后,以 release 发布原生窗口死亡状态;普通和模态宿主循环以 acquire 读取并在安全点退出。发布点不得提前,否则会造成仍在执行的 WndProc 访问已析构 `Window`。
自动关闭 smoke 在 `Start-Process + SC_CLOSE + EX_SHOWCONSOLE` 组合下还确认:EasyX 可能以 `0xC000013A` 直接结束,最后一批 WndProc 内存记录来不及由宿主安全点刷盘。探测脚本不得无条件忽略该退出码;只有日志可解析、sequence 严格递增、无文字原值泄露且已有 `closeObserved=true` 时才接受。0A 继续坚持 WndProc 不做磁盘 I/O,不为了补齐 `session_end` 改变被测时序。
### 23. 搜狗实测与输入布局请求补观测(2026-10-04)
搜狗真实提交会话目录为 `TestResults/InputProbe/manual-20261004-204556-353-Sogou`:
- JSONL 共 2120 条,全部可解析,`sequence` 严格递增,文字原始 `wParam` 泄露为 0。
- WndProc 与宿主线程分别为 12600 和 668;线程 ID 仅代表本次会话。
- 观察到 3 次 START、3 次 COMPOSITION、3 次 END,3 次 `GCS_RESULTSTR` 均成功。
- 三次结果分别为 7、2、6 个有效 Unicode scalar;对应 CP936 预期回声为 14、4、12 字节,均未使用默认替代字符。
- `WM_IME_CHAR` 三次分别为 14/14、4/4、12/12 `matched`;`WM_CHAR` 三次均为 0/N `expired`。
- 共有 3 条 `records_lost`,原因均为 `wndproc_lock_contention`;关闭链完整且写出 `window_detach/session_end`。
微软拼音与搜狗在当前 ANSI EasyX + CP936 环境下给出相同提交拓扑:`GCS_RESULTSTR` 是正式提交,随后出现完整匹配的 `WM_IME_CHAR` 回声,没有形成可匹配的 `WM_CHAR` 回声。阶段 1 必须以结果串只提交一次,并把匹配回声标记为观察/转发而不重复入队;不得按字符内容做全局去重。
短输入布局切换会话目录为 `TestResults/InputProbe/manual-20261004-223833-893-Sogou`:
- JSONL 共 1048 条,全部可解析,`sequence` 严格递增,无文字原值泄露;4 条丢失均为 `wndproc_lock_contention`。
- 日志观察到 `VK_LWIN`、焦点往返、Shift 和 A,但 `WM_INPUTLANGCHANGE` 为 0,关闭与 `session_end` 完整。
- 该会话运行时的探测器尚未把 `WM_INPUTLANGCHANGEREQUEST` 列入默认观察集合,因此不能用“日志里没有 request”证明系统没有发送 request。
- 这暴露了 0A schema/观察面的组织性缺口,而不是用户操作失败。现已把 request 纳入 message name、window category、原始参数和方向 flags 观察,并在分析摘要中分别统计 request/change。
- IME 内部中英文模式、TSF 输入配置文件和 HKL/代码页是不同层次;只有实际 `WM_INPUTLANGCHANGE` 才先清 `pendingLead`、再更新活动代码页,不能把 Win+Space、Shift 或配置文件切换本身当作代码页已经变化。
补观测后的专用 Win+Space 短测目录为 `TestResults/InputProbe/manual-20261004-231414-758-Sogou`:
- JSONL 共 1243 条,全部可解析,`sequence` 严格递增,无文字原值泄露;2 条丢失均为已显式记账的 `wndproc_lock_contention`,关闭链与 `session_end` 完整。
- 新版观察面下 `WM_INPUTLANGCHANGEREQUEST=0`、`WM_INPUTLANGCHANGE=0`,同时也未出现 `WM_IME_SELECT`。
- 日志观察到 `VK_LWIN`、窗口焦点往返;输入 A 时按键进入 `VK_PROCESSKEY`,随后产生 1 次 IME composition/result 和匹配 `WM_IME_CHAR` 回声,说明切换后的输入配置文件仍属于中文 IME 路径。
- `Get-WinUserLanguageList` 只返回 `zh-Hans-CN`,但含多个输入配置文件。因此这次 Win+Space 只能验证同一中文 HKL/CP936 内的 TSF 配置文件切换,不具备跨语言 HKL 条件。
- 结论:同 HKL 的 TSF 配置文件切换不保证产生 request/change;跨语言 HKL 的消息行为仍未实测。验证后未新增英文键盘布局,因为那会改变用户系统配置。
## 实现与影响
- 关键实现点:
- 已增加内部 `InputProbe` 第一、二切片,使用固定内存记录和安全点 JSONL 序列化。
- 实测 EasyX 窗口过程与框架宿主循环位于不同线程,因此共享状态按双线程模型实现:WndProc 使用非阻塞 `TryAcquireSRWLockExclusive`,宿主安全点复制快照后在锁外写盘。
- WndProc enter/leave 覆盖现有提前返回分支;调用旧窗口过程后原样返回其结果。
- 0A 和 0B 必须共用 schema,消费实验不得另造不可比较的日志格式。
- 涉及文件 / 类 / 函数:
- `src/StellarX/internal/SxInputProbeInternal.h`:编译开关下的内部接口及默认关闭空实现。
- `src/StellarX/SxInputProbe.cpp`:会话、记录、缓冲、序列化、flush 与窗口/键盘/IME 元数据采集。
- `src/StellarX/Window.cpp`:WndProc、普通事件循环和安全点接入。
- `src/StellarX/Dialog.cpp`:模态循环批次与安全点接入。
- `StellarX.Core.vcxproj`:源文件登记及 `StellarXInputProbe` 编译期开关。
- `tests/EasyXThreadProbe.cpp`、`tests/EasyX.ThreadProbe.vcxproj`:不链接 StellarX 的线程归属独立复现。
- `scripts/run-easyx-thread-probe.ps1`:独立复现运行入口。
- `scripts/run-input-probe-manual.ps1`、`scripts/analyze-input-probe.ps1`:微软拼音/搜狗人工测试与隐私安全汇总入口。
- 兼容性影响:
- 探测关闭时不产生文件、线程、定时器或公共 API 变化;实测即使设置运行环境变量也不会创建日志。
- 探测开启时业务消息参数、转发次数和返回值保持不变。
- 性能影响:
- 开启探测会增加 QPC、IMM 查询、UTF-16 扫描、HMAC、字段解析和内存写入开销;全部仅存在于显式探测构建与运行开关下。
- 通过预分配和安全点批量序列化避免在 WndProc 执行文件 I/O。
- 风险点:
- 探测本身仍会产生微小时序扰动,不能把日志视为零扰动的硬件级跟踪。
- 缓冲达到 hardLimit 时因果链可能不完整,必须依赖 `records_lost` 判断。
- 错误记录原始 `wParam`、组合串或明文测试日志可能泄露用户输入。
- WndProc 消息时间来源不可靠时,错误填充 `messageTime` 会制造虚假顺序证据。
## 测试与验证
- 测试范围:
- 自动验证默认关闭与探测开启两种 Debug x64 构建、现有单元测试和 JSONL 结构。
- 固定消息 smoke 验证 WndProc/EasyX 双观察链、线程模型、enter/leave 配对、隐私字段和丢失记录。
- 合成 IME smoke 只验证 `imm_query` schema、空结果错误路径和隐私边界,不冒充真实 IME 提交验证。
- 已执行微软拼音与搜狗真实人工测试;结论只覆盖当前 Windows/EasyX/CP936 环境,不能外推到所有 IME。
- 验证步骤:
1. 实现前只读定位 WndProc、EasyX EX_KEY、普通循环和模态循环的现有接入点。
2. 实现 0A 后比较探测关闭/开启时旧窗口过程参数、调用次数、返回值和现有业务行为。
3. 使用预定非敏感测试串验证 WndProc/EasyX/IME 记录、在线回声关联、缓冲溢出和各类 flush 边界。
- 验证结果:
- 2026-10-03:默认关闭的 `StellarX.Core` Debug x64 编译通过;探测开启的 `StellarX.Core` 与 `StellarX.UiFixture` Debug x64 编译、链接通过。
- 2026-10-03:`scripts/test.ps1 -Suite Unit -Configuration Debug -Platform x64` 通过,15/15。
- 2026-10-03 固定消息 smoke:91 行 JSONL 全部可解析,`sequence` 严格递增;固定 A 键的 Down/Up 在 WndProc 与 EasyX 两条链均可观察,所有键消息 enter/leave 配对,无文本原始 `wParam` 泄露,无 `records_lost`。
- 同次 smoke 实测 WndProc 线程 ID 为 11940、宿主循环线程 ID 为 15220,证明二者不是同一线程;线程 ID 仅代表本次会话,不是稳定常量。
- 同次 smoke 实测 `isWindowUnicode=false`,即当前 EasyX 窗口为 ANSI;`dpiForWindow=96`。
- 测试窗口短暂取得前台期间还观察到环境产生的其他键盘消息,因此固定消息结论按 virtual key、scan code 和 lParam flags 精确匹配,不以本次键消息总数作为稳定断言。
- `WM_CLOSE` enter 之后旧窗口过程没有返回到当前 thunk,进程正常结束,因而该消息没有 leave,析构路径也没有写出 `window_detach/session_end`;不得伪造配对或会话结束记录。
- 默认关闭构建在设置 `STELLARX_INPUT_PROBE=1` 后运行,指定输出目录文件数仍为 0。
- 独立 EasyX 复现实测:main/`peekmessage` 线程 18860;HWND 属主、裸 WndProc 和固定键 WndProc 线程 29472;固定键在 WndProc/EasyX 两面各观察 2 条;窗口为 ANSI。线程 ID 只代表该次会话。
- 第二切片合成 IME smoke:24 行 JSONL 全部可解析、sequence 严格递增、产生 1 条 `imm_query`,无真实结果时状态为 `empty`,无原始文本泄露且无 `records_lost`。
- 2026-10-04 微软拼音真实会话:4609 行 JSONL 全部可解析、sequence 严格递增;START/END 为 6/6,3 次结果均为 2 个 scalar,3 次 `WM_IME_CHAR` 均 4/4 matched,3 次 `WM_CHAR` 均 0/4 expired;无文字原值泄露,存在 5 条显式 `wndproc_lock_contention` 丢失。
- 同次真实关闭发现并修复 `BUG-20261004-0001`;普通主循环和真实模态循环的原生关闭回归均在 5 秒内自行退出。
- 探测开启的最终自动关闭 smoke 产生 8 份可解析 JSONL,全部 sequence 严格递增、`closeObserved=true`、无文字原值泄露、无 `records_lost`。较早一轮中两条 `Start-Process + SC_CLOSE` 用例曾以 `0xC000013A` 结束且未刷出最后的 `WM_CLOSE`,说明该尾批落盘存在宿主时序差异;最终成功轮次不抹去该已知限制。
- 2026-10-04 搜狗真实提交会话:2120 行 JSONL;3 次结果分别为 7、2、6 个 scalar,`WM_IME_CHAR` 分别 14/14、4/4、12/12 matched,`WM_CHAR` 均 expired;无文字原值泄露,3 条丢失均为 `wndproc_lock_contention`,正常写出 `session_end`。
- 2026-10-04 输入布局短会话:1048 行 JSONL,观察到 LWin/Shift/A 与焦点往返,未观察到 `WM_INPUTLANGCHANGE`;旧探测构建未采集 `WM_INPUTLANGCHANGEREQUEST`,因此该会话对 request 的存在性不作结论。
- 2026-10-04 request 补观测最终实现后:探测开启的 `StellarX.Core` 与 `StellarX.UiFixture` Debug x64 重新编译、链接通过;UI 回归 1 轮通过,产生 8 份 JSONL,全部可解析、`sequence` 严格递增且无文字原值泄露;其中 1 份有 1 条已显式记账的 `wndproc_lock_contention`,其余 7 份无丢失。自动场景没有执行输入布局切换,因此 request/change 均为 0,不冒充人工切换证据。
- 新版 `scripts/analyze-input-probe.ps1` 已重新读取微软拼音与两份搜狗旧日志;新增 request 统计不破坏旧 schema,但旧日志中的 0 仅表示当时没有采集该消息。
- 同一最终源码重新执行 Debug x64 单元测试,15/15 通过;编译输出仅包含 EasyX 匿名联合和仓库原有成员遮蔽/未使用项警告。
- 2026-10-04 专用 Win+Space 短测:1243 条 JSONL 可解析且 sequence 严格递增,request/change 均为 0;当前系统仅有 `zh-Hans-CN`,该结果只证明同 HKL 的 TSF 输入配置文件切换不产生这两类消息。
- 2026-10-05 提交前复验:独立线程探针再次得到 `separateWindowThread=true`、`wndProcMatchesOwner=true`、`keyCrossedBothBackends=true`、`windowUnicode=false`;探测开启 UI 回归 1 轮产生 8 份 JSONL,全部可解析、sequence 严格递增、无文字原值泄露、无 `records_lost`,自动原生关闭均保留 `closeObserved=true` 且不伪造 `session_end`;探测关闭构建的 VSTest 15/15 通过。
- 已知限制 / 遗留问题:
- 微软拼音和搜狗的结果摘要与回声顺序已经确认;其他 IME、其他 EasyX/Windows 版本仍可能不同。
- `WM_INPUTLANGCHANGEREQUEST` 补观测是在两次搜狗会话之后加入,既有日志无法追溯恢复当时被过滤的 request;补观测后的短测已完成,但当前机器没有第二种语言/HKL,仍无法覆盖真实 request/change 分支。
- 后续短测使用 `scripts/run-input-probe-manual.ps1 -ImeName Sogou -InputLanguageOnly`;该开关只缩短人工提示,不改变构建、探测、日志或摘要路径。
- 默认缓冲容量和最大滞留时间仍需在真实 IME 日志量下校准。
- WndProc 与 EasyX 记录能否精确匹配取决于实际可见字段,允许得出 `ambiguous` 结论。
- 旧窗口过程处理 `WM_CLOSE` 时可能不返回;异常或直接终止场景只保证保留此前安全点已写出的记录,不保证存在 `session_end`。
- 宽字符 GDI 和 DPI 探测由 [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md) 承接,不硬塞进键盘消息 schema。
## 落地信息
- 关联功能变更 ID:无
- 关联 BUG / Fix:[`BUG-20261004-0001`](../BUG/BUG-20261004-0001-EasyX原生窗口销毁后宿主事件循环未退出.md) / [`Fix-BUG-20261004-0001`](../Fix/Fix-BUG-20261004-0001-原生窗口销毁后的宿主循环退出修复.md)
- Commit:`c218c25`(阶段 0A 诊断实现与线程归属复现);`c0eca1d`(探测期间发现并修复的原生窗口关闭缺陷)
- PR:无
- 发布版本:未发布
- 相关文档:
- [`Module-20261001-0001-通用输入焦点与键盘路由系统设计.md`](./Module-20261001-0001-通用输入焦点与键盘路由系统设计.md)
- [`Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md`](./Module-20261001-0003-阶段0C宽字符绘制与IME坐标探测规格.md)
- [TranslateMessage](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-translatemessage)
- [WM_UNICHAR](https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-unichar)
- [WM_IME_COMPOSITION](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-composition)
- [WM_IME_CHAR](https://learn.microsoft.com/en-us/windows/win32/intl/wm-ime-char)
- [ImmGetCompositionStringW](https://learn.microsoft.com/en-us/windows/win32/api/imm/nf-imm-immgetcompositionstringw)