镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

fix(windows-ime): keep the TSF text service always active instead of switching IMEs - #1143

Open
DepengWang wants to merge 1 commit into
Open-Less:betafrom
DepengWang:fix/windows-tsf-always-active
Open

DepengWang wants to merge 1 commit into
Open-Less:betafrom
DepengWang:fix/windows-tsf-always-active

Conversation

@DepengWang

@DepengWang DepengWang commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Refs #954, #665, #1033, #1032.

In TSF insertion mode, every dictation switched the session's input profile to the OpenLess keyboard TIP and back. Switching back makes TSF re-activate the user's own IME on every TSF thread of every process. With Weasel (Rime) that re-activation does blocking named-pipe IPC and can hang the host forever. That is the explorer/taskbar AppHang reported in #665 and #954.

This PR registers the OpenLess text service under the TSF speech category instead of the keyboard category. TSF keeps a speech TIP active next to the keyboard IME, so dictation no longer switches anything: it just submits text to the text service that is already active in the target app.

Root cause (dump-verified)

Windows 11, Weasel 0.17.4, windowsInsertionMode=tsf. explorer.exe hung 6 times in two days, starting the day TSF mode was first used; no explorer hangs in the three weeks before.

  • Minidump of the hung explorer: the desktop thread is blocked in
    explorer message loop -> msctf -> weasel.dll -> KERNELBASE!FlushFileBuffers -> NtFlushBuffersFile.
    A second dump four minutes later shows the same thread at the same stack pointer.
  • WeaselServer at that moment: every per-client worker idle in ReadFile, one in ConnectNamedPipe. The server is not stuck; client and server are out of sync on that pipe.
  • Quitting WeaselServer released the explorer thread within the same second.
  • The stuck thread had already returned from OpenLess's Deactivate(); the next thing TSF does on that thread is re-activate Weasel.
  • The WER hang signature was the same with the released DLL and with a rebuilt DLL that had no worker thread, which rules out the DLL's own code. One earlier hang on this machine has the same signature as the one quoted in [windows][ime] OpenLessIme.dll causes intermittent explorer.exe/taskbar AppHang until OpenLess Voice Input TSF is disabled #665.

The blocking FlushFileBuffers is Weasel's bug, but OpenLess is what triggers it on every dictation, and any IME with a slow or fragile activation path is exposed the same way.

Changes

Always active instead of switching

  • DllRegisterServer registers GUID_TFCAT_TIP_SPEECH for all languages (0xFFFF, like the system SpTip.dll) and removes the keyboard category and zh-CN profile from earlier releases, so upgrading migrates in place.
  • Removes the per-dictation capture / activate / restore of the input profile: windows_ime_restore.rs, most of windows_ime_profile.rs, and the prepared-session plumbing in core_adapters.rs. A dictation is now a single submit.
  • A speech profile is only activated while enabled, so the "show OpenLess in keyboard list" preference no longer disables the profile.
  • Registration checks, the install smoke script and the settings description (8 locales) follow the new registration.

No thread or pipe inside host processes

The DLL is now active in every TSF-enabled process all the time, so it should be as passive as possible there.

  • The DLL no longer runs a worker thread and a named pipe per TSF thread, and Deactivate() no longer joins a thread on the host UI thread (unbounded if it runs under the loader lock).
  • OpenLess sends WM_COPYDATA (token + UTF-16 text) to the message-only window the DLL already had. The DLL acknowledges, posts to itself and commits from the top of the host message loop as before; the result is polled with the same token, which also covers async edit sessions (Word).
  • UIPI is left intact: the window does not accept WM_COPYDATA from lower-integrity senders.
  • Outcome semantics are unchanged: anything after dispatch is OutcomeUnknown and never re-inserts. A mismatched app/DLL pair fails before dispatch and uses the non-TSF fallback.
  • The lifecycle contract test now asserts that the DLL has no threads, pipes or blocking waits, and that the protocol constants match between C++ and Rust.

Compatibility

  • Keyboard list: OpenLess no longer appears in the Windows keyboard/IME list. The windowsShowOpenlessInKeyboardList preference is now inert on Windows. I left the setting and its UI in place; removing or repurposing it is a product decision.
  • Always loaded: one message-only window per TSF thread in each host, no threads.
  • Upgrade: requires the DLL to be re-registered, which the NSIS/MSI installers already do. Until then the app reports the registration as broken and uses the fallback path.
  • Not included: removing the now-inert setting, any change to paste / SendInput modes, macOS / Linux / Android behaviour.

Testing

On Windows 11 with Weasel as the keyboard IME:

  • After registering, TSF activated the text service by itself in explorer, Word, Windows Terminal, WeChat, WeCom, Chrome/WebView2 hosts and others (18 threads), with weasel.dll still loaded in each.
  • Real dictations committed through TSF into Word (async edit path), WeChat and WeCom, 10-15 ms per submit, no input-profile switching, IME state unchanged before and after.
  • No explorer hang since the change. Small sample so far (a handful of dictations); before the change roughly 6 hangs in 45 TSF sessions.
  • x64 and Win32 DLL build with 0 warnings.
  • cargo check --locked on stable and on 1.88.0, rustfmt --check on the touched files, prettier on the touched files, tsc && vite build.
  • scripts/*.test.mjs: 53 of 56 pass. The 3 failures (android-apk-workflow-contract, ci-cache-usage, ci-changed-areas) fail identically on an untouched upstream/beta checkout on this machine.
  • Unit tests of windows_ime_ipc.rs / windows_ime_protocol.rs and an end-to-end round trip against the TSF-activated DLL, run from a small standalone harness.

Not tested:

  • cargo test --lib for the app itself: on this machine the test binary exits at load with 0xC0000139, before any test runs and regardless of this change. The tests in windows_ime_session.rs / windows_ime_profile.rs have therefore only been compiled here.
  • Elevated host windows, 32-bit hosts, UWP / immersive hosts (Start, Settings).
  • Other third-party IMEs (the reporter of [windows][ime] 插入后输入法状态偶发没还原:要按两下才从英文回到中文,无法立刻改错字 #1033 uses Palm Input), Microsoft Pinyin, Japanese IMEs.
  • A fresh install and an in-place upgrade through the NSIS / MSI installers; registration was done with regsvr32 on the built DLLs.

🤖 Generated with Claude Code

In TSF mode every dictation switched the session's input profile to the
OpenLess keyboard TIP and back. Switching back makes TSF re-activate the
user's own IME on every TSF thread of every process. With Weasel (Rime)
that re-activation does blocking named-pipe IPC ending in
FlushFileBuffers, which sometimes never returns and freezes the host.

Observed on Windows 11 with Weasel 0.17.4: explorer.exe AppHang shortly
after a dictation, with the desktop thread stuck in
  explorer message loop -> msctf -> weasel.dll -> FlushFileBuffers
while every WeaselServer worker sat idle in ReadFile. Stopping
WeaselServer released the thread at once. This matches Open-Less#665 and Open-Less#954
(both Weasel users) and is also the source of the restore glitches in
Open-Less#1033 / Open-Less#1032.

Register the text service under GUID_TFCAT_TIP_SPEECH for all languages
(0xFFFF), like the system speech text service. TSF keeps such a TIP
active next to the keyboard IME, so nothing has to be switched:

- DllRegisterServer registers the speech category and removes the
  keyboard category and zh-CN profile left by earlier releases, so an
  upgrade migrates in place.
- The per-dictation capture / activate / restore of the input profile is
  removed (windows_ime_restore.rs, most of windows_ime_profile.rs, the
  prepared-session plumbing in core_adapters.rs). A dictation is now a
  single submit to the IME window in the target app.
- A speech profile is only activated while enabled, so the "show in
  keyboard list" preference no longer disables it. The text service no
  longer appears in the keyboard list at all; the preference is now inert
  on Windows and left in place for a follow-up decision.
- Registration checks, the install smoke script and the settings blurb
  follow the new registration.

Because the DLL is now active in every TSF-enabled process all the time,
it also stops running a worker thread and a named pipe per TSF thread:

- OpenLess sends WM_COPYDATA (token + UTF-16 text) to the message-only
  window the DLL already owned on the TSF thread. The DLL acknowledges,
  posts a message to itself and commits from the top of the host message
  loop, as before; the result is polled with the same token, which also
  covers hosts that only grant an async edit session (Word).
- Activate()/Deactivate() no longer start, signal or join anything.
  Deactivate() used to join the worker on the host UI thread, which is
  unbounded if it runs with the loader lock held.
- The window does not opt in to WM_COPYDATA from lower-integrity senders,
  so a non-elevated process still cannot inject text into an elevated
  host.
- Timeout semantics are unchanged: anything after the submit was
  delivered stays OutcomeUnknown and never triggers a second insertion.

The lifecycle contract test now asserts that the DLL contains no threads,
pipes or blocking waits and that the protocol constants match between
C++ and Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant