Skip to content

PressAndHoldHandler tracks one hold per device, so two simultaneous holds on one device collide (and it isn't thread-safe) #1489

Description

@anthony-lopez-pd

Summary

PressAndHoldHandler (src/PepperDash.Essentials.MobileControl.Messengers/Messengers/PressAndHoldHandler.cs) keys its heartbeat timers by device key only, in a static Dictionary<string, Timer> with no locking. Two press-and-hold actions held at the same time on the same device, such as cameraLeft and cameraUp for a diagonal PTZ move, share one entry. The result is an exception in the message handler, a second action that never starts, and an axis that stops about 1 s late.

Present in v3.0.0-rc.5, and unchanged on main, development and dev/v3-routing.

Repro

  1. Any camera using the core camera messengers (seen with MockVC camera codec-zoom-camera1 on an RMC4, Essentials 3.0.0-rc.5).
  2. From a Mobile Control client, send in quick succession:
    • /device/<camera>/cameraLeft {"value":"pressed"}
    • /device/<camera>/cameraUp {"value":"pressed"}
    • held for both every 250 ms, then released for both.
  3. The error log shows:
    [EROR][appServer] Exception in handler for message type /device/codec-zoom-camera1/cameraLeft, ClientId 2

It happens on every simultaneous press; a single direction never triggers it.

What goes wrong

Both actions resolve to _pushedActions[deviceKey]:

Case Effect
Both pressed messages pass TryGetValue before either Adds (messages are handled concurrently) The second Add throws ArgumentException (duplicate key). That is the logged exception. Both actions have already been started with action(true), but only one timer is stored.
The second pressed arrives after the first Add AddTimer finds the existing entry and returns without calling action(true), so only one axis moves.
After either case held for either action resets the same timer. The orphaned timer's Elapsed calls _pushedActions.Remove(deviceKey), removing the other action's entry. That action's released then finds nothing, and it stops only when its own timer expires, about ButtonHeartbeatInterval (1 s) after release.

Worst case, a camera keeps moving for about a second after release rather than indefinitely. But the behaviour is racy, and a UI can't offer a combined move (diagonal PTZ) reliably. There is no combined pan+tilt command in the camera interfaces (IHasCameraPanControl / IHasCameraTiltControl are separate), so issuing two holds is the only way to do it.

Proposed fix

  1. Key per device and action. Have HandlePressAndHold take the action path (or accept a key such as $"{deviceKey}{path}"), so cameraLeft and cameraUp get independent timers. Callers already know the path from AddAction.
  2. Make it thread-safe. Use a ConcurrentDictionary<string, Timer> with TryAdd / TryRemove, or lock around check-and-add. The Elapsed handler should only remove the entry if it is still its own timer (TryRemove with a value comparison / ICollection<KeyValuePair>.Remove).
  3. Don't swallow the second press. With per-action keys this goes away. If a device-level key is kept for some caller, a second distinct action should still start rather than return silently.

Backwards compatible: single-action holds behave exactly as today.

Context

Found while adding diagonal PTZ buttons to a Mobile Control React UI for a Zoom Room system (the Zoom Room SDK's camera actions are also single-axis only).

Activity

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