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
- Any camera using the core camera messengers (seen with
MockVC camera codec-zoom-camera1 on an RMC4, Essentials 3.0.0-rc.5).
- 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.
- 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
- 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.
- 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).
- 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).
Summary
PressAndHoldHandler(src/PepperDash.Essentials.MobileControl.Messengers/Messengers/PressAndHoldHandler.cs) keys its heartbeat timers by device key only, in a staticDictionary<string, Timer>with no locking. Two press-and-hold actions held at the same time on the same device, such ascameraLeftandcameraUpfor 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 onmain,developmentanddev/v3-routing.Repro
MockVCcameracodec-zoom-camera1on an RMC4, Essentials3.0.0-rc.5)./device/<camera>/cameraLeft {"value":"pressed"}/device/<camera>/cameraUp {"value":"pressed"}heldfor both every 250 ms, thenreleasedfor both.[EROR][appServer] Exception in handler for message type /device/codec-zoom-camera1/cameraLeft, ClientId 2It happens on every simultaneous press; a single direction never triggers it.
What goes wrong
Both actions resolve to
_pushedActions[deviceKey]:pressedmessages passTryGetValuebefore eitherAdds (messages are handled concurrently)AddthrowsArgumentException(duplicate key). That is the logged exception. Both actions have already been started withaction(true), but only one timer is stored.pressedarrives after the firstAddAddTimerfinds the existing entry and returns without callingaction(true), so only one axis moves.heldfor either action resets the same timer. The orphaned timer'sElapsedcalls_pushedActions.Remove(deviceKey), removing the other action's entry. That action'sreleasedthen finds nothing, and it stops only when its own timer expires, aboutButtonHeartbeatInterval(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/IHasCameraTiltControlare separate), so issuing two holds is the only way to do it.Proposed fix
HandlePressAndHoldtake the action path (or accept a key such as$"{deviceKey}{path}"), socameraLeftandcameraUpget independent timers. Callers already know the path fromAddAction.ConcurrentDictionary<string, Timer>withTryAdd/TryRemove, or lock around check-and-add. TheElapsedhandler should only remove the entry if it is still its own timer (TryRemovewith a value comparison /ICollection<KeyValuePair>.Remove).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).