* 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>
593 lines
22 KiB
C++
593 lines
22 KiB
C++
// The client-side commit-reply contract: a committing reply that has not arrived yet must never
|
|
// be reported as a transport failure, must never drop the key waiting for it, and must be handed
|
|
// back to the cooperative poll on the same window.
|
|
//
|
|
// The TSF service, its message window and the reply pipe cannot be instantiated without a host,
|
|
// so the paths that make that decision are compiled from the production sources into
|
|
// commit_reply_source.h (see generate_commit_reply_fixture.py). Only the pipe probe, the message
|
|
// window and the clock are doubles here. Regenerate the fixture when any of those paths changes.
|
|
|
|
#include "Key/DeferredKeyFailurePolicy.h"
|
|
#include "../../engine/contracts/windows_ipc.h"
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <functional>
|
|
#include <iostream>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
// --- the slice of the Win32 / project surface the extracted paths are written against --------
|
|
|
|
using HRESULT = long;
|
|
using UINT = unsigned int;
|
|
using WCHAR = wchar_t;
|
|
using DWORD = unsigned long;
|
|
using ULONGLONG = unsigned long long;
|
|
using UINT_PTR = unsigned long long;
|
|
using WPARAM = unsigned long long;
|
|
using LONG = long;
|
|
using HWND = UINT_PTR;
|
|
using BOOL = int;
|
|
|
|
constexpr HRESULT S_OK = 0;
|
|
constexpr HRESULT E_FAIL = static_cast<HRESULT>(0x80004005L);
|
|
constexpr unsigned long SEVERITY_SUCCESS = 0;
|
|
constexpr unsigned long FACILITY_ITF = 4;
|
|
constexpr unsigned long FACILITY_WIN32 = 7;
|
|
|
|
constexpr unsigned long MAKE_HRESULT(unsigned long severity, unsigned long facility, unsigned long code)
|
|
{
|
|
return (severity << 31) | (facility << 16) | code;
|
|
}
|
|
|
|
// ERROR_TIMEOUT (1460) / ERROR_BROKEN_PIPE (109) through the same mapping winerror.h uses.
|
|
constexpr HRESULT HResultFromWin32(unsigned long code)
|
|
{
|
|
return (code & 0x0000FFFF) | (FACILITY_WIN32 << 16) | 0x80000000L;
|
|
}
|
|
constexpr HRESULT ERROR_TIMEOUT = HResultFromWin32(1460);
|
|
constexpr HRESULT ERROR_BROKEN_PIPE = HResultFromWin32(109);
|
|
|
|
// The production hooks carry the Win32 spelling of this mapping.
|
|
#define __HRESULT_FROM_WIN32(code) HResultFromWin32(code)
|
|
#define HRESULT_FROM_WIN32(code) HResultFromWin32(code)
|
|
|
|
// windows/src/IME/MetasequoiaIME.h; the private-message base is fixed by Win32.
|
|
constexpr UINT WM_USER = 0x0400;
|
|
constexpr UINT WM_AsyncFinalizeCandidate = WM_USER + 11;
|
|
constexpr UINT WM_AsyncPunctuationCommit = WM_USER + 12;
|
|
constexpr UINT WM_AsyncNumberCandidateCommit = WM_USER + 13;
|
|
constexpr UINT WM_AsyncServerCandidateKey = WM_USER + 14;
|
|
constexpr UINT WM_AsyncCycleInputMode = WM_USER + 29;
|
|
constexpr UINT_PTR TIMER_COMMIT_REPLY_POLL = 8;
|
|
constexpr UINT COMMIT_REPLY_POLL_INTERVAL_MS = 1;
|
|
|
|
// The enumerators the fixture round-trips. The values are not asserted; only that whatever the
|
|
// message handler classified the key as comes back unchanged for the re-run.
|
|
enum KEYSTROKE_FUNCTION
|
|
{
|
|
FUNCTION_FINALIZE_CANDIDATELIST = 0,
|
|
FUNCTION_FINALIZE_CANDIDATELIST_AND_INPUT,
|
|
FUNCTION_PUNCTUATION,
|
|
FUNCTION_SELECT_BY_NUMBER,
|
|
FUNCTION_CONVERT,
|
|
};
|
|
|
|
namespace Global
|
|
{
|
|
namespace DataFromServerMsgType = FanyImeReplyType;
|
|
} // namespace Global
|
|
|
|
#include "commit_reply_types_source.h"
|
|
|
|
namespace
|
|
{
|
|
// --- pipe probe / clock doubles --------------------------------------------------------------
|
|
|
|
bool replyAvailable = false; // what the nonblocking probe reports
|
|
int readCalls = 0; // how many times the session entered the read path
|
|
DWORD lastReadTimeoutMs = 0; // the wait it asked the read path for
|
|
FanyImeNamedpipeDataToTsf frame{}; // the frame the read path yields, when it yields one
|
|
bool readYieldsFrame = false;
|
|
|
|
ULONGLONG clockMs = 10000;
|
|
bool timerAvailable = true;
|
|
bool postAvailable = true;
|
|
bool timerKilled = false;
|
|
UINT nextToken = 100;
|
|
// What the message window does with a request the poll timer re-dispatches.
|
|
std::function<void(UINT message, UINT token)> onSendMessage;
|
|
|
|
bool HasCommitReplyToRead(uint64_t)
|
|
{
|
|
return replyAvailable;
|
|
}
|
|
|
|
FanyImeNamedpipeDataToTsf *TryReadDataFromServerPipeWithDeadline(uint64_t, bool, DWORD timeoutMs, bool *notReady)
|
|
{
|
|
++readCalls;
|
|
lastReadTimeoutMs = timeoutMs;
|
|
if (!readYieldsFrame)
|
|
{
|
|
if (notReady != nullptr)
|
|
{
|
|
*notReady = true;
|
|
}
|
|
return nullptr;
|
|
}
|
|
if (notReady != nullptr)
|
|
{
|
|
*notReady = false;
|
|
}
|
|
return &frame;
|
|
}
|
|
|
|
BOOL IsWindow(HWND window)
|
|
{
|
|
return window != 0 ? 1 : 0;
|
|
}
|
|
|
|
UINT_PTR SetTimer(HWND, UINT_PTR, UINT, void *)
|
|
{
|
|
return timerAvailable ? 1 : 0;
|
|
}
|
|
|
|
BOOL PostMessage(HWND, UINT, WPARAM, LONG)
|
|
{
|
|
return postAvailable ? 1 : 0;
|
|
}
|
|
|
|
LONG SendMessage(HWND, UINT message, WPARAM token, LONG)
|
|
{
|
|
if (onSendMessage)
|
|
{
|
|
onSendMessage(message, static_cast<UINT>(token));
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
BOOL KillTimer(HWND, UINT_PTR)
|
|
{
|
|
timerKilled = true;
|
|
return 1;
|
|
}
|
|
|
|
UINT NextWindowMessageToken()
|
|
{
|
|
return nextToken++;
|
|
}
|
|
|
|
ULONGLONG GetTickCount64()
|
|
{
|
|
return clockMs;
|
|
}
|
|
|
|
void DebugTsfKeyLatency(const wchar_t *, uint64_t, double, HRESULT)
|
|
{
|
|
}
|
|
|
|
void resetDoubles()
|
|
{
|
|
replyAvailable = false;
|
|
readCalls = 0;
|
|
lastReadTimeoutMs = 0;
|
|
frame = {};
|
|
readYieldsFrame = false;
|
|
clockMs = 10000;
|
|
timerAvailable = true;
|
|
postAvailable = true;
|
|
timerKilled = false;
|
|
nextToken = 100;
|
|
onSendMessage = nullptr;
|
|
}
|
|
|
|
class CMetasequoiaIME
|
|
{
|
|
public:
|
|
HWND _msgWndHandle = 1;
|
|
std::mutex _pendingCommitCandidateMutex;
|
|
std::map<UINT, AsyncKeyRequest> _pendingAsyncKeyMessages;
|
|
UINT _sessionCommitReplyMessage = 0;
|
|
|
|
bool _RequeueAsyncKeyRequest(UINT token, UINT message, AsyncKeyRequest request);
|
|
DWORD _SessionCommitReplyWaitMs() const;
|
|
void _DispatchPendingCommitReplyPolls();
|
|
void _HandOffUnreadyCommitReply(uint64_t requestId, uint64_t deferredReplayToken, UINT message,
|
|
ULONGLONG commitReplyDeadlineTick, UINT code, WCHAR wch,
|
|
KEYSTROKE_FUNCTION keystrokeFunction, const std::wstring &prefetchedText,
|
|
uint64_t focusToken, uint64_t compositionEpoch);
|
|
|
|
// Recorded outcomes of a deferred key: exactly one of these must happen for every key.
|
|
void _CompleteDeferredKeyReplay(uint64_t token)
|
|
{
|
|
completedTokens.push_back(token);
|
|
}
|
|
|
|
void _FailDeferredKey(uint64_t token, DeferredKeyFailureReason reason)
|
|
{
|
|
failedTokens.push_back(token);
|
|
failureReasons.push_back(reason);
|
|
}
|
|
|
|
void _FailUnansweredCommitKey(uint64_t requestId, uint64_t deferredReplayToken)
|
|
{
|
|
unansweredRequestIds.push_back(requestId);
|
|
unansweredTokens.push_back(deferredReplayToken);
|
|
}
|
|
|
|
std::vector<uint64_t> completedTokens;
|
|
std::vector<uint64_t> failedTokens;
|
|
std::vector<DeferredKeyFailureReason> failureReasons;
|
|
std::vector<uint64_t> unansweredRequestIds;
|
|
std::vector<uint64_t> unansweredTokens;
|
|
};
|
|
|
|
#include "commit_reply_source.h"
|
|
|
|
} // namespace
|
|
|
|
#define CHECK(condition) \
|
|
do \
|
|
{ \
|
|
if (!(condition)) \
|
|
{ \
|
|
std::cerr << "Failed at line " << __LINE__ << ": " << #condition << '\n'; \
|
|
return 1; \
|
|
} \
|
|
} while (false)
|
|
|
|
namespace
|
|
{
|
|
// The read outcome a session sees for a committing key whose reply has not arrived. Every case
|
|
// below is a different production branch, driven through the real production body.
|
|
int checkReplyReadContract()
|
|
{
|
|
resetDoubles();
|
|
FanyImeNamedpipeDataToTsf *reply = &frame;
|
|
|
|
// A healthy pipe with nothing in it must be NotReady, and the session must not even enter
|
|
// the read path: this used to be a 300ms blocking read whose timeout came back as
|
|
// TransportUnavailable, which reset the session and dropped the key.
|
|
replyAvailable = false;
|
|
CommitReplyWaitStatus status = TryReadCommitReplyFromServerPipe(8818, /*blockingWaitMs=*/0, &reply);
|
|
CHECK(status == CommitReplyWaitStatus::NotReady);
|
|
CHECK(reply == nullptr);
|
|
CHECK(readCalls == 0);
|
|
|
|
// Readable, but the read path yields nothing at its cooperative boundary (only another
|
|
// request's frames were consumed): still NotReady, and it asked for a zero-length wait.
|
|
replyAvailable = true;
|
|
readYieldsFrame = false;
|
|
status = TryReadCommitReplyFromServerPipe(8818, /*blockingWaitMs=*/0, &reply);
|
|
CHECK(status == CommitReplyWaitStatus::NotReady);
|
|
CHECK(reply == nullptr);
|
|
CHECK(readCalls == 1);
|
|
CHECK(lastReadTimeoutMs == 0);
|
|
|
|
// A real transfer failure still comes back as the transport frame, so a broken pipe keeps
|
|
// its full reset: only the timeout stopped being a transport failure.
|
|
resetDoubles();
|
|
replyAvailable = true;
|
|
readYieldsFrame = true;
|
|
frame.msg_type = FanyImeReplyType::TransportUnavailable;
|
|
status = TryReadCommitReplyFromServerPipe(8818, /*blockingWaitMs=*/0, &reply);
|
|
CHECK(status == CommitReplyWaitStatus::Transport);
|
|
CHECK(reply == &frame);
|
|
|
|
// An authoritative reply is handed to the caller unchanged.
|
|
resetDoubles();
|
|
replyAvailable = true;
|
|
readYieldsFrame = true;
|
|
frame.msg_type = FanyImeReplyType::Normal;
|
|
status = TryReadCommitReplyFromServerPipe(8818, /*blockingWaitMs=*/0, &reply);
|
|
CHECK(status == CommitReplyWaitStatus::Ready);
|
|
CHECK(reply == &frame);
|
|
|
|
// A session nothing can re-run keeps the bounded single-shot wait: it reads even when the
|
|
// probe sees nothing yet, and a timeout comes back as the transport frame, never NotReady.
|
|
resetDoubles();
|
|
replyAvailable = false;
|
|
readYieldsFrame = true;
|
|
frame.msg_type = FanyImeReplyType::TransportUnavailable;
|
|
status = TryReadCommitReplyFromServerPipe(8818, FANY_IME_COMMIT_REPLY_SESSION_WAIT_MS, &reply);
|
|
CHECK(status == CommitReplyWaitStatus::Transport);
|
|
CHECK(reply == &frame);
|
|
CHECK(readCalls == 1);
|
|
CHECK(lastReadTimeoutMs == FANY_IME_COMMIT_REPLY_SESSION_WAIT_MS);
|
|
|
|
return 0;
|
|
}
|
|
|
|
// Which sessions may read without waiting: only one whose request a poll owns and can be handed
|
|
// back through a live message window.
|
|
int checkSessionWaitContract()
|
|
{
|
|
resetDoubles();
|
|
CMetasequoiaIME service;
|
|
service._sessionCommitReplyMessage = WM_AsyncFinalizeCandidate;
|
|
CHECK(service._SessionCommitReplyWaitMs() == 0);
|
|
|
|
// A key that reached its session without the message window has nothing to hand back to.
|
|
service._sessionCommitReplyMessage = 0;
|
|
CHECK(service._SessionCommitReplyWaitMs() == FANY_IME_COMMIT_REPLY_SESSION_WAIT_MS);
|
|
|
|
service._sessionCommitReplyMessage = WM_AsyncFinalizeCandidate;
|
|
service._msgWndHandle = 0;
|
|
CHECK(service._SessionCommitReplyWaitMs() == FANY_IME_COMMIT_REPLY_SESSION_WAIT_MS);
|
|
return 0;
|
|
}
|
|
|
|
// The poll timer pass must keep running while any request still waits, including one an edit
|
|
// session handed back under a fresh token during the very dispatch that pass made.
|
|
int checkDispatchContract()
|
|
{
|
|
resetDoubles();
|
|
CMetasequoiaIME service;
|
|
AsyncKeyRequest waiting;
|
|
waiting.message = WM_AsyncFinalizeCandidate;
|
|
waiting.requestId = 8818;
|
|
waiting.commitReplyDeadlineTick = clockMs + 700;
|
|
service._pendingAsyncKeyMessages[300] = waiting;
|
|
|
|
// The handler takes its request off the table and its session hands it back.
|
|
onSendMessage = [&](UINT, UINT token) {
|
|
AsyncKeyRequest request = service._pendingAsyncKeyMessages[token];
|
|
service._pendingAsyncKeyMessages.erase(token);
|
|
service._HandOffUnreadyCommitReply(request.requestId, 77, request.message, request.commitReplyDeadlineTick,
|
|
0x20, L' ', FUNCTION_FINALIZE_CANDIDATELIST, L"", 5, 6);
|
|
};
|
|
nextToken = 400;
|
|
service._DispatchPendingCommitReplyPolls();
|
|
CHECK(service._pendingAsyncKeyMessages.size() == 1);
|
|
CHECK(service._pendingAsyncKeyMessages.count(300) == 0);
|
|
CHECK(!timerKilled);
|
|
CHECK(service.unansweredRequestIds.empty());
|
|
|
|
// Once the handler settles the request, the timer stops.
|
|
onSendMessage = [&](UINT, UINT token) { service._pendingAsyncKeyMessages.erase(token); };
|
|
service._DispatchPendingCommitReplyPolls();
|
|
CHECK(service._pendingAsyncKeyMessages.empty());
|
|
CHECK(timerKilled);
|
|
return 0;
|
|
}
|
|
|
|
// The hand-off that keeps a not-ready commit alive: it must not extend the window, must not
|
|
// lose the key, and must never leave a request that nothing will ever poll.
|
|
int checkHandOffContract()
|
|
{
|
|
resetDoubles();
|
|
CMetasequoiaIME service;
|
|
clockMs = 20000;
|
|
const ULONGLONG windowEnd = clockMs + 700; // a window the first slice already started
|
|
|
|
service._HandOffUnreadyCommitReply(8818, /*deferredReplayToken=*/77, WM_AsyncFinalizeCandidate, windowEnd,
|
|
/*code=*/0x20, /*wch=*/L' ', FUNCTION_FINALIZE_CANDIDATELIST,
|
|
/*prefetchedText=*/L"一致", /*focusToken=*/5, /*compositionEpoch=*/6);
|
|
|
|
// The server is still answering the request the key was sent with; it is back in the poll on
|
|
// exactly the window it already had, with everything the re-run needs.
|
|
CHECK(service._pendingAsyncKeyMessages.size() == 1);
|
|
const AsyncKeyRequest &queued = service._pendingAsyncKeyMessages.begin()->second;
|
|
CHECK(queued.message == WM_AsyncFinalizeCandidate);
|
|
CHECK(queued.requestId == 8818);
|
|
CHECK(queued.deferredReplayToken == 77);
|
|
CHECK(queued.focusToken == 5);
|
|
CHECK(queued.compositionEpoch == 6);
|
|
CHECK(queued.keystrokeFunction == FUNCTION_FINALIZE_CANDIDATELIST);
|
|
CHECK(queued.prefetchedText == L"一致");
|
|
CHECK(queued.commitReplyDeadlineTick == windowEnd);
|
|
CHECK(service.unansweredRequestIds.empty());
|
|
CHECK(service.failedTokens.empty());
|
|
|
|
// A request that never started a window opens one now, and never stays at 0: the poll only
|
|
// re-dispatches requests whose wait has started, so a 0 here would hang the key forever.
|
|
service._pendingAsyncKeyMessages.clear();
|
|
service._HandOffUnreadyCommitReply(9000, 78, WM_AsyncPunctuationCommit, /*commitReplyDeadlineTick=*/0,
|
|
/*code=*/0xBE, /*wch=*/L'.', FUNCTION_PUNCTUATION, /*prefetchedText=*/L"",
|
|
/*focusToken=*/5, /*compositionEpoch=*/7);
|
|
CHECK(service._pendingAsyncKeyMessages.size() == 1);
|
|
CHECK(service._pendingAsyncKeyMessages.begin()->second.commitReplyDeadlineTick ==
|
|
clockMs + FANY_IME_COMMIT_REPLY_TIMEOUT_MS);
|
|
|
|
// A session never held a poll slot of its own, so the hand-off must take a free one instead
|
|
// of overwriting a key that is already waiting.
|
|
service._pendingAsyncKeyMessages.clear();
|
|
service._pendingAsyncKeyMessages[300].requestId = 41;
|
|
nextToken = 400;
|
|
service._HandOffUnreadyCommitReply(9100, 79, WM_AsyncNumberCandidateCommit, windowEnd, /*code=*/'9',
|
|
/*wch=*/L'9', FUNCTION_SELECT_BY_NUMBER, /*prefetchedText=*/L"", 5, 8);
|
|
CHECK(service._pendingAsyncKeyMessages.size() == 2);
|
|
CHECK(service._pendingAsyncKeyMessages[300].requestId == 41);
|
|
bool foundHandedOff = false;
|
|
for (const auto &entry : service._pendingAsyncKeyMessages)
|
|
{
|
|
if (entry.second.requestId == 9100)
|
|
{
|
|
foundHandedOff = true;
|
|
}
|
|
}
|
|
CHECK(foundHandedOff);
|
|
CHECK(service.unansweredRequestIds.empty());
|
|
|
|
// No window handle at all: the request cannot be kept alive, so the key is dropped as
|
|
// unanswered (never left in a table nothing will poll, never sent again).
|
|
resetDoubles();
|
|
CMetasequoiaIME stuck;
|
|
stuck._msgWndHandle = 0;
|
|
stuck._HandOffUnreadyCommitReply(9200, 80, WM_AsyncFinalizeCandidate, 0, /*code=*/0x20, /*wch=*/L' ',
|
|
FUNCTION_FINALIZE_CANDIDATELIST, L"", 5, 9);
|
|
CHECK(stuck.unansweredRequestIds.size() == 1);
|
|
CHECK(stuck.unansweredRequestIds.front() == 9200);
|
|
CHECK(stuck.unansweredTokens.front() == 80);
|
|
|
|
// The timer may be gone while the message window still works; the posted-message fallback
|
|
// keeps the request, and only losing both drops the key.
|
|
resetDoubles();
|
|
CMetasequoiaIME posted;
|
|
timerAvailable = false;
|
|
posted._HandOffUnreadyCommitReply(9300, 81, WM_AsyncFinalizeCandidate, 0, /*code=*/0x20, /*wch=*/L' ',
|
|
FUNCTION_FINALIZE_CANDIDATELIST, L"", 5, 10);
|
|
CHECK(posted.unansweredRequestIds.empty());
|
|
CHECK(posted._pendingAsyncKeyMessages.size() == 1);
|
|
|
|
resetDoubles();
|
|
CMetasequoiaIME dead;
|
|
timerAvailable = false;
|
|
postAvailable = false;
|
|
dead._HandOffUnreadyCommitReply(9400, 82, WM_AsyncFinalizeCandidate, 0, /*code=*/0x20, /*wch=*/L' ',
|
|
FUNCTION_FINALIZE_CANDIDATELIST, L"", 5, 11);
|
|
CHECK(dead._pendingAsyncKeyMessages.empty());
|
|
CHECK(dead.unansweredRequestIds.size() == 1);
|
|
CHECK(dead.unansweredRequestIds.front() == 9400);
|
|
|
|
// Only a committing message owns a poll; anything else is not handed back at all.
|
|
resetDoubles();
|
|
CMetasequoiaIME foreign;
|
|
foreign._HandOffUnreadyCommitReply(9500, 83, WM_USER + 9, windowEnd, 0, L'\0', FUNCTION_FINALIZE_CANDIDATELIST, L"",
|
|
5, 12);
|
|
CHECK(foreign._pendingAsyncKeyMessages.empty());
|
|
CHECK(foreign.unansweredRequestIds.size() == 1);
|
|
|
|
// A hand-off takes a fresh slot, so it is bounded like a freshly posted key: a full table
|
|
// drops the key as unanswered instead of growing past its bound.
|
|
resetDoubles();
|
|
CMetasequoiaIME full;
|
|
for (UINT token = 1; token <= kMaxPendingAsyncKeys; ++token)
|
|
{
|
|
full._pendingAsyncKeyMessages[token].requestId = token;
|
|
}
|
|
nextToken = 1000;
|
|
full._HandOffUnreadyCommitReply(9600, 84, WM_AsyncFinalizeCandidate, windowEnd, /*code=*/0x20, /*wch=*/L' ',
|
|
FUNCTION_FINALIZE_CANDIDATELIST, L"", 5, 13);
|
|
CHECK(full._pendingAsyncKeyMessages.size() == kMaxPendingAsyncKeys);
|
|
CHECK(full.unansweredRequestIds.size() == 1);
|
|
CHECK(full.unansweredRequestIds.front() == 9600);
|
|
|
|
return 0;
|
|
}
|
|
|
|
// Both session completion hooks decide whether the key the session was applying is finished,
|
|
// failed, or still in flight. A not-ready reply must be the third: neither completing it (it was
|
|
// not applied) nor failing it (that is the drop this whole change removes).
|
|
int checkSessionCompletionContract()
|
|
{
|
|
// KeyHandlerEditSession: a key that applied nothing because its reply has not arrived stays
|
|
// in flight for the hand-off the session itself performs.
|
|
{
|
|
CMetasequoiaIME service;
|
|
{
|
|
DeferredReplayCompletion completion{&service, /*token=*/77, false};
|
|
completion.applied = false;
|
|
completion.superseded = false;
|
|
completion.commitReplyNotReady = true;
|
|
}
|
|
CHECK(service.completedTokens.empty());
|
|
CHECK(service.failedTokens.empty());
|
|
}
|
|
|
|
// The same session hook still retires a real transfer failure with the classified reason.
|
|
{
|
|
CMetasequoiaIME service;
|
|
{
|
|
DeferredReplayCompletion completion{&service, /*token=*/78, false};
|
|
completion.applied = false;
|
|
completion.superseded = false;
|
|
completion.transportBroken = true;
|
|
}
|
|
CHECK(service.completedTokens.empty());
|
|
CHECK(service.failedTokens.size() == 1);
|
|
CHECK(service.failureReasons.front() == DeferredKeyFailureReason::TransportBroken);
|
|
}
|
|
|
|
// And still completes a key that was applied.
|
|
{
|
|
CMetasequoiaIME service;
|
|
{
|
|
DeferredReplayCompletion completion{&service, /*token=*/79, false};
|
|
completion.applied = true;
|
|
}
|
|
CHECK(service.completedTokens.size() == 1);
|
|
CHECK(service.failedTokens.empty());
|
|
}
|
|
|
|
// A session without a deferred key behind it has nothing to retire either way.
|
|
{
|
|
CMetasequoiaIME service;
|
|
{
|
|
DeferredReplayCompletion completion{&service, /*token=*/0, false};
|
|
completion.superseded = false;
|
|
}
|
|
CHECK(service.completedTokens.empty());
|
|
CHECK(service.failedTokens.empty());
|
|
}
|
|
|
|
// CPunctuationCommitEditSession: the same rule on the other committing session.
|
|
{
|
|
CMetasequoiaIME service;
|
|
{
|
|
Completion completion{&service, /*token=*/80, false};
|
|
completion.superseded = false;
|
|
completion.result = FANY_S_COMMIT_REPLY_NOT_READY;
|
|
}
|
|
CHECK(service.completedTokens.empty());
|
|
CHECK(service.failedTokens.empty());
|
|
}
|
|
|
|
// A punctuation commit that was applied completes, and one that really failed is dropped as
|
|
// ambiguous rather than being mistaken for a missing reply.
|
|
{
|
|
CMetasequoiaIME service;
|
|
{
|
|
Completion completion{&service, /*token=*/81, false};
|
|
completion.applied = true;
|
|
completion.result = S_OK;
|
|
}
|
|
CHECK(service.completedTokens.size() == 1);
|
|
CHECK(service.failedTokens.empty());
|
|
}
|
|
{
|
|
CMetasequoiaIME service;
|
|
{
|
|
Completion completion{&service, /*token=*/82, false};
|
|
completion.superseded = false;
|
|
completion.result = FANY_E_COMMIT_REPLY_AMBIGUOUS;
|
|
}
|
|
CHECK(service.completedTokens.empty());
|
|
CHECK(service.failedTokens.size() == 1);
|
|
CHECK(service.failureReasons.front() == DeferredKeyFailureReason::DeliveryAmbiguous);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
} // namespace
|
|
|
|
int main()
|
|
{
|
|
if (const int failed = checkReplyReadContract())
|
|
{
|
|
return failed;
|
|
}
|
|
if (const int failed = checkSessionWaitContract())
|
|
{
|
|
return failed;
|
|
}
|
|
if (const int failed = checkHandOffContract())
|
|
{
|
|
return failed;
|
|
}
|
|
if (const int failed = checkDispatchContract())
|
|
{
|
|
return failed;
|
|
}
|
|
if (const int failed = checkSessionCompletionContract())
|
|
{
|
|
return failed;
|
|
}
|
|
|
|
std::cout << "The session-side commit reply never resets the session and never loses the key\n";
|
|
return 0;
|
|
}
|