Skip to content

Improve backend stability and config tooling#880

Open
omni624562 wants to merge 1 commit into
EasyIME:masterfrom
omni624562:codex/pime-stability-fixes
Open

Improve backend stability and config tooling#880
omni624562 wants to merge 1 commit into
EasyIME:masterfrom
omni624562:codex/pime-stability-fixes

Conversation

@omni624562

Copy link
Copy Markdown

Summary / 摘要

This PR extracts stability and performance-oriented fixes from WIME back to PIME, without WIME branding, mockups, installer-only changes, or the larger candidate window UI redesign.

此 PR 將 WIME 中較適合回饋給 PIME 的穩定性與效能修正獨立整理出來,不包含 WIME 品牌改名、mockup、限定 installer,或較大型的候選窗 UI 重構。

Changes / 變更內容

  • Improve PIMELauncher backend startup responsiveness by avoiding holding the async state lock while spawning a backend.
    改善 PIMELauncher 啟動 backend 時的反應速度,避免在 spawn backend 時持有 async state lock,降低其他 client 被阻塞的機會。

  • Run Python backends unbuffered and flush protocol replies to reduce typing latency.
    讓 Python backend 以 unbuffered 模式執行,並在送出 protocol 回應時 flush,降低打字延遲。

  • Avoid protocol parsing allocations in backend output handling.
    優化 backend output 的 protocol parsing,避免不必要的配置。

  • Make C++ RPC handling more tolerant of missing JSON fields and absent showCandidates.
    讓 C++ RPC 處理對缺少 JSON 欄位或沒有 showCandidates 的回應更具容錯性。

  • Reduce pipe reconnect wait time and increase pipe read buffer size.
    縮短 pipe 重新連線等待時間,並增加 pipe read buffer 大小。

  • Recreate the overlapped I/O event after pipe resets.
    pipe reset 後會重新建立 overlapped I/O event,避免一次斷線後後續 RPC 難以恢復。

  • Keep the Python backend alive after one malformed or failing request, with regression tests.
    Python backend 遇到單次 malformed 或失敗 request 後會繼續運作,並加入 regression tests。

  • Harden cinbase config loading/saving so shipped defaults are layered under user settings and internal state is not persisted.
    強化 cinbase 設定讀寫,先載入內建預設值再覆蓋使用者設定,並避免內部狀態被寫入設定檔。

  • Reset cinbase loading flags even when loading threads fail.
    即使 cinbase 載入 thread 發生錯誤,也會正確重設 loading flag,避免輸入法永久停在載入狀態。

  • Fix New Chewing config tab switching and static file cache refresh behavior.
    修正新酷音設定頁 tab 切換,以及靜態檔快取造成設定頁更新不即時的問題。

  • Allow config tools to save intentionally empty symbol/keyboard data.
    允許設定工具儲存刻意清空的符號或鍵盤資料。

Validation / 驗證

  • python -m py_compile python\server.py python\cinbase\config.py python\cinbase\__init__.py python\cinbase\configtool.py python\input_methods\chewing\config_tool.py
  • python -m unittest tests.test_backend_resilience
  • rustfmt --edition 2021 --check src\main.rs src\backend_manager.rs src\protocol.rs
  • cargo check in PIMELauncher
  • x86 and x64 PIMETextService Release builds with Visual Studio 2022/CMake
    使用 Visual Studio 2022/CMake 成功建置 x86 與 x64 PIMETextService Release 版本。

@PCMan

PCMan commented Jul 14, 2026

Copy link
Copy Markdown
Member

?? Gemini-Generated Code Review (Followed by my review)

This code review was generated by Gemini, followed by my review.


Pull Request #880 Review Report: EasyIME/PIME

This report provides a comprehensive code review of PR #880.


Executive Summary

PR #880 introduces major reliability, resilience, performance, and caching improvements across the codebase (Rust launcher, C++ client/service, Python server, and Web UI configurations).

High-Impact Enhancements:

  1. Concurreny & Performance (Rust Launcher): Spawns backend processes asynchronously outside the main registry lock, preventing several-second UI freezes for other active input method users.
  2. Backend Resiliency (Python Server): Rather than calling sys.exit(1) and crashing the server on client errors, the Python server now recovers gracefully. Destabilizing or restarting Python backend processes during an active Text Service Framework (TSF) session often crashes or hangs the host Windows application.
  3. Web Caching & Compatibility (Web UI): Adds no-caching HTTP headers for the Tornado-based configuration tools, versioned cache-busting redirects, atomic config file writes to prevent corruption on crash, and tab fallbacks for legacy rendering engines.
  4. C++ Client Robustness: Prevents crashes in PIMEClient.cpp when accessing JSON settings by using type checks (is_boolean()) and default value fallbacks (value()).

??? Critical Flaw Found: Infinite Orphaned Process Spawning Loop

There is a severe concurrency bug introduced/worsened in PIMELauncher/src/backend_manager.rs that can cause PIME to spawn processes infinitely and consume 100% CPU.

Technical Analysis:

  1. In get_backend_input(backend_name), the PR changes process spawning to happen outside the lock (self.state.lock().await):
    let backend = self.spawn_backend_process(&config).await;
    let mut state = self.state.lock().await;
    state.backends.entry(backend_name.to_string()).or_insert(backend);
  2. If two client threads concurrently ask for the same backend, both will bypass the fast path (as the backend is not yet in state.backends) and both will spawn a new backend process concurrently.
  3. Upon locking, the first thread to acquire the lock inserts its BackendProcess (which contains stdin_tx). The second thread's BackendProcess is discarded because of .or_insert(backend).
  4. When the second thread's BackendProcess is discarded, its stdin_tx sender is dropped.
  5. In the background task spawned by spawn_backend_process for this second instance, the stdin_rx receiver is now permanently closed (since all matching senders were dropped).
  6. Inside forward_inputs_to_backend, stdin_rx.recv() immediately yields None without blocking:
    msg = stdin_rx.recv() => {
        let Some(data) = msg else { 
            info!("Backend {} stdin channel closed. Exiting input loop.", backend_name);
            break; // breaks from input loop
        };
    }
  7. Once it breaks, the spawning loop executes:
    warn!("Restarting backend {}", backend_name_clone);
    tokio::time::sleep(Duration::from_secs(1)).await;
  8. Because stdin_rx remains closed/empty, the next loop iteration immediately spawns another process, which breaks instantly, sleeps for 1 second, and restarts. This creates an infinite loop of spawning and killing orphaned OS processes in the background, consuming CPU resources indefinitely.

Caution

This infinite loop also triggers whenever a dead backend is removed from state.backends during a write failure in send_to_backend(), since dropping the old BackendProcess drops the only stdin_tx, permanently closing the channel for that background spawn loop.

Proposed Fix:

Ensure that forward_inputs_to_backend returns whether the channel was closed, and if it was, terminate the tokio::spawn loop instead of restarting. (See Recommendations section below for the code change).


Detailed File-by-File Analysis

1. PIMELauncher (Rust Launcher)

  • src/backend_manager.rs:
    • Fast Path / Slow Path Lock Optimization: Spawns processes outside the state lock. Good optimization, but introduces the concurrency process-leak bug detailed above.
    • PYTHONUNBUFFERED = "1": Set on spawned Python processes. This prevents Python's standard output buffering, resolving any delayed response bugs.
    • Watchdog tick logging: Downgraded to debug! level, preventing logs from being flooded.
  • src/main.rs:
    • Sets the max logging level to tracing::Level::WARN when running in the GUI subsystem, keeping the system cleaner.
  • src/protocol.rs:
    • Memory Optimization: parse_backend_output was rewritten to parse messages using strip_prefix and slice instead of splitn(3, '|') which allocated vectors. This reduces memory pressure in input loop message processing.

2. PIMETextService (C++ Service Client)

  • PIMEClient.cpp:
    • Safe JSON Access: Changed direct accesses like ret["return"].get<bool>() to ret.value("return", false). This prevents runtime exceptions/crashes in the C++ IME DLL if the python backend returns unexpected JSON data.
    • Faster Timeout & Connection Recovery: Decreased named pipe connection retry timeout from 30 seconds to 3 seconds, and attempts from 5 to 3. This speeds up recovery and prevents application freezes if the launcher hangs.
    • Buffer Expansion: Enlarged read buffer size char buf[1024] to 8192 to handle larger input method payloads.

3. python/cinbase (Python Input Method Engine Base)

  • __init__.py:
    • Hangs Avoided in Loading Threads: Wrapped the Cin loading operations inside try...finally blocks. This guarantees that loading = False is set even if a parsing or I/O exception occurs, preventing users from getting stuck in an infinite "Loading IME tables..." state.
  • config.py:
    • Layered Config Defaults: Loads the default shipping configuration first before overlaying the user's custom settings. If new config keys are added in an update, legacy user configs will now inherit them instead of encountering missing attribute errors.
  • configtool.py:
    • Atomic Write: Saves configurations by writing to .tmp first, then calling os.replace. This guarantees that if the server crashes mid-write, the configuration file is not corrupted.
    • HTTP Cache Prevention: Implemented NoCacheStaticFileHandler to force browser cache invalidation.
    • Tornado JSON responses: Removed multiple calls to self.write() within the same request (which generated malformed JSON like {"return":true}{"return":true}).

4. python/input_methods/chewing (Chewing IME Web Configuration)

  • js/config.js:
    • Old Browser/IE Tab Fallback: Added a manual click handler fallback for .nav-tabs links. Essential for Windows when the config page is rendered inside an older embedded WebBrowser (IE) control, where native Bootstrap JS/events might fail.

Recommendations & Code Fixes

Fix for the Infinite Restart Loop in backend_manager.rs

Modify forward_inputs_to_backend to return a bool indicating if the channel was closed, and use it to break the restart loop.

1. In forward_inputs_to_backend (Line ~277 in backend_manager.rs):

    async fn forward_inputs_to_backend(
        stdin_rx: &mut mpsc::Receiver<String>,
        stdin: tokio::process::ChildStdin,
        child_process: &mut tokio::process::Child,
        backend_name: &str,
        last_output_time: Arc<AtomicU64>,
    ) -> bool { // Returns true if the channel was closed
        let mut stdin_writer = FramedWrite::new(stdin, LinesCodec::new_with_max_length(1048576));
        let mut last_request_time: Option<u64> = None;
        let mut watchdog_interval = tokio::time::interval(Duration::from_secs(1));

        loop {
            tokio::select! {
                msg = stdin_rx.recv() => {
                    let Some(data) = msg else { 
                        info!("Backend {} stdin channel closed. Exiting input loop.", backend_name);
                        return true; // Channel closed
                    };
                    let now = Self::current_ms();
                    last_request_time = Some(now);
                    
                    let write_res = tokio::time::timeout(Duration::from_secs(5), stdin_writer.send(data)).await;
                    if let Err(_) = write_res {
                        error!("Timeout writing to backend {}. Forcing restart.", backend_name);
                        let _ = child_process.kill().await;
                        return false;
                    }
                    if let Err(e) = write_res.unwrap() {
                        error!("Failed to write to backend {}: {}", backend_name, e);
                        let _ = child_process.kill().await;
                        return false;
                    }
                }
                _ = watchdog_interval.tick() => {
                    let now = Self::current_ms();
                    if let Some(req_t) = last_request_time {
                        let last_out = last_output_time.load(Ordering::SeqCst);
                        if last_out < req_t && (now - req_t) > 15000 {
                            error!("Backend {} seems to be hung (no output for 15s after request). Forcing restart.", backend_name);
                            let _ = child_process.kill().await;
                            return false;
                        }
                    }
                }
                status = child_process.wait() => {
                    warn!("Backend {} exited with status {:?}", backend_name, status);
                    return false; // Process exited, but channel remains open
                }
            }
        }
    }

2. In spawn_backend_process (Line ~154 in backend_manager.rs):

                let channel_closed = Self::forward_inputs_to_backend(
                    &mut stdin_rx,       // Inputs received from client connections.
                    stdin,               // Backend stdin.
                    &mut child_process,  // Backend process.
                    &backend_name_clone, // Backend name.
                    last_output_time,    // Output tracker.
                )
                .await;

                stdout_task.abort();
                stderr_task.abort();

                if channel_closed {
                    info!("Backend {} input channel closed permanently. Stopping manager loop.", backend_name_clone);
                    break; // Exit the loop entirely to prevent infinite restarts!
                }

                warn!("Restarting backend {}", backend_name_clone);
                tokio::time::sleep(Duration::from_secs(1)).await;

Conclusion

This PR is highly recommended for merge as it resolves long-standing UI freezes and increases configuration stability. However, the Infinite Spawning Loop bug must be fixed before merging, as it represents a severe system resource exhaustion risk.

@PCMan

PCMan commented Jul 14, 2026

Copy link
Copy Markdown
Member

Hi @omni624562 非常感謝貢獻,這些修改看起來大多不錯,但我還是使用 AI 輔助 code review 因為我個人不熟悉 rust,看起來 backend spawn process 確實有可能有問題,這部份能再修改一下嗎? 或是把這部份拆到別的 PR 我們先 merge 其餘部份。謝謝

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