From bcd3ef20ff055e62e63fa9e4046b9c421af6c401 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:53:54 +0200 Subject: [PATCH] fix(cursor): system cursor swaps off the tick thread; pulse thread HIGHEST (#363) The zoom-out restore (SPI_SETCURSORS scheme reload, 8 ms median, up to 90 ms under load) and the blank (14 SetSystemCursor) ran on the tick thread and froze the 1x landing and zoom-in frames. They now run in order on a worker; the repaint nudge follows the restore there. restoreSync() for the input-panel clip nudge and shutdown. The txPace=2 composite pulse thread runs at HIGHEST so a build no longer starves it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KPUNAWcwghXdHCApcKKjSG --- docs/architecture/07-cursor.md | 6 ++- src/cursor_blanker.cpp | 71 ++++++++++++++++++++++++++++++---- src/cursor_blanker.h | 29 +++++++++++++- src/main.cpp | 3 ++ src/transform_model.cpp | 17 ++++---- src/version.h | 4 +- 6 files changed, 112 insertions(+), 18 deletions(-) diff --git a/docs/architecture/07-cursor.md b/docs/architecture/07-cursor.md index eec0d75..dda7416 100644 --- a/docs/architecture/07-cursor.md +++ b/docs/architecture/07-cursor.md @@ -90,7 +90,11 @@ is hidden and a stand-in drawn. keeps the originals. It first reloads the user's scheme, so a previously killed Wind's blanks are never captured as originals. `MagShowSystemCursor(FALSE)` covers app-custom cursors. The blank runs before the magnification context exists, because each swap under a live context costs a - re-composite. + re-composite. The swaps run in order on the `Wind cursor swaps` worker thread (#363): the restore + is a full `SPI_SETCURSORS` scheme reload (8 ms median, up to 90 ms under load) and the blank 14 + `SetSystemCursor` calls, which froze the zoom-in and the 1x landing frame on the tick thread. + The zoom-out repaint nudge rides on the worker after the restore. `restoreSync()` waits (the + input-panel clip nudge, shutdown); the crash filter restores directly. - **`CursorSprite`** (`src/cursor_sprite.*`) is a small layered window painting the current shape, or the Inspect crosshair. It sits at the lens point in desktop coordinates, so DWM shows it at the view's centre, magnified. `keepOnTop()` re-asserts topmost only when displaced. diff --git a/src/cursor_blanker.cpp b/src/cursor_blanker.cpp index ce746d8..3a04b06 100644 --- a/src/cursor_blanker.cpp +++ b/src/cursor_blanker.cpp @@ -1,5 +1,6 @@ #include "cursor_blanker.h" #include "tick_span.h" // #361: per-tick spans +#include namespace wind { static const UINT kStandardIds[] = { @@ -16,32 +17,88 @@ static HCURSOR CreateBlankCursor() { return CreateCursor(nullptr, 0, 0, 32, 32, andMask, xorMask); } +static void BlankAll() { + for (UINT id : kStandardIds) { + HCURSOR blank = CreateBlankCursor(); + if (blank) SetSystemCursor(blank, id); // SetSystemCursor takes ownership of 'blank' + } +} + +static void RestoreAll() { + // Reloads the user's scheme as it is NOW (animated cursors, size and scheme changes made + // while Wind runs all survive), which is why this is not a copy-back of cached handles. + SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0); +} + CursorBlanker::CursorBlanker() { // If a previous Wind was hard-killed while cursors were blanked, the desktop still has blank // shared cursors; reload the user's scheme FIRST or the blanks get captured as "originals". - SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0); + RestoreAll(); for (UINT id : kStandardIds) { HCURSOR shared = LoadCursorW(nullptr, MAKEINTRESOURCEW(id)); if (!shared) continue; HCURSOR copy = CopyCursor(shared); if (copy) originals_[shared] = copy; } + worker_ = std::thread([this] { run(); }); +} + +CursorBlanker::~CursorBlanker() { + if (blanked_) restoreSync(); + { std::lock_guard lk(mx_); stop_ = true; } + cv_.notify_one(); + if (worker_.joinable()) worker_.join(); +} + +void CursorBlanker::run() { + SetThreadDescription(GetCurrentThread(), L"Wind cursor swaps"); + // Above normal: under a build the swap must still land promptly, or the pointer comes back + // late. It never competes with the tick for long: a swap is a few ms of work. + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL); + for (;;) { + std::function op; + { + std::unique_lock lk(mx_); + cv_.wait(lk, [this] { return stop_ || !q_.empty(); }); + if (q_.empty()) return; // stop_ with nothing left to do + op = std::move(q_.front()); + q_.pop_front(); + } + op(); + } +} + +void CursorBlanker::post(std::function op) { + { std::lock_guard lk(mx_); q_.push_back(std::move(op)); } + cv_.notify_one(); +} + +bool CursorBlanker::runSync(std::function op) { + auto done = std::make_shared>(); + auto fut = done->get_future(); + post([op = std::move(op), done] { op(); done->set_value(); }); + return fut.wait_for(std::chrono::seconds(2)) == std::future_status::ready; } void CursorBlanker::blank() { SpanScope span(kSpanCursor); if (blanked_) return; blanked_ = true; - for (UINT id : kStandardIds) { - HCURSOR blank = CreateBlankCursor(); - if (blank) SetSystemCursor(blank, id); // SetSystemCursor takes ownership of 'blank' - } + post(BlankAll); } -void CursorBlanker::restore() { +void CursorBlanker::restore(std::function after) { SpanScope span(kSpanCursor); if (!blanked_) return; blanked_ = false; - SystemParametersInfoW(SPI_SETCURSORS, 0, nullptr, 0); + post([after = std::move(after)] { RestoreAll(); if (after) after(); }); +} + +void CursorBlanker::restoreSync() { + SpanScope span(kSpanCursor); + blanked_ = false; + // Queued behind any pending blank, so the final state is restored. A worker that does not + // answer in 2 s (wedged) must not leave the user without a pointer: restore from here. + if (!runSync(RestoreAll)) RestoreAll(); } } diff --git a/src/cursor_blanker.h b/src/cursor_blanker.h index e3cf5b7..15384a5 100644 --- a/src/cursor_blanker.h +++ b/src/cursor_blanker.h @@ -1,16 +1,43 @@ #pragma once #include +#include +#include +#include +#include +#include #include namespace wind { +// Swaps the system cursor set for blanks while Wind draws its own pointer, and back. +// +// The swaps run on a worker thread, in order (#363). Each one is 14 SetSystemCursor calls or a +// full SPI_SETCURSORS scheme reload from disk: measured 8 ms median and up to 90 ms per zoom-out +// under load, all of it a frozen frame when it ran on the tick thread. blanked() is the caller's +// view (what was last asked for); the worker catches up within a few ms. class CursorBlanker { public: CursorBlanker(); + ~CursorBlanker(); + CursorBlanker(const CursorBlanker&) = delete; + CursorBlanker& operator=(const CursorBlanker&) = delete; const std::unordered_map& originals() const { return originals_; } bool blanked() const { return blanked_; } void blank(); - void restore(); + // after (optional) runs on the worker once the cursors are back: the repaint nudge, which + // must follow the restore to show the restored shape. + void restore(std::function after = nullptr); + // Restore and wait for it (up to 2 s). For paths that act on the restored pointer in the same + // breath (the input-panel clip nudge) and for shutdown. + void restoreSync(); private: + void post(std::function op); + bool runSync(std::function op); + void run(); std::unordered_map originals_; bool blanked_ = false; + std::mutex mx_; + std::condition_variable cv_; + std::deque> q_; + bool stop_ = false; + std::thread worker_; }; } diff --git a/src/main.cpp b/src/main.cpp index d0bf8fe..42e111d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -64,6 +64,9 @@ static void EnsureCompositePulse() { if (!g_compEvt) return; HANDLE th = CreateThread(nullptr, 0, [](LPVOID) -> DWORD { SetThreadDescription(GetCurrentThread(), L"Wind composite pulse"); + // HIGHEST like the tick it paces (#363): at normal priority a build starved it and the + // tick waited on a late pulse (pulse-thread-late hitches). It only blocks in DwmFlush. + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST); for (;;) { if (DwmFlush() != S_OK) Sleep(50); // DWM restarting: back off, keep trying DWM_TIMING_INFO ti{}; ti.cbSize = sizeof(ti); diff --git a/src/transform_model.cpp b/src/transform_model.cpp index fbcb8bb..c78365b 100644 --- a/src/transform_model.cpp +++ b/src/transform_model.cpp @@ -400,13 +400,16 @@ void TransformModel::setActive(bool active) { // a session that never entered the draw branch (cursorVisibility=never, hide-hotkey) still // has blanked system cursors to give back even though cursorHidden_ never went true. if (blanker_) { - blanker_->restore(); - step(1); // system cursor shapes restored // Windows repaints the pointer plane only on the next cursor EVENT, so a restored-but- // still pointer stays invisible until the hand moves (field-verified). A 1px nudge and - // back generates that event invisibly. - POINT np; - if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + // back generates that event invisibly. It must FOLLOW the restore, which runs on the + // blanker's worker (#363: the scheme reload froze the 1x landing frame for 8-90 ms), so + // the nudge rides along on the worker too. + blanker_->restore([] { + POINT np; + if (GetCursorPos(&np)) { SetCursorPos(np.x + 1, np.y); SetCursorPos(np.x, np.y); } + }); + step(1); // system cursor restore queued (it was the whole teardown cost) } step(2); // pointer nudge edgeClipManage(false); // give the clip back before the session winds down @@ -945,7 +948,7 @@ void TransformModel::present(const MapResult& r, double level, const Config& cfg // at zoom-in without cursorHidden_, so a panel open at zoom-in stayed invisible (field). if (cursorHidden_) { ShowSystemCursorMarshalled(TRUE); cursorHidden_ = false; } if (blanker_ && blanker_->blanked()) { - blanker_->restore(); + blanker_->restoreSync(); // the clip nudge below needs the restored shape in place // The plane repaints only on the next cursor EVENT, and a same-position SetCursorPos is // not one; the pointer is pinned by its 1px clip, so nudge the clip a pixel and back // (review #284). The weld above has already placed the pin this frame. @@ -1130,7 +1133,7 @@ bool TransformModel::retarget(const MonitorTarget& m) { void TransformModel::shutdown() { teardownMag(); if (sprite_) sprite_->destroy(); - if (blanker_) blanker_->restore(); + if (blanker_ && blanker_->blanked()) blanker_->restoreSync(); // never exit with blank cursors pin_.destroy(); mpoGhost_.destroy(); ready_ = false; diff --git a/src/version.h b/src/version.h index 75232d5..2c5cfd0 100644 --- a/src/version.h +++ b/src/version.h @@ -4,7 +4,7 @@ #define WIND_VER_MAJOR 0 #define WIND_VER_MINOR 23 -#define WIND_VER_PATCH 0 +#define WIND_VER_PATCH 1 // String form for logs/snapshot/UI. Keep in sync with the numeric parts above. -#define WIND_VERSION_STR "0.23.0" +#define WIND_VERSION_STR "0.23.1"