Skip to content

fix(WeaselIPC, WeaselServer): 客戶端 IPC 改為有界等待,托盤刷新移至專用執行緒(#1909 後續) - #1914

Open
Wujidadi wants to merge 5 commits into
rime:masterfrom
Wujidadi:fix/ipc-bounded-wait
Open

fix(WeaselIPC, WeaselServer): 客戶端 IPC 改為有界等待,托盤刷新移至專用執行緒(#1909 後續)#1914
Wujidadi wants to merge 5 commits into
rime:masterfrom
Wujidadi:fix/ipc-bounded-wait

Conversation

@Wujidadi

@Wujidadi Wujidadi commented Aug 22, 2026

Copy link
Copy Markdown

概要

#1909 的客戶端那一半,與已合併的 #1912 互補。

#1912 把托盤刷新移到伺服器訊息執行緒,斷開了「explorer 工作列執行緒等管線回應 ↔ WeaselServer 管線工作執行緒等 Shell_NotifyIcon」的死結環,解決了最常見的 7~8 秒凍結。
WeaselIPC 客戶端本身仍是無限等待:TSF 模組載入於每個宿主行程,焦點切換與按鍵的 IPC 往返都跑在宿主 UI 執行緒上,只要伺服器因任何原因停頓,宿主視窗就被一起拖死。
本 PR 讓跑在宿主執行緒上的客戶端 IPC 一律有上限,伺服器端呼叫路徑維持原行為。

現況問題(WeaselIPC/PipeChannel.cpp

  1. _Connect()while (_Invalid(pipe = _TryConnect())) ::WaitNamedPipe(name, 500); 無限重試——伺服器忙到掛不出管線執行個體時,宿主 UI 執行緒永遠等下去。
  2. _Receive()_WritePipe() 用同步 ReadFileWriteFile,無逾時;連上之後伺服器不回應即死等(WeaselIPC 客戶端在宿主程式 UI 執行緒上無限等待伺服器,切換視窗時造成卡頓或視窗凍結 7~8 秒 #1909weasel.dll 导致应用进程卡死,附详细堆栈信息 #1878 的傾印都停在這裡的 WaitForSingleObject(hPipe, INFINITE))。
  3. _WritePipe() 內的 FlushFileBuffers 在具名管線上會等到對端讀走才返回,伺服器忙碌時成為額外阻塞點;request/response 模式已有同步點,此呼叫無必要。

#1912 之後仍會觸發上述等待的情境:

  • 部署、SyncUserData、大型 userdb 寫入等使 g_api_mutex 長時間被持有。
  • RimeWithWeaselHandler::FocusIn_UpdateUI() 仍在管線工作執行緒上呼叫 m_ui->Hide()m_ui->Update();後者在 ctx/status 改變時會走 WeaselPanel::Refresh()SetWindowPos,而候選視窗屬伺服器訊息執行緒,跨執行緒 SetWindowPosShowWindow 是同步送訊息。若訊息執行緒此刻正在 Shell_NotifyIcon(內部 SendMessageTimeoutSMTO_BLOCK,期間不處理送入訊息),原本的環仍可短暫成立。
  • 第三方掛鉤(防毒)包覆管線 I/O 拉長處理時間。

修改內容

三個提交,可獨立審閱:

1. WeaselIPC 客戶端有界等待

全部收斂在 WeaselIPC,不動 TSF 端語義:

  • _Connect() 改為 fail-fast:一次連不上即丟 ERROR_PIPE_BUSY,由既有 _Ensure() 接住回傳失敗;移除 WaitNamedPipe 迴圈。
  • 管線以 FILE_FLAG_OVERLAPPED 開啟;新增 _WaitIo() 對讀寫套 WaitForSingleObject 逾時,逾時即 CancelIoEx 視同斷線。ERROR_MORE_DATA 兩段式讀取語義保留。
  • 移除 _WritePipe()FlushFileBuffers
  • _ConnectServerPipe() 配合改用 OVERLAPPED 的 ConnectNamedPipe(等待仍為 INFINITE),並修掉失敗路徑的控制代碼洩漏。
  • _WritePipe()_Receive()Transact() 新增 timeout_ms 參數,預設 INFINITE,伺服器端呼叫不受影響。
  • 客戶端逾時依指令性質分兩級(WeaselClientImpl.cpp_TimeoutForCommand()):
    • 組字/按鍵路徑(真實按鍵的 PROCESS_KEY_EVENTCOMMIT、選字、翻頁等):kClientIoTimeoutMs,確保按鍵不因短暫忙碌被放行。
    • 焦點/通知路徑(ECHOFOCUS_INFOCUS_OUTSTART_SESSIONEND_SESSIONSTART_MAINTENANCEEND_MAINTENANCEUPDATE_INPUT_POSTRAY_COMMAND,以及 OnSetThreadFocus 送出的 keycode 0 焦點刷新探測):kClientFocusTimeoutMs,漏掉一次下次事件自動補上。

失敗路徑:所有逾時/錯誤以 DWORD 例外丟出,由 ClientImpl::_SendMessage 既有的 catch (DWORD) 接住回 0,輸入法安靜降級(該次按鍵放行、下次自動重連),宿主程式不受影響。

2. _Send() 重連後改用新控制代碼

PipeChannel::_Send()catch 區塊在 _Reconnect() 後仍以按值傳入的舊控制代碼重送,該次重送注定失敗;改為取 _GetPipeHandle() 重送。

3. 托盤刷新改於專用執行緒(#1912 的後續)

#1912Shell_NotifyIcon 移到伺服器訊息執行緒,但訊息執行緒同時擁有候選視窗:管線工作執行緒在 FocusIn_UpdateUI() 仍會對候選視窗做跨執行緒 ShowWindowSetWindowPos,同步送訊息給訊息執行緒;而 Shell_NotifyIcon 內部以 SMTO_BLOCK 等工作列、期間不處理送入訊息。當工作列 UI 執行緒正在等管線回應時,環仍可能成立(只在焦點切換伴隨 UI 狀態改變時,窗口比原本窄)。

  • WeaselTrayIcon 改由專用執行緒套用刷新:RequestRefresh() 存快照並喚醒,RefreshThreadProc() 合併待處理請求後呼叫 Refresh()DisableRefresh()/解構子停止並 join。Shell_NotifyIcon 可自任一執行緒呼叫,回呼訊息仍送至 hWnd 所屬的訊息執行緒。
  • 移除因此無用的 WM_WEASEL_SERVICE_NOTIFYServerImpl::OnServiceNotifyMessageServer::SetTrayRefreshCallback
  • 移除 _RefreshTrayIcon()explorer.exe 延遲 100 ms 另開執行緒的「Dangerous, don't touch」繞道:回呼已不阻塞,對任何客戶端同步呼叫皆安全。

若維護者希望保留 #1912 的訊息執行緒結構,第 3 個提交可單獨拆出另議。

逾時值

目前提交的值為 kClientIoTimeoutMs = 500kClientFocusTimeoutMs = 25,是在 #1909 的環境(Windows 11 25H2、0.17.4)實測肉眼無感且輸入正常的值。
這兩個常數開放討論:若偏好保守,可改為焦點 100 ms/按鍵 1000~2000 ms,或改成可由 weasel.yaml 設定;我可依回饋調整。

測試

  • Windows 11 25H2(build 26200)、x64 自建版,連續使用 2026-08-09 至 2026-08-22:多開視窗、快速點擊工作列切換,凍結消失;VS Code、Windows Terminal、檔案總管輸入正常;候選視窗、切換中英、部署通知行為不變。
  • fix(WeaselServer): avoid tray refresh blocking IPC pipe #1912 同時套用(合入 d73f629 後重建)亦正常。
  • 未測:ARM64、Win32 宿主(32 位元程式載入 32 位元 weasel.dll)——程式碼路徑相同,但未在實機驗證。(我沒有這種實機😭)

相關

🤖 Generated with Claude Code

TSF 模組載入於每個文字輸入行程,焦點切換時會在宿主程式的 UI 執行緒上與 WeaselServer 同步往返。

`_Connect()` 的無上限重試迴圈與 `_Receive()`/`_WritePipe()` 的無逾時阻塞讀寫,
會使伺服器一忙碌,便將整個視窗凍結數秒,表現為視窗變灰、無回應,且跨 VS Code、檔案總管、Windows Terminal 一致,Alt+Tab 不受影響。

改動均收斂於 **WeaselIPC**,伺服器端呼叫以 `timeout_ms` 預設 `INFINITE` 維持原行為:
- `_Connect()` 改為 fail-fast,移除 WaitNamedPipe 無限迴圈,一次連不上即拋出 `ERROR_PIPE_BUSY`,由既有 `_Ensure()` 接住。
- 管線改用 `FILE_FLAG_OVERLAPPED`,新增 `_WaitIo()` 對讀寫套用 `WaitForSingleObject` 逾時,逾時即 `CancelIoEx` 視同斷線,保留 `ERROR_MORE_DATA` 兩段式讀取語義。
- 移除 `_WritePipe()` 內的 `FlushFileBuffers`。
- `_ConnectServerPipe()` 配合改為 `OVERLAPPED`,並修掉失敗時的控制代碼洩漏。

客戶端逾時依指令性質(`_TimeoutForCommand`)分兩級:
- 組字/按鍵路徑維持 `kClientIoTimeoutMs=500` 以確保按鍵不被誤放。
- 焦點/通知路徑(含 `OnSetThreadFocus` 的 keycode 0 焦點刷新探測)用 `kClientFocusTimeoutMs=25`,漏掉一次即自癒,讓視窗切換肉眼無感。

失敗經既有 `catch(DWORD)` 讓輸入法安靜降級(該次按鍵放行、下次自動重連),宿主程式不受影響。
`_Reconnect()` 會關閉原本的管線控制代碼並重新連線,但 `catch` 區塊的重送仍寫入按值傳入的舊控制代碼,該次重送必定失敗。
改為自 `_GetPipeHandle()` 取重連後的控制代碼重送。
rime#1912 把 `Shell_NotifyIcon` 從管線工作執行緒移到伺服器訊息執行緒,但訊息執行緒同時擁有候選視窗。
管線工作執行緒在 `FocusIn` → `_UpdateUI()` 仍會對候選視窗做跨執行緒的 ShowWindow/SetWindowPos,這些呼叫會同步送訊息給訊息執行緒;
而 `Shell_NotifyIcon` 內部以 `SMTO_BLOCK` 等待工作列,期間不處理送入的訊息。
當工作列 UI 執行緒本身正在等管線回應時,工作執行緒等待訊息執行緒、訊息執行緒等待工作列、工作列等待工作執行緒的環仍可能成立。

- WeaselTrayIcon 改由專用執行緒套用刷新:`RequestRefresh()` 只存快照並喚醒,`RefreshThreadProc()` 合併待處理請求後呼叫 `Refresh()`,`DisableRefresh()` 停止並 join。
- 移除 rime#1912 引入、現已無用的 `WM_WEASEL_SERVICE_NOTIFY`、`ServerImpl::OnServiceNotifyMessage` 與 `Server::SetTrayRefreshCallback`。
- 移除 `_RefreshTrayIcon()` 對 explorer.exe 延遲 100ms 另開執行緒的繞道:回呼已不再阻塞,對任何客戶端同步呼叫皆安全。
Copilot AI lite review requested due to automatic review settings August 22, 2026 12:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR follows up on #1909/#1912 by (1) making WeaselIPC client-side pipe operations bounded (so host-app UI threads don’t block indefinitely when WeaselServer is busy) and (2) moving tray refresh execution off the server message thread to further reduce deadlock risk involving Explorer/taskbar interactions.

Changes:

  • Add client-side IPC timeouts via overlapped pipe I/O and per-command timeout selection (focus vs keystroke paths).
  • Fix resend-after-reconnect to use the new pipe handle instead of the stale one.
  • Move tray icon refresh to a dedicated thread and remove the prior server-message-thread refresh callback/WM message path and the explorer-specific delayed workaround.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
WeaselServer/WeaselTrayIcon.h Introduces dedicated tray refresh thread plumbing (thread member, API changes).
WeaselServer/WeaselTrayIcon.cpp Implements coalesced refresh processing on a dedicated thread and shutdown/join behavior.
WeaselServer/WeaselServerApp.cpp Removes server refresh callback wiring; uses direct RequestRefresh() + DisableRefresh().
WeaselIPCServer/WeaselServerImpl.h Removes WM_WEASEL_SERVICE_NOTIFY handler and tray refresh callback plumbing.
WeaselIPCServer/WeaselServerImpl.cpp Removes tray refresh message handler and Server::SetTrayRefreshCallback implementation.
WeaselIPC/WeaselClientImpl.cpp Adds _TimeoutForCommand() and applies bounded timeouts to client Transact.
WeaselIPC/PipeChannel.cpp Implements overlapped connect/read/write + bounded wait/cancel behavior and server-side overlapped ConnectNamedPipe.
RimeWithWeasel/RimeWithWeasel.cpp Removes the explorer-specific delayed refresh workaround (now safe to call synchronously).
include/WeaselIPC.h Removes WM_WEASEL_SERVICE_NOTIFY and Server::SetTrayRefreshCallback from the public interface.
include/PipeChannel.h Adds timeout constants and timeout-capable Transact/_Send/_ReceiveResponse API surface.
Suppressed comments (2)

WeaselIPC/PipeChannel.cpp:101

  • _WaitIo can block indefinitely after a timeout/WAIT_FAILED because it unconditionally calls WaitForSingleObject(INFINITE) after CancelIoEx without checking whether CancelIoEx actually succeeded (or why it failed). If CancelIoEx fails unexpectedly, the intended bounded-wait behavior can be lost. Handle CancelIoEx failure explicitly and preserve the original wait error when throwing.
  if (wait != WAIT_OBJECT_0) {
    ::CancelIoEx(pipe, &ov);
    ::WaitForSingleObject(ov.hEvent, INFINITE);
    _ThrowCode(wait == WAIT_TIMEOUT ? static_cast<DWORD>(ERROR_TIMEOUT)
                                    : ::GetLastError());

WeaselIPC/PipeChannel.cpp:177

  • In _ConnectServerPipe, the overlapped ConnectNamedPipe wait ignores the return value of WaitForSingleObject. If the wait fails (e.g., invalid event handle), the code proceeds to GetOverlappedResult and can report a confusing error. Check the wait result and fail/close the pipe handle immediately on WAIT_FAILED.
  if (!ok && err == ERROR_IO_PENDING) {
    ::WaitForSingleObject(op.ov.hEvent, INFINITE);
    DWORD n = 0;

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread WeaselIPC/PipeChannel.cpp
Comment on lines +19 to +26
struct OverlappedOp {
OVERLAPPED ov;
OverlappedOp() : ov() { ov.hEvent = ::CreateEvent(NULL, TRUE, FALSE, NULL); }
~OverlappedOp() {
if (ov.hEvent)
::CloseHandle(ov.hEvent);
}
};
state = m_pending_state;
m_refresh_pending = false;
m_refresh_in_progress = true;
Refresh(state);
- `OverlappedOp` 於 `CreateEvent` 失敗時立即拋出,避免後續以空事件等待。
- `_WaitIo()` 在 `CancelIoEx` 與第二次等待之前先保存逾時/失敗的原始錯誤碼,不再被後續呼叫覆寫;
  第二次等待仍為必要:`OVERLAPPED` 位於呼叫端堆疊,I/O 未完成前不得返回,`CancelIoEx` 唯一的失敗情境(`ERROR_NOT_FOUND`)代表 I/O 已完成、事件已觸發。
- `_ConnectServerPipe()` 檢查 `WaitForSingleObject` 結果,失敗即取消 I/O、關閉管線並拋出。
- `_Receive()` 失敗(含逾時)時關閉客戶端的管線連線:被放棄的回應否則會留在管線中,成為下一筆請求讀到的回應;下一次交易自動重連,伺服器端工作執行緒於讀寫失敗時自行收尾。
`Refresh()` 在托盤執行緒修改 `CSystemTray` 狀態(`m_tnd`、`m_bHidden` 等),而訊息執行緒仍會在工作列重建(`InstallIconPending()`)與托盤通知(`OnTrayNotification()`)時觸及同一組狀態。
三個進入點一律持 `m_tray_mutex`;採 recursive mutex,因托盤選單的模態迴圈可能在持鎖期間派送工作列重建訊息。
`CSystemTray::InstallIconPending()` 改為 virtual 以便覆寫。
@Wujidadi

Copy link
Copy Markdown
Author

已依 Copilot 的審查補了兩個提交(8d429d9、1a853c7):

WeaselIPC/PipeChannel.cpp

  • OverlappedOpCreateEvent 失敗即拋出。
  • _WaitIo():在 CancelIoEx 之前先保存逾時/失敗的原始錯誤碼,不再被第二次等待覆寫。第二次 WaitForSingleObject(INFINITE) 保留:OVERLAPPED 位於呼叫端堆疊,I/O 未完成前不能返回;CancelIoEx 唯一會失敗的情境是 I/O 已完成(ERROR_NOT_FOUND),此時事件已觸發、等待立即返回,不會無限阻塞。
  • _ConnectServerPipe():檢查 WaitForSingleObject 結果,失敗即取消 I/O、關閉管線並拋出。
  • 另補一個審查未提、但我自查發現的問題:_Receive() 逾時後原本未關閉連線,被放棄的回應會留在管線裡成為下一筆請求的回應。現在 _Receive() 失敗即關閉客戶端連線,下一次交易自動重連;伺服器端 _ProcessPipeThread 本就在讀寫失敗時 _FinalizePipe

WeaselServer/WeaselTrayIcon.*

  • Refresh()(托盤執行緒)、InstallIconPending()(工作列重建)、OnTrayNotification()(托盤通知)三個進入點一律持同一把 recursive mutex;CSystemTray::InstallIconPending() 改為 virtual 以便覆寫。
    OnTrayNotification() 實際只讀 Create() 之後不再變動的欄位,加鎖主要是讓狀態存取規則單純。

Wujidadi added a commit to Wujidadi/weasel that referenced this pull request Aug 22, 2026
- `OverlappedOp` 於 `CreateEvent` 失敗時立即拋出,避免後續以空事件等待。
- `_WaitIo()` 在 `CancelIoEx` 與第二次等待之前先保存逾時/失敗的原始錯誤碼,不再被後續呼叫覆寫;
  第二次等待仍為必要:`OVERLAPPED` 位於呼叫端堆疊,I/O 未完成前不得返回,`CancelIoEx` 唯一的失敗情境(`ERROR_NOT_FOUND`)代表 I/O 已完成、事件已觸發。
- `_ConnectServerPipe()` 檢查 `WaitForSingleObject` 結果,失敗即取消 I/O、關閉管線並拋出。
- `_Receive()` 失敗(含逾時)時關閉客戶端的管線連線:被放棄的回應否則會留在管線中,成為下一筆請求讀到的回應;下一次交易自動重連,伺服器端工作執行緒於讀寫失敗時自行收尾。

(cherry picked from commit 8d429d9)
Wujidadi added a commit to Wujidadi/weasel that referenced this pull request Aug 22, 2026
- build.bat 的 fork 版號後綴進位 -wujidadi.4
- FORK-CHANGELOG 新增 0.17.4-wujidadi.4 節,記錄自 rime#1914 分支揀回的四筆修正
- appcast 與 testing-appcast 條目更新至 0.17.4-wujidadi.4
fcxxxz added a commit to fcxxxz/weasel that referenced this pull request Sep 1, 2026
按用户指令恢复 GPU 优先/WARP 兜底(WEASEL_WARP=1 可选软渲染):
CPU 满载时渲染不与系统抢 CPU,全屏布局+阴影在 4K 下必须 GPU
(对抗审查 rime#7 实锤该场景软渲染为每键整屏 CPU 光栅化)。

服务器端已接受管道改阻塞读写:worker 线程可弃,无需每请求付
overlapped 事件跳转;客户端完整保留 overlapped+分级超时,rime#1914
防冻语义不变。审查确认无死锁/帧错位回归。

对抗性审查(18 项)落地四项修复:
- Present 改回 (0,0):每帧等 vblank 最高 8ms 打字卡顿,TSF 进程内
  模式下卡的是宿主应用 UI 线程(rime#1)
- _ReceiveSync 删短读分支:防旧版客户端消息被当正文吞掉造成帧
  错位(rime#10)
- Initialize 先销毁旧保活会话:防部署后双初始化泄漏(rime#14)
- Prewarm 应用待定尺寸 + 隐藏期钳位用待定尺寸(rime#2/rime#3)

GPU 模式验证:p50 40µs、会话 3ms、切换 1ms、句柄 30k 键稳定;
单测/parser 门全绿;墨菲压测(90k 键+并发+强杀客户端)全过。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants