Skip to content

Linux: uinput keyboard repeat thread can re-press a just-released key, leaving it stuck down #197

Closed

Description

@swillunderscore

Describe the Bug

On Linux, UinputKeyboard's repeat thread can press a key again right after it was released. The key then stays down in the kernel, but libvirtualhid (pressed_keys_) and Sunshine (key_press) both track it as released, so nothing ever releases it. That includes Sunshine's reset_keyboard_keys() at session end, so the key stays held on the virtual keyboard after the stream is over.

What I saw with Sunshine: after a Moonlight session ended, KEY_F was still held on libvirtualhid Keyboard (EVIOCGKEY) with no session active. On Wayland (Hyprland), a key held on any keyboard blocks that key on every keyboard, so F stopped working on the physical keyboard too, until I released it by writing KEY_F 0 to the device's event node. I can't prove this race is what caused that particular stuck key. It is the only path I found that leaves a key down while both trackers say it's released, which matches what I saw.

The race, in src/platform/linux/uhid_backend.cpp:

  • submit() emits the event, then updates pressed_keys_ under pressed_keys_mutex_.
  • The repeat thread copies pressed_keys_ (pressed_keys_snapshot()), drops the lock, then emits pressed = true for every copied key.

If a release is emitted after the copy but before the repeat thread's emit, the kernel sees release, then press: the key is down, and pressed_keys_ says released. write_mutex_ only covers single writes, so it doesn't close this gap. The window is small, so it's rare with normal typing, but when it hits, the key stays down indefinitely.

Sunshine doesn't set auto_repeat_interval_ms (refresh_keyboard()), so the default of 50 ms applies. On the running Sunshine, one thread wakes 20 times a second in hrtimer_nanosleep, consistent with this loop.

On Linux the thread doesn't produce repeats anyway: the input core drops EV_KEY value 1 for a key that's already down (only value 2 skips that check), and the device has no EV_REP. On a grabbed uinput device, 1 press + 5 re-sent presses 50 ms apart + 1 release delivers [1, 0]. Held-key repeat comes from the compositor / X server. So this race is the only visible effect the thread has.

Expected Behavior

A released key stays released, and no key is left held on the virtual keyboard after a stream ends.

Additional Context

Versions: Sunshine 2026.1013.171040 (CachyOS package; master 7c124b4) with libvirtualhid ecc8291. The same code is at libvirtualhid master dfff3ea (Sunshine master d60255f). CachyOS, kernel 7.2.10, Hyprland 0.56.2.

Repro: a standalone copy of submit(), the repeat thread and the per-write lock (below), against a real uinput device grabbed so nothing reaches the desktop. It presses and releases KEY_F as fast as it can for 5 s and checks EVIOCGKEY after each release:

lvh       5s: 2178373 press/release cycles, 16 times KEY_F left down in the kernel with the library tracking it as released
norepeat  5s: 2240492 press/release cycles, 0 times KEY_F left down in the kernel with the library tracking it as released
fixed     5s: 2149470 press/release cycles, 0 times KEY_F left down in the kernel with the library tracking it as released

Two more lvh runs: 13 and 10. lvh is the upstream logic. norepeat has the repeat thread off. fixed does the emit and the pressed_keys_ update under one lock in submit(), and the repeat thread emits while holding that lock. This copy is not libvirtualhid itself; I haven't patched and rebuilt the library.

Possible fixes: don't start the repeat thread on Linux (or have Sunshine pass auto_repeat_interval_ms = 0), or hold pressed_keys_mutex_ across emit + bookkeeping in both places.

race.cpp (g++ -std=c++20 -O2 -pthread race.cpp; ./race lvh 5)
// Replica of libvirtualhid's Linux UinputKeyboard (ecc8291 / dfff3ea,
// src/platform/linux/uhid_backend.cpp): submit() = emit, then update
// pressed_keys_; repeat thread = every 50 ms, snapshot pressed_keys_, emit a
// press for each, with no lock held across snapshot -> emit. Writes take a
// per-write mutex like UinputDevice::emit_event().
// Mode "lvh": as upstream.  "norepeat": repeat thread off.  "fixed": emit and
// bookkeeping under one lock, repeat re-checks membership under that lock.
// The test device is EVIOCGRAB'd so no event reaches the compositor.
#include <linux/uinput.h>
#include <linux/input.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <vector>

static int ufd;
static std::mutex mtx;
static std::set<int> pressed;
static std::string mode;

static std::mutex write_mtx;  // UinputDevice::write_mutex_, held per write

static void write_event(int type, int code, int value) {
  std::lock_guard l {write_mtx};
  input_event ev {};
  ev.type = type; ev.code = code; ev.value = value;
  if (write(ufd, &ev, sizeof ev) < 0) perror("write");
}

static void emit(int code, int value) {
  // upstream emit_keyboard_event(): emit_event(EV_KEY) then sync(), each a separately locked write
  write_event(EV_KEY, code, value);
  write_event(EV_SYN, SYN_REPORT, 0);
}

static void submit(int code, bool down) {
  if (mode == "fixed") {
    std::lock_guard l {mtx};
    emit(code, down);
    if (down) pressed.insert(code); else pressed.erase(code);
    return;
  }
  emit(code, down);                       // emit_keyboard_event(event)
  std::lock_guard l {mtx};                // update_pressed_keys(event)
  if (down) pressed.insert(code); else pressed.erase(code);
}

int main(int argc, char **argv) {
  mode = argc > 1 ? argv[1] : "lvh";
  double seconds = argc > 2 ? atof(argv[2]) : 5;
  ufd = open("/dev/uinput", O_WRONLY | O_NONBLOCK);
  if (ufd < 0) { perror("/dev/uinput"); return 2; }
  ioctl(ufd, UI_SET_EVBIT, EV_KEY);
  ioctl(ufd, UI_SET_KEYBIT, KEY_F);
  uinput_setup us {};
  us.id.bustype = BUS_VIRTUAL;
  strcpy(us.name, "tc-lvh-race-test");
  ioctl(ufd, UI_DEV_SETUP, &us);
  ioctl(ufd, UI_DEV_CREATE);
  char sys[64] {};
  ioctl(ufd, UI_GET_SYSNAME(sizeof sys), sys);
  // find /dev/input/eventN for /sys/devices/virtual/input/<sys>
  int efd = -1;
  for (int tries = 0; tries < 200 && efd < 0; ++tries) {
    for (int n = 0; n < 512 && efd < 0; ++n) {
      std::string link = "/sys/devices/virtual/input/" + std::string(sys) + "/event" + std::to_string(n);
      if (access(link.c_str(), F_OK) == 0) {
        std::string dev = "/dev/input/event" + std::to_string(n);
        efd = open(dev.c_str(), O_RDONLY | O_NONBLOCK);
      }
    }
    if (efd < 0) std::this_thread::sleep_for(std::chrono::milliseconds(5));
  }
  if (efd < 0 || ioctl(efd, EVIOCGRAB, 1) != 0) { perror("grab"); return 2; }

  std::atomic_bool run {true};
  std::thread rep;
  if (mode != "norepeat") {
    rep = std::thread([&] {
      while (run) {
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
        if (mode == "fixed") {
          std::lock_guard l {mtx};
          for (int k : pressed) emit(k, 1);
          continue;
        }
        std::vector<int> snap;
        { std::lock_guard l {mtx}; snap.assign(pressed.begin(), pressed.end()); }  // pressed_keys_snapshot()
        for (int k : snap) emit(k, 1);    // emit_keyboard_event({key, pressed=true})
      }
    });
  }

  unsigned char bits[KEY_MAX / 8 + 1];
  long cycles = 0, stuck = 0;
  auto end = std::chrono::steady_clock::now() + std::chrono::duration<double>(seconds);
  while (std::chrono::steady_clock::now() < end) {
    submit(KEY_F, true);
    submit(KEY_F, false);
    ++cycles;
    ioctl(efd, EVIOCGKEY(sizeof bits), bits);
    if (bits[KEY_F / 8] & (1 << (KEY_F % 8))) {
      bool tracked; { std::lock_guard l {mtx}; tracked = pressed.count(KEY_F); }
      if (!tracked) ++stuck;              // kernel: down; library: released
    }
  }
  run = false;
  if (rep.joinable()) rep.join();
  submit(KEY_F, false);                   // leave nothing held
  ioctl(efd, EVIOCGRAB, 0);
  close(efd);
  ioctl(ufd, UI_DEV_DESTROY);
  close(ufd);
  printf("%-9s %.0fs: %ld press/release cycles, %ld times KEY_F left down in the kernel with the library tracking it as released\n",
         mode.c_str(), seconds, cycles, stuck);
}

Activity

  1. ReenigneArcher commented on Oct 20, 2026

    @ReenigneArcher
    Member

    Are you able to self compile Sunshine with #198 to test that it resolves your issue?

  2. swillunderscore commented on Oct 20, 2026

    @swillunderscore
    Author

    Yes. I built Sunshine 7c124b4 (the commit I run) twice, changing only the libvirtualhid submodule: dfff3ea (this PR's base) as the control, and 5bab58e (this PR). Then I tested both over a real Moonlight session.

    Setup: Moonlight Qt 6.1.0 (flatpak) in cage on a second Linux PC, wired LAN to the Sunshine host (CachyOS, kernel 7.2.10, Hyprland 0.56.2). A script on the client typed into the compositor through zwp_virtual_keyboard_v1: a random walk over F13-F24 with 4-9 keys held at once and 0.2-2 ms between events (~850 events/s), same seed for both builds. On the host, libvirtualhid Keyboard was grabbed with EVIOCGRAB and every EV_KEY it emitted was logged and compared one by one with what the client sent. A stale press shows up as a press of a key the client had already released; the client's next press of that key is then dropped by the kernel because the key is still down.

    libvirtualhid run key events sent / emitted stale presses other differences
    dfff3ea (base) 600 s 508,942 / 508,942 35 0
    5bab58e (this PR) 600 s 508,798 / 508,798 0 0

    A 30 s run on the base build had 2 more.

    Typing still works with the PR. Typing "hello" and then holding x for 1 s into a terminal on the host gave hello plus 12 x's (base build: 11). That's what Hyprland's own repeat gives for a 1 s hold at 600 ms delay / 25 per second, so repeat is fine without the thread.

    Looks fixed to me. Thanks for the quick fix!

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions