Files
msime-windows/windows/tests/generate_commit_reply_fixture.py
Jia Suiandfanlusky 3b1206ea82 fix(windows,engine): 提交键回复等待不再因超时重置会话或丢字 (#1019)
* fix(windows,engine): 提交键回复等待不再因超时重置会话或丢字

服务端按键处理偶发数百毫秒尖峰时,提交键(空格顶首选 / 确定选词 / 标点顶高亮候选)
的回复等待会走到会话内那条 300ms 同步读;一超时就被判成 TransportUnavailable
→ DeliveryAmbiguous → Transport 完整重置(丢键 + 吞队列 + 清组合 + 轮换 epoch),
用户看到的是整段已输入内容消失。

- 删除 FANY_IME_COMMIT_REPLY_SESSION_WAIT_MS 那条破坏性兜底读。会话侧改为非阻塞探测,
  拿不到回复返回「未就绪」,由 session 自身在 DoEditSession 内交回,沿用同一个
  FANY_IME_COMMIT_REPLY_TIMEOUT_MS 分片轮询窗口,不重开窗口、不新造等待原语。
- 交回必须发生在 session 内部:RequestEditSession 用 TF_ES_ASYNCDONTCARE,TSF 异步授权
  时调用方读不到 session 的 HRESULT;若靠调用方读返回值,该键会既不丢也不提交,
  deferred 闸门再不打开(后续按键全部只入队)。也不能改用 TF_ES_SYNC(WinWord 上不成立)。
- 真失败(写失败 / 读失败 / 坏帧 / 字节数不符 / 无 request id / 无管道)仍判 Transport 重置。
- 预取覆盖面扩到 candidate 类 finalize(空格顶首选等),堵住原先只能靠那条 300ms
  兜底读的路径。
- 新增按键级延迟诊断 bench_key_latency_diag(build 里有同名 exe 但源码已不在树),
  以及两份对照测试:commit_reply_not_ready(回复三态与交回状态机)、
  candidate_consistency(同配置下候选集合/顺序/分页逐字一致,引擎侧护栏)。

实测(bench_key_latency_diag):稳态逐键 median 0.6~0.8ms / max 12.5ms,尖峰指向内存
压力下的页回收而非稳态算力。在「结果完全不变」的约束下,预热 / 缓存 / 投机预解码均无效
或反向,故本次不改引擎打分路径。

* fix(windows): 直接标点提交会话补齐未就绪交回,并写准窗口沿用边界

审查 `7aca5e5d` 发现的问题:

- `CPunctuationCommitEditSession` 在 `FANY_S_COMMIT_REPLY_NOT_READY` 时只抑制了 token
  结算(`applied` / `_FailDeferredKey` 都不走),**没有**像 `CKeyHandlerEditSession` 那样把
  请求交回 `_PollCommitReply`。一旦走到这条分支——`_HandleCompositionPunctuation` 的高亮候选
  分支要求 `_candidateMode != CANDIDATE_NONE`,而派发该会话的条件是
  `_candidateMode == CANDIDATE_NONE`,两者在 session 执行时才求值——deferred token 会永久留在
  飞行中,后续所有按键只入队不处理,正是本 PR 在别处修掉的失效模式。现在两处都交回,
  并把原因写进注释,避免下一个人再照半个写法抄一遍。
- `_HandOffUnreadyCommitReply` 的两处注释与实现不符或不够具体:交回沿用第一片已经开始的窗口;
  只有从未开窗的路径(标点处理函数在派发时高亮候选条件为假、session 执行时条件成立)才在此
  开窗,那种情况键一点没等过、`waitedMs` 为 0。另一支(无法保活 → `_FailUnansweredCommitKey`)
  只在请求不属于协作轮询时到达,实务上即 `commitReplyMessage == 0`:派发没能用消息窗,
  没有可交回的对象。

签名相应扩展:`_RequestDirectPunctuationEditSession` 新增 `commitReplyMessage` /
`commitReplyDeadlineTick`,`CPunctuationCommitEditSession` 携带它们。没有行为回退,两个新参数
默认 0 时与改动前一致(而那正是「无消息窗」的既有降级路径)。

验证:windows x64 Release 构建通过,`ctest` 17/17 通过,`clang-format 18.1.8 --dry-run
--Werror --style=file` 对本次涉及文件全过。

* fix(windows): 把 _HandOffUnreadyCommitReply 移到公共区,修 CI 编译失败

CI 的 Windows x64 与 Windows Win32 两条腿都挂在同一处编译错误:

  MetasequoiaIME_AsyncRequests.cpp: error C2248:
  “CMetasequoiaIME::_HandOffUnreadyCommitReply”: 无法访问 private 成员

上一提交给 CPunctuationCommitEditSession 补「未就绪交回」时调用了这个成员,但它在 private
区。CKeyHandlerEditSession 能调是因为它在 MetasequoiaIME.h 里被显式声明为 friend;而
CPunctuationCommitEditSession 定义在 MetasequoiaIME_AsyncRequests.cpp 的匿名命名空间里
(该文件 31-254 行),头文件里的 friend 声明指不到它,加 friend 也无效。

处置:把该声明移到公共区,与同样由 session 调用的 _FailDeferredKey、_CompleteDeferredKeyReplay、
_HandleCompositionPunctuation 并列,并在注释里写明「它必须公共」的原因,免得下次有人顺手挪回私区。
仅可见性与注释,行为零变化。

验证(triplet 与 CI 一致,且这次看退出码、不截断构建输出):
- x64-windows-static:cmake --build build --config Release --parallel 退出码 0;
  ctest --no-tests=error 17/17。
- x86-windows-static:cmake --build build32 --config Release --parallel 退出码 0;
  ctest --no-tests=error 17/17。
- clang-format 18.1.8 --dry-run --Werror --style=file 对本次改动文件通过。

另:上一轮「构建通过」是我误报——当时用 Select-String 过滤构建输出又加了 -Last 8,错误行被截掉,
且没有检查 $LASTEXITCODE;ctest 过的是测试目标,与 DLL 编译失败并不冲突。

* fix(windows): 提交回复交回后轮询不再停摆,同步兜底保留有界等待

- 轮询定时器按请求 id 判断是否仍在等待:session 交回时换了新 token,
  按旧 token 查会误判为已结算并 KillTimer,交回的请求从此无人再分发。
- 没有轮询可交回的 session(同步 _InvokeKeyHandler 路径)恢复
  FANY_IME_COMMIT_REPLY_SESSION_WAIT_MS 的单次有界等待,不再一读不到就丢键重置。
- 确定并输入在提交回复为传输失败时不再继续键入字符并返回 S_OK。
- session 交回占用新槽位时同样受 kMaxPendingAsyncKeys 约束。
- 修正注释:未就绪由 session 自己的完成钩子交回,不是消息处理函数。
- 测试:覆盖轮询分发、等待上限与满表交回;SAL 剥离按最长前缀匹配;
  引擎一致性用例③不再清缓存,否则测不到跨输入的缓存泄漏。

---------

Co-authored-by: fanlusky <1730976608@qq.com>
2026-10-17 16:19:26 +08:00

147 lines
5.5 KiB
Python

"""Compile the production commit-reply decision paths against a standalone service fixture.
The TSF service, its message window and the reply pipe cannot be instantiated without a host,
so the paths that decide what an *unready* committing reply costs are extracted verbatim
instead of copied into the test: the three-state reply read, the session's wait bound, the
hand-off that keeps a request alive, the poll timer pass, and both session completion hooks. Only the pipe probe, the message window and the
clock remain test doubles. Regenerate whenever one of the extracted functions changes, so a
removed step cannot hide behind a copy here.
Two headers are produced, because the test's service stub has to declare the extracted
`AsyncKeyRequest` before it can own a request table: `<...>_types_source.h` with the types the
stub needs, then `<...>_source.h` with the extracted definitions.
"""
from pathlib import Path
import re
import sys
TOKENS = re.compile(
r'//[^\n]*|/\*.*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]',
re.DOTALL,
)
# SAL annotations travel in the production signatures; the fixture has no sal.h.
# Longest spellings first: alternation takes the first match, so `In` ahead of
# `Inout` would leave `out_` behind.
SAL = re.compile(
r"\b_(?:Outptr_result_maybenull_|Inout_opt_|Inout|In_opt_|In_z_|In|Out_opt_|Out_z_|Out)_?[ \t]*"
)
def find_body(source, marker, after=0):
"""Return (signature, body) for the definition the marker names."""
start = source.index(marker, after)
opening = None
depth = 0
for token in TOKENS.finditer(source, start):
if token.group() == "{":
if opening is None:
opening = token.start()
depth += 1
elif token.group() == "}":
depth -= 1
if opening is not None and depth == 0:
return SAL.sub("", source[start:opening]).strip(), source[
opening : token.end()
]
raise ValueError(f"Unclosed production definition: {marker}")
def find_declaration(source, marker):
"""Return the verbatim `marker ... ;` declaration."""
start = source.index(marker)
return source[start : source.index(";", start) + 1]
def generate(
ipc_header,
ipc_source,
ime_header,
async_source,
session_source,
types_output,
source_output,
):
ipc_h = Path(ipc_header).read_text(encoding="utf-8")
ipc_cpp = Path(ipc_source).read_text(encoding="utf-8")
ime_h = Path(ime_header).read_text(encoding="utf-8")
async_cpp = Path(async_source).read_text(encoding="utf-8")
session_cpp = Path(session_source).read_text(encoding="utf-8")
banner = "// Generated from the production commit-reply paths; do not edit.\n"
status_signature, status_body = find_body(ipc_h, "enum class CommitReplyWaitStatus")
request_signature, request_body = find_body(ime_h, "struct AsyncKeyRequest")
types = banner
types += f"{status_signature}\n{status_body};\n"
types += (
find_declaration(ipc_h, "constexpr DWORD FANY_IME_COMMIT_REPLY_TIMEOUT_MS")
+ "\n"
)
types += (
find_declaration(ipc_h, "constexpr DWORD FANY_IME_COMMIT_REPLY_SESSION_WAIT_MS")
+ "\n"
)
types += (
find_declaration(ipc_h, "constexpr HRESULT FANY_S_COMMIT_REPLY_NOT_READY")
+ "\n"
)
types += (
find_declaration(ime_h, "static constexpr size_t kMaxPendingAsyncKeys")
+ "\n"
)
types += (
find_declaration(ipc_h, "constexpr HRESULT FANY_E_COMMIT_REPLY_AMBIGUOUS")
+ "\n"
)
types += f"{request_signature}\n{request_body};\n"
Path(types_output).write_text(types, encoding="utf-8")
# The punctuation commit session precedes the Completion struct it defines; anchoring the
# search there keeps the extraction on that struct if another `struct Completion` appears.
punctuation_session = async_cpp.index("class CPunctuationCommitEditSession")
reply_session = session_cpp.index("STDAPI CKeyHandlerEditSession::DoEditSession")
poll_signature, poll_body = find_body(
async_cpp, "bool IsCommitReplyPollMessage(UINT message)"
)
requeue_signature, requeue_body = find_body(
async_cpp, "bool CMetasequoiaIME::_RequeueAsyncKeyRequest("
)
handoff_signature, handoff_body = find_body(
async_cpp, "void CMetasequoiaIME::_HandOffUnreadyCommitReply("
)
wait_signature, wait_body = find_body(
async_cpp, "DWORD CMetasequoiaIME::_SessionCommitReplyWaitMs()"
)
dispatch_signature, dispatch_body = find_body(
async_cpp, "void CMetasequoiaIME::_DispatchPendingCommitReplyPolls()"
)
read_signature, read_body = find_body(
ipc_cpp, "CommitReplyWaitStatus TryReadCommitReplyFromServerPipe("
)
completion_signature, completion_body = find_body(
async_cpp, "struct Completion", punctuation_session
)
replay_signature, replay_body = find_body(
session_cpp, "struct DeferredReplayCompletion", reply_session
)
source = banner
source += f"{poll_signature}\n{poll_body}\n"
source += f"{requeue_signature}\n{requeue_body}\n"
source += f"{handoff_signature}\n{handoff_body}\n"
source += f"{wait_signature}\n{wait_body}\n"
source += f"{dispatch_signature}\n{dispatch_body}\n"
source += f"{read_signature}\n{read_body}\n"
source += f"{completion_signature}\n{completion_body};\n"
source += f"{replay_signature}\n{replay_body};\n"
Path(source_output).write_text(source, encoding="utf-8")
if __name__ == "__main__":
generate(*sys.argv[1:])