Skip to content

fix(control): bound a hold whose release was lost - #52

Merged
mrprecioustech merged 2 commits into
masterfrom
fix/stuck-hold-hijacks-host
Aug 10, 2026
Merged

fix(control): bound a hold whose release was lost#52
mrprecioustech merged 2 commits into
masterfrom
fix/stuck-hold-hijacks-host

Conversation

@mrprecioustech

Copy link
Copy Markdown
Contributor

Diagnosed from a host-side log. The host is Linux/Wayland on the
wayland-ydotool backend — worth stating because the previous two attempts at
this bug (#49, #50) both landed in the nut-js backend and never executed on
the affected machine.

Root cause

armHoldWatchdog is called at the end of trackHeldState, which runs for
every event — including mouse moves. The 5s idle timer was therefore reset by
movement. If a button's "up" was lost while the guest kept moving the mouse,
every move re-armed the timer, it never fired, and the button stayed held
indefinitely.

A held button is not passive. dispatch reads heldButtons.size > 0 as
"dragging", and a drag is the one case where remote movement is injected
rather than tracked virtually:

if (event.action === 'move') {
  this.remotePosition = { x: event.x, y: event.y };
  if (this.virtualCursor && !dragging) return;   // ← bypassed while held
  await backend.inject(this.withEdgeMargin(event));
}

So a stuck hold turns every remote move into a real pointer move. Observed
consequences, all from this one cause:

  • the guest's mouse drives the host's pointer continuously, so the host
    cannot move their own cursor
  • the guest cannot click, because the button is already down and every
    interaction has become a drag
  • revoking control fixes it without a reboot, because that is
    disable() -> releaseAll()

Fix

Not resetting the idle timer on movement would trade this for a different bug:
any drag longer than the timeout would be torn apart mid-drag. Both properties
are required simultaneously, so there are two timers:

  • idle (5s) — keeps its reset-on-every-event behaviour, so an active drag
    is never cut short. Catches a viewer that disappears entirely.
  • absolute (30s) — started once when the first button or key goes down,
    never reset, cleared on genuine release. The only thing that can bound a hold
    whose release was lost. No real drag reaches it.

Also surfaces heldButtons/heldKeys in getDiagnostics(). A stuck hold is
otherwise invisible — it presents to the user as "my mouse is possessed" with
nothing anywhere reporting that the injector still believes a button is down.

Tests

Three regressions, all using fake timers:

  • a hold whose "up" was lost is released even while moves keep arriving
  • the absolute timer is not restarted by repeated presses
  • a normal press/release clears it, so the next hold gets a full window

Validation: lint, typecheck, 1454 tests, 34 script tests, format, build.

Taking a host's machine away from them needed only a dropped "up".

`armHoldWatchdog` runs at the end of `trackHeldState`, which runs for every
event — including mouse moves. So the 5s idle timer was reset by movement. If
a button's "up" was lost while the guest kept moving the mouse, every move
re-armed the timer and it never fired, and the button stayed held forever.

A held button is not passive. `dispatch` reads `heldButtons.size > 0` as
"dragging", and a drag is the one case where remote movement is injected
instead of being tracked virtually. So the stuck hold turned every remote
move into a real pointer move: the guest's mouse drove the host's, the host
could not move their own cursor, and their clicks did nothing because the
button was already down and everything had become a drag. Revoking control
was the only escape, which is precisely what `disable()` -> `releaseAll()`
does — and why it recovered without a reboot.

Fixing it by not resetting the idle timer on movement would trade one bug for
another: a legitimate drag longer than the timeout would be torn apart
mid-drag. Both properties are needed at once, so there are now two timers.
The idle timer keeps its reset-on-every-event behaviour, so an active drag is
never cut short. A second, absolute timer starts once when the first button or
key goes down, is never reset, and releases everything after 30s. It is the
only thing that can bound a hold whose release was lost, and no real drag
reaches it.

Also surfaces heldButtons/heldKeys in getDiagnostics. A stuck hold is
otherwise invisible: it presents as "my mouse is possessed", with nothing
anywhere reporting that the injector still thinks a button is down.
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

160 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 49 | LOW: 106

Severity Rule Location
HIGH secret-generic-credential apps/livekit/fly.toml:12
HIGH manifest-typosquat apps/mobile/package.json:43
HIGH secret-generic-credential apps/turn/fly.toml:11
HIGH secret-generic-credential docs/API.md:648
HIGH secret-generic-credential docs/API.md:654
MEDIUM insecure-temp-file apps/desktop/src/main/streaming/index.test.ts:64
MEDIUM sql-template-interpolation apps/desktop/src/renderer/components/capture/CameraBubble.tsx:129
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:370
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:393
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:402
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:438
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:48
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:191
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:165
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:128
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:145
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:122
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:284
MEDIUM js-open-redirect apps/web/src/app/pricing/UpgradeButton.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:282
MEDIUM manifest-install-lifecycle-script package.json:54
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:81
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:96
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:112
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:132
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:152
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:178
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:200
MEDIUM sql-template-interpolation scripts/lib/package-managers/apt.ts:55
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:154
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:160
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:208
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:313
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/aur.ts:310
MEDIUM sql-template-interpolation scripts/lib/package-managers/aur.ts:341
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/aur.ts:341
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:264
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/chocolatey.ts:288
MEDIUM sql-template-interpolation scripts/lib/package-managers/gentoo.ts:274
MEDIUM sql-template-interpolation scripts/lib/package-managers/homebrew.ts:152
MEDIUM sql-template-interpolation scripts/lib/package-managers/nix.ts:225
MEDIUM sql-template-interpolation scripts/lib/package-managers/rpm.ts:55
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:201
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/rpm.ts:261
MEDIUM sql-template-interpolation scripts/lib/package-managers/scoop.ts:145
MEDIUM js-shell-exec-interpolation scripts/release.mjs:145
MEDIUM js-shell-exec-interpolation scripts/release.mjs:146
MEDIUM js-shell-exec-interpolation scripts/release.mjs:147

…and 110 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

Both diagnosed from documentation and build output rather than inference.

**dbus-next could never load in a packaged build.** Its `require('x11')` sits
inside `getDbusAddressFromWindowSelection`, a function `connection.js` never
calls — it only imports `getDbusAddressFromFs`. Lazy, and dead. But rollup's
commonjs plugin hoisted it to module scope, line 11 of the chunk, beside the
node builtins:

    const path$1 = require("path");
    const require$$1$2 = require("os");
    require("x11");            <-- hoisted out of a function that never runs

`x11` is a dependency of nothing here — not of dbus-next (it is not even in
its optionalDependencies), not of remote-input, and it ships nowhere. So
loading the chunk threw `Cannot find module 'x11'`, KWin cursor reporting was
never available in any packaged build on any Linux host, `getCursorPosition()`
always returned null, and `restoreLocalPointer()` was permanently a no-op.

dbus-next was only bundled because remote-input is excluded from
externalization and drags its dependencies in with it. Declaring dbus-next in
the desktop app makes externalizeDepsPlugin leave it alone and electron-builder
ship it, so the lazy require stays lazy. The chunk is gone and the require with
it; the module is now a genuine runtime `import("dbus-next")`.

**The desktop cursor overlay is unsupported on Wayland.** Electron documents
`showInactive()` — the call that shows this window *without* taking focus — as
"Not supported on Wayland (Linux)", along with `setPosition()`, and notes that
positioning, moving, focusing and blurring generally are not possible there
without user input. The `level` argument to `setAlwaysOnTop` is documented
macOS/Windows only.

This window is fullscreen and always-on-top over the host's real desktop, and
the file's own header already warned that one which failed to be inert "would
lock the user out of their own desktop". Building it out of operations the
platform documents as unsupported is not a risk worth carrying, so it is not
created on Wayland. The in-app cursor still draws inside the PairUX window,
so the guest's pointer stays visible over the video; only the desktop-wide
overlay is given up.
@mrprecioustech
mrprecioustech merged commit 5ca2726 into master Aug 10, 2026
13 checks passed
@mrprecioustech
mrprecioustech deleted the fix/stuck-hold-hijacks-host branch August 10, 2026 16:00
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.

1 participant