Skip to content

Wayland/uinput: paste leaves Ctrl/Alt stuck — chord() re-presses held modifiers on the virtual keyboard and never releases them #123

Closed

Description

wispr-flow --doctor output

doctor
Wispr Flow Diagnostics
==========================
Session: wayland   Desktop: ubuntu:GNOME   Arch: x86_64
Helper:  /tmp/.mount_XXXX/usr/lib/wispr-flow/resources/Release/wispr-flow-linux-helper

Display / Session
[PASS] Display server: Wayland (WAYLAND_DISPLAY=wayland-0)
       Mode: Electron Ozone auto-detect (default)
       Desktop: ubuntu:GNOME  [GNOME (Mutter)]

Text Injection (uinput)
[PASS] /dev/uinput: writable (keystroke injection available)
[WARN] input group: current user is NOT a member
       Harmless if /dev/uinput is already writable above.
       Otherwise: sudo usermod -aG input "$USER"  (then re-login)

Push-to-Talk (input monitor)
[PASS] /dev/input: 16/16 event device(s) readable (push-to-talk available)

Clipboard
[PASS] Clipboard: wl-copy and wl-paste present (Wayland)

Accessibility (AT-SPI)
[PASS] AT-SPI: accessibility enabled (toolkit-accessibility=true)

GNOME Window Bridge
[PASS] GNOME extension: active ([email protected])

Helper & Runtime
[PASS] Helper binary: launches OK (wispr-flow-linux-helper 0.1.2)
[PASS] Electron runtime: v42.3.0 (/tmp/.mount_XXXX/usr/lib/wispr-flow/wispr-flow)
       chrome-sandbox: AppImage runs with --no-sandbox (setuid not required)
[WARN] Desktop entry: not found (expected for AppImage installs)
[PASS] Disk space: 162459MB free on the config partition
[PASS] SingletonLock: held by running process (PID 264811)
[PASS] Recent crashes: none in launcher.log tail

All checks passed.

Distribution

Ubuntu 24.04.5 LTS

Desktop environment

GNOME Shell 46.0 (Mutter)

Session type

Wayland

Install format

AppImage: wispr-flow-1.6.897-1.0.3-x86_64.AppImage (helper 0.1.2; v1.0.4 pins the same helper in helper-version.txt)

What happened

After a dictation is pasted, the keyboard sometimes becomes unusable. Every key acts as Ctrl+key, and Alt+Tab opens GNOME's Ctrl+Alt+Tab "switch system controls" panel. When Alt is the stuck key, Esc cycles windows (Alt+Esc). Pressing the key again doesn't clear it, and later pastes don't either.

EVIOCGKEY on the Wispr Flow Linux Helper input device shows the modifier (for example KEY_LEFTCTRL) still held. Writing a release (KEY_LEFTCTRL 0 + SYN_REPORT) to that event node, or restarting the helper, fixes it.

Trigger: a modifier is physically held at the moment the helper injects the paste. For example, after the default Ctrl+Win push-to-talk you release Win first and are still holding Ctrl when the text lands.

Steps to reproduce

  1. Hold Ctrl+Win and dictate a sentence.
  2. Release Win, but keep holding Ctrl until the text is pasted.
  3. Release Ctrl and type.

Typing now produces Ctrl shortcuts, and Ctrl is still down on the helper's virtual keyboard.

Headless and deterministic, with no real keyboard involved: create a uinput keyboard, EVIOCGRAB it so the compositor ignores it, and hold KEY_LEFTALT on it. Then send the helper a PasteText request. The helper's virtual keyboard emits:

KEY_LEFTCTRL 1, KEY_V 1, KEY_V 0, KEY_LEFTCTRL 0, KEY_LEFTALT 1

Afterwards, EVIOCGKEY on it reports KEY_LEFTALT held.

Expected vs actual

Expected: after a paste, the helper's virtual keyboard holds no keys.
Actual: any modifier held on any keyboard at paste time stays pressed on the virtual keyboard until the helper exits.

Logs / errors

The helper's stderr shows nothing. The trace above was read from the helper's event device.

Anything else

Root cause: UInput::chord() in src/backend/uinput.rs (helper main @ fa93fcf, v0.1.2):

let held = held_modifiers();                            // EVIOCGKEY on *every* /dev/input/event*
for &m in &held { let _ = self.key(m, false); }         // "release first"
// ... Ctrl down, V down, V up, Ctrl up ...
for &m in held.iter().rev() { let _ = self.key(m, true); } // "restore"

This mirrors the Windows helper's GetKeyState dance, but on Linux key state is per device:

  • Release step: the key-ups go to the helper's own uinput device, where those keys aren't down. The kernel drops them, so they never release the physical key. They don't appear in the trace above.
  • Restore step: the key-downs are new presses on the uinput device. Nothing ever releases them, because the user's later key-up arrives on the physical device. libinput and Mutter then keep the modifier down for the whole seat.
  • Self-perpetuating: held_modifiers() also scans the helper's own device. Every later paste sees the stuck key there and presses it again.

This should affect any compositor that uses libinput. The X11 backend does a plain chord and is not affected. Its TODO to add the same dance via XQueryKeymap would be worth dropping.

Proposed fix: remove the dance from the uinput backend. The release half can't work from a separate device, and the restore half is what causes the bug.

--- a/src/backend/uinput.rs
+++ b/src/backend/uinput.rs
@@ pub fn chord(&mut self, key: u16, mods: &[u16]) -> Result<()> {
-        let held = held_modifiers();
-        for &m in &held {
-            let _ = self.key(m, false);
-        }
         for &m in mods {
             self.key(m, true)?;
         }
         self.key(key, true)?;
         self.key(key, false)?;
         for &m in mods.iter().rev() {
             self.key(m, false)?;
         }
-        // Restore physically-held modifiers (reverse order). Best-effort.
-        for &m in held.iter().rev() {
-            let _ = self.key(m, true);
-        }
         Ok(())
     }

held_modifiers() and keymap::EVDEV_MODIFIERS are then unused and can be removed to keep clippy clean.

With a modifier held, the paste becomes for example Ctrl+Shift+V. That already happens today, because the release step never worked. If you want to neutralise held modifiers properly, wait briefly before injecting (bounded, say up to 300–500 ms) until no physical modifier is down. That avoids touching another device's state.

Verified on the shipped 0.1.2 binary, with a local change equivalent to the diff (held_modifiers() forced to return nothing):

  • The headless repro now emits only Ctrl↓ V↓ V↑ Ctrl↑ and leaves nothing held.
  • A live dictation with Ctrl held through the paste no longer sticks.
  • CheckStaleKeys answers are unchanged.

Activity

  1. switch87 commented on Oct 4, 2026

    @switch87
    Contributor

    Confirming this on Gentoo (amd64), KDE Plasma 6.7.5, Wayland — Wispr Flow 1.6.957, wrapper 1.0.4, helper 0.1.2 built from source. Same symptom: Ctrl stays logically held after a paste if it is still down at injection time.

    Our Gentoo package (app-accessibility/wispr-flow-bin) carries the equivalent of wispr-flow-linux/helper#14 (dropping the held-modifier release/restore in UInput::chord()) as a distro patch; with it the stuck modifier no longer occurs. +1 for merging #14 so the patch can be dropped.

  2. hassanaziz0012 commented on Oct 6, 2026

    @hassanaziz0012

    Same issue happens to me (Gnome, Arch Linux).

  3. switch87 commented on Oct 13, 2026

    @switch87
    Contributor

    @hassanaziz0012 the fix is in wispr-flow-linux/helper#14 (it drops the release/restore of held modifiers in UInput::chord()). Until it's merged, building the helper with that patch applied removes the stuck modifier.

  4. aaddrick commented on Oct 27, 2026

    @aaddrick
    Contributor

    Thanks for the precise root cause and the headless repro; the code matched what you describe. The fix is wispr-flow-linux/helper#14, now merged, and it reaches the packages once it is in a helper release and the pin here moves. Until then, pkill -f wispr-flow-linux-helper clears a stuck key without logging out, since the app restarts the helper.

    I'm working through these with Claude Duet, which is driving this on my behalf.


    Generated by Claude Code

  5. added a commit that references this issue on Oct 27, 2026
  6. added a commit that references this issue on Nov 2, 2026
  7. added a commit that references this issue on Nov 3, 2026
    afeffa7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions