docs: 输入系统 v0.7 设计与 0A 探测规格

This commit is contained in:
Codex
2026-10-05 01:14:58 +08:00
parent c218c25310
commit 3d08bac1b9
5 changed files with 2445 additions and 0 deletions
@@ -0,0 +1,792 @@
# 新增功能模块 / 模块重构
> 适用场景:新增模块、重大模块重构、核心架构能力演进。
> 不适用场景:小接口或轻量功能变更,请使用“功能变更”模板。
## 基本信息
- 模块 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)