From 4b48482e8fdf5202bf193392e6f3af9490fcf266 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 08:45:10 -0400 Subject: [PATCH] ui: bound queued synthetic input gestures (#2197) * ui: bound queued synthetic input gestures * ui: reserve exact click event count --- crates/ui-egui/src/control.rs | 29 ++++++++++++++++++++ crates/ui-egui/tests/ui_control_modifiers.rs | 18 ++++++++++++ 2 files changed, 47 insertions(+) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 15493ad7..7fe382a5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -321,6 +321,26 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques Err(e) => return err(e), }; let m = modifiers(p); + // Validate the complete gesture before mutating the queue; the loopback control + // channel is externally reachable and must not amplify tiny requests into huge work. + const MAX_PENDING_EVENTS: usize = 4096; + const MAX_CLICK_COUNT: u64 = 512; + let pending = app.synthetic.len(); + let gesture_events = if req.method == "ui.click" { + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + if count == 0 || count > MAX_CLICK_COUNT { + return err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}")); + } + let Some(events) = usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)) else { + return err("click event count overflow"); + }; + events + } else { + 3 + }; + if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) { + return err("synthetic input queue is full"); + } // egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event) // and the synthetic events arrive one per frame: hold them for the whole gesture. app.synthetic.push(egui::Event::ModifiersChanged(m)); @@ -348,6 +368,15 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques (Err(e), _) | (_, Err(e)) => return err(e), }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); + const MAX_PENDING_EVENTS: usize = 4096; + const MAX_DRAG_STEPS: u64 = 2048; + if steps > MAX_DRAG_STEPS { + return err(format!("`steps` must be at most {MAX_DRAG_STEPS}")); + } + let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") }; + if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) { + return err("synthetic input queue is full"); + } let m = modifiers(p); app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); diff --git a/crates/ui-egui/tests/ui_control_modifiers.rs b/crates/ui-egui/tests/ui_control_modifiers.rs index 46567aed..3288d6f1 100644 --- a/crates/ui-egui/tests/ui_control_modifiers.rs +++ b/crates/ui-egui/tests/ui_control_modifiers.rs @@ -37,6 +37,24 @@ fn send(app: &mut EffectcraftApp, method: &str, params: Value) { let _ = control::handle(app, &ctx, &req); } +#[test] +fn control_gestures_reject_excessive_counts_without_queue_mutation() { + let mut app = EffectcraftApp::new(Session::default()); + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": u64::MAX})); + assert!(app.take_synthetic_input().is_empty(), "an oversized click must not partially queue input"); + + send(&mut app, "ui.drag", json!({"from": {"x": 10, "y": 10}, "to": {"x": 90, "y": 40}, "steps": u64::MAX})); + assert!(app.take_synthetic_input().is_empty(), "an oversized drag must not partially queue input"); + + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512})); + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512})); + assert_eq!(app.take_synthetic_input().len(), 2054, "two maximum accepted clicks fit under the aggregate cap"); + for _ in 0..4 { + send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512})); + } + assert_eq!(app.take_synthetic_input().len(), 3081, "the pending event limit is enforced before requests can exceed it"); +} + #[test] fn shift_click_holds_shift_from_press_to_release() { let mut app = EffectcraftApp::new(Session::default());