Skip to content

Windows: Controller.scroll() fires on_scroll twice (NotifierMixin._emit + WH_MOUSE_LL hook) #672

@mxschmitt

Description

@mxschmitt

Summary

On Windows, calling Controller.scroll() causes active Listener instances to receive two on_scroll callbacks per scroll call — one from NotifierMixin._emit() and one from the WH_MOUSE_LL hook. This does not happen for clicks (_press/_release don't call _emit).

Root cause

In pynput/mouse/_win32.py, Controller._scroll() does:

  1. SendInput(MOUSEINPUT.WHEEL, ...) — injects the scroll event into the OS
  2. self._emit('on_scroll', px, py, dx, dy, True) — directly invokes all listeners via NotifierMixin._listener_cache

On Windows, WH_MOUSE_LL hooks do receive SendInput events (the LLMHF_INJECTED flag confirms this). So step 2 is redundant — the listener already receives the event from step 1 via the hook.

By contrast, _press() and _release() only call SendInput() without _emit, and they work correctly (no duplication). The _emit on _scroll appears to be an inconsistency.

Minimal reproduction

import queue
import threading
import time
from pynput import mouse

events = queue.Queue()

def on_scroll(x, y, dx, dy):
    events.put(("scroll", dx, dy, threading.get_ident()))

listener = mouse.Listener(on_scroll=on_scroll)
listener.start()
listener.wait()
time.sleep(0.2)

# Clear any startup noise
while not events.empty():
    events.get_nowait()

mc = mouse.Controller()
mc.scroll(0, 2)
time.sleep(0.5)

results = []
while not events.empty():
    results.append(events.get_nowait())

print(f"Scroll events received: {len(results)}  (expected 1, got {len(results)})")
unique_tids = set(r[3] for r in results)
print(f"From {len(unique_tids)} different threads: {unique_tids}")
for i, r in enumerate(results):
    print(f"  [{i}] dx={r[1]} dy={r[2]} tid={r[3]}")

listener.stop()

Output on Windows 11 (pynput 1.8.1, Python 3.14):

Scroll events received: 2  (expected 1, got 2)
From 2 different threads: {41872, 40488}
  [0] dx=0 dy=2 tid=41872    # from WH_MOUSE_LL hook
  [1] dx=0 dy=2 tid=40488    # from NotifierMixin._emit

Suggested fix

Remove the _emit('on_scroll', ...) call from Controller._scroll() in pynput/mouse/_win32.py (line 103), matching the pattern used by _press() and _release().

Alternatively, guard it with a check so it only fires on platforms where SendInput doesn't reach the hook.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions