M1: bound synthetic control input expansion (#1709)

This commit is contained in:
DWAA1660 2026-10-13 13:33:04 -06:00 committed by bflatastic
parent f17ecf4f5c
commit fdf6ba9c9d
9 changed files with 229 additions and 13 deletions

File diff suppressed because one or more lines are too long

View File

@ -32,6 +32,31 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use crate::VectorcraftApp;
use crate::canvas::Xf;
/// Synthetic pointer input advances one event per frame: keep gestures and backlog modest.
pub(crate) const MAX_DRAG_STEPS: u64 = 256;
pub(crate) const MAX_CLICK_COUNT: u64 = 16;
pub(crate) const MAX_SYNTHETIC_EVENTS: usize = 1024;
pub(crate) const MAX_CONTROL_REQUESTS_PER_FRAME: usize = 64;
fn input_count(p: &Value, key: &str, default: u64, max: u64) -> Result<usize, String> {
let n = match p.get(key) {
None => default,
Some(v) => v.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))?,
};
if n > max {
return Err(format!("`{key}` must be at most {max}"));
}
// Preserve legacy zero behaviour: both a zero count and zero steps still perform once.
usize::try_from(n.max(1)).map_err(|_| format!("`{key}` is out of range"))
}
fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(), String> {
if additional > MAX_SYNTHETIC_EVENTS.saturating_sub(app.synthetic.len()) {
return Err(format!("synthetic input queue limit ({MAX_SYNTHETIC_EVENTS} events); retry after pending input drains"));
}
app.synthetic.try_reserve(additional).map_err(|e| format!("cannot reserve synthetic input: {e}"))
}
pub type ControlResponse = Value;
pub struct ControlRequest {
@ -193,6 +218,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
}
"ui.key" => {
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) {
return err(e);
}
let m = modifiers(p);
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
// Typed text comes with the press, so it shares the key's frame and modifiers.
@ -204,12 +232,34 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
ok(Value::Null)
}
"ui.move" => {
if let Err(e) = reserve_synthetic(app, 1) {
return err(e);
}
let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32);
app.synthetic.push(egui::Event::PointerMoved(pos));
ctx.request_repaint();
ok(Value::Null)
}
"ui.click" | "ui.drag" => {
// Validate the entire expansion before appending even the initial move/press.
let steps = if req.method == "ui.drag" {
match input_count(p, "steps", 8, MAX_DRAG_STEPS) {
Ok(n) => n,
Err(e) => return err(e),
}
} else {
0
};
let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) {
Ok(n) => n,
Err(e) => return err(e),
};
let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else {
return err("synthetic input event count is out of range");
};
if let Err(e) = reserve_synthetic(app, events) {
return err(e);
}
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let button = match s("button") {
@ -222,14 +272,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
if req.method == "ui.drag" {
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).max(1);
for i in 1..=steps {
let t = i as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
}
}
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
for _ in 1..count {
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers });
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
@ -238,6 +286,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
ok(Value::Null)
}
"ui.wheel" => {
if let Err(e) = reserve_synthetic(app, 2) {
return err(e);
}
// Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways
// (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them.
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
@ -250,6 +301,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
ok(Value::Null)
}
"ui.text" => {
if let Err(e) = reserve_synthetic(app, 1) {
return err(e);
}
app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string()));
ctx.request_repaint();
ok(Value::Null)
@ -410,6 +464,126 @@ mod tests {
use super::{ControlRequest, Outcome, handle};
use crate::{Services, VectorcraftApp};
fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value {
let (req, _) = ControlRequest::new(method, params);
let Outcome::Done(r) = handle(app, &egui::Context::default(), &req) else { panic!("expected immediate reply") };
r
}
#[test]
fn control_input_rejects_excessive_counts_before_mutation() {
// Only modest over-limit values on the baseline: never exercise an unbounded loop.
for (method, params) in
[("ui.click", json!({"count": 17})), ("ui.drag", json!({"steps": 257})), ("ui.drag", json!({"steps": 2, "count": 17}))]
{
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.synthetic.push(egui::Event::Text("already queued".into()));
let before = format!("{:?}", app.synthetic);
let r = input_request(&mut app, method, params);
assert_eq!(r["ok"], false, "oversized input must be rejected: {r}");
assert_eq!(format!("{:?}", app.synthetic), before, "rejection must preserve the entire queue");
}
}
#[test]
fn control_input_respects_existing_queue_budget() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1023];
let before = format!("{:?}", app.synthetic);
let r = input_request(&mut app, "ui.click", json!({}));
assert_eq!(r["ok"], false, "a whole click must fit before any part is appended");
assert_eq!(format!("{:?}", app.synthetic), before);
}
#[test]
fn control_input_yields_control_channel_after_frame_budget() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
let mut replies = vec![];
for _ in 0..65 {
let (req, reply) = ControlRequest::new("ui.move", json!({"x": 1, "y": 2}));
tx.send(req).unwrap();
replies.push(reply);
}
app.drain_control(&egui::Context::default());
assert_eq!(replies.iter().filter(|r| r.try_recv().is_ok()).count(), 64);
assert_eq!(app.synthetic.len(), 64);
app.drain_control(&egui::Context::default());
assert_eq!(replies.last().unwrap().try_recv().unwrap()["ok"], true);
assert_eq!(app.synthetic.len(), 65);
}
#[test]
fn control_input_rejects_invalid_and_extreme_counts() {
for value in [json!(u64::MAX), json!(-1), json!(1.5), json!("2"), Value::Null] {
for (method, key) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.synthetic.push(egui::Event::Text("pending".into()));
let before = format!("{:?}", app.synthetic);
let mut params = json!({});
params[key] = value.clone();
let r = input_request(&mut app, method, params);
assert_eq!(r["ok"], false, "{method}/{key}: {r}");
assert!(r["error"].as_str().unwrap().contains(key));
assert_eq!(format!("{:?}", app.synthetic), before);
}
}
}
#[test]
fn control_input_accepts_boundary_gestures_and_preserves_event_order() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
let r = input_request(
&mut app,
"ui.drag",
json!({"x": 2, "y": 3, "toX": 20, "toY": 30,
"steps": 256, "count": 16, "button": "right", "shift": true}),
);
assert_eq!(r["ok"], true);
assert_eq!(app.synthetic.len(), 289);
assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(2.0, 3.0)));
assert!(
matches!(app.synthetic.get(1), Some(egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Secondary, modifiers, .. }) if modifiers.shift)
);
assert!(matches!(app.synthetic.get(257), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(20.0, 30.0)));
assert!(matches!(app.synthetic.get(258), Some(egui::Event::PointerButton { pressed: false, .. })));
assert!(matches!(app.synthetic.last(), Some(egui::Event::PointerButton { pressed: false, pos, .. }) if *pos == egui::pos2(20.0, 30.0)));
for (method, params, events) in [
("ui.click", json!({}), 3),
("ui.click", json!({"count": 2}), 5),
("ui.drag", json!({}), 11),
("ui.drag", json!({"steps": 0, "count": 0}), 4),
] {
app.synthetic.clear();
assert_eq!(input_request(&mut app, method, params)["ok"], true);
assert_eq!(app.synthetic.len(), events);
}
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(0.0, 0.0)); 1021];
assert_eq!(input_request(&mut app, "ui.click", json!({}))["ok"], true);
assert_eq!(app.synthetic.len(), 1024, "the exact queue boundary is permitted");
}
#[test]
fn control_input_budget_covers_every_synthetic_method() {
for (method, params) in [
("ui.key", json!({"key": "Enter", "text": "x"})),
("ui.move", json!({})),
("ui.wheel", json!({"dy": 1})),
("ui.text", json!({"text": "hello"})),
("ui.click", json!({})),
("ui.drag", json!({"steps": 1})),
] {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.synthetic = vec![egui::Event::PointerMoved(egui::pos2(1.0, 2.0)); 1024];
let before = format!("{:?}", app.synthetic);
let r = input_request(&mut app, method, params);
assert_eq!(r["ok"], false, "{method}: {r}");
assert_eq!(format!("{:?}", app.synthetic), before);
app.synthetic.clear();
assert_eq!(input_request(&mut app, method, json!({"key": "Enter", "steps": 1}))["ok"], true, "queue space becomes usable after draining");
}
}
/// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked
/// for, at the scale asked for; the first without one.
#[test]

View File

@ -1188,7 +1188,9 @@ pub fn message_literals() -> std::collections::BTreeSet<String> {
if name != "tests" {
stack.push(path);
}
} else if name.ends_with(".rs") && !name.starts_with("tests") {
} else if name.ends_with(".rs") && !name.starts_with("tests") && !(d == crates.join("ui-egui").join("src") && name == "control.rs") {
// Control replies are English protocol data, never status-bar messages
// (see i18n's module contract). Engine messages are scanned in their own crates.
let text = std::fs::read_to_string(&path).unwrap_or_default();
let code = without_test_module(&text);
for marker in MESSAGE_MARKERS {

View File

@ -759,7 +759,10 @@ impl VectorcraftApp {
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let mut handled = 0;
while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME {
let Ok(req) = rx.try_recv() else { break };
handled += 1;
let reply = req.reply.clone();
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
let roots = self.automation_roots.clone();
@ -783,6 +786,10 @@ impl VectorcraftApp {
}
}
}
if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME {
// Leave later requests on the channel and schedule another frame to serve them.
ctx.request_repaint();
}
self.control_rx = Some(rx);
}

View File

@ -1,6 +1,6 @@
# Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (synthetic control-input budgets, #1709; desktop printing uses craft-print; Windows 7 fallback retained) · **Target:** Adobe Illustrator 2026 (30.x)
How VectorCraft is built today. The design rationale lives in the local, gitignored `plan/architecture.md`;
this file describes what is committed. Day-to-day rules (clean-room, never crash, quality gates) are in
@ -62,7 +62,9 @@ its target excludes the shared backend because Windows.Data.Pdf is unavailable t
## Agent control
- **Control channel:** JSON lines on a local TCP port (`--control 7979`), with real egui pointer and keyboard
injection and screenshots ([control-protocol.md](control-protocol.md)).
injection and screenshots ([control-protocol.md](control-protocol.md)). Synthetic gestures are validated
before queue mutation; the pending input queue is capped, and control requests yield after a bounded
batch per frame (#1709).
- **MCP:** `vectorcraft-cli mcp`, attached to a running app or headless ([mcp.md](mcp.md)).
- **Sandboxing:** `--automation-read-root` / `--automation-write-root` confine what agents read and write.
@ -76,6 +78,7 @@ saves copies in the background. See [development.md](development.md#robustness-v
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
| 2026-10-12 | minor | Desktop PDF spooler shared through craft-print; exact physical sizes, composed copies and Windows 7 fallback retained. Scores and remaining gaps unchanged. |
| 2026-10-10 | minor | Added the `three-d` crate |
| 2026-10-10 | major | First version, from the workspace, the layering table and the existing docs |

View File

@ -1,6 +1,6 @@
# Control protocol
> **Last updated:** 2026-10-13 · **Change:** bounded transport waits (#1726).
> **Last updated:** 2026-10-13 · **Change:** synthetic input budgets (#1709); bounded transport waits (#1726).
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). The port must be a number
from 1 to 65535 (`--control=<port>` works too), given once; otherwise the app prints an error and exits
@ -29,6 +29,20 @@ persistent clients can send further requests within these bounds, or reconnect a
These transport budgets are separate from the existing 60-second command-processing wait.
Transport: `apps/vectorcraft/src/control_server.rs`.
## Synthetic input budgets
`ui.drag` accepts at most **256** `steps`; `ui.click` and `ui.drag` accept at most **16** `count`.
They must be non-negative JSON integers. Omitted values keep their defaults (8 steps, one click);
explicit zero still acts as one, as before. Oversized or malformed values return an error before
any move, press or release is queued.
All synthetic methods (`ui.key`, `ui.text`, `ui.move`, `ui.click`, `ui.drag`, `ui.wheel`) share a
**1,024-event** pending-queue limit. The whole request must fit; rejection preserves existing input
and does not append a partial gesture. Clients can retry once earlier input has drained. At most
**64 control requests** run per frame; later requests remain on the channel and another frame is
scheduled. These limits cover synthetic input admission, separately from socket transport and
command-processing timeouts.
## Confining file access
```sh
@ -73,6 +87,8 @@ the two can still win that race.
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool` |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
| `ui.move` | `{x, y}` | screen-point pointer move |
| `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above |
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
@ -802,6 +818,12 @@ resizes the root divider (`false`/`true` path entries select first/second childr
The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale
split paths return an error without changing the workspace.
## Synthetic input hardening checklist
- [x] Validate counts and reserve the whole gesture before queue mutation.
- [x] Bound pending events across all synthetic methods, preserving rejected requests' prior queue.
- [x] Yield after a finite control-request batch per frame; retain later requests and schedule another frame.
## Transport hardening checklist
- [x] Bound idle, partial-line and reply-write waits without renewing a partial-line deadline per byte.
@ -812,4 +834,5 @@ split paths return an error without changing the workspace.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). |
| 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). |

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long