Skip to main content

brep_app/automation/
queue.rs

1//! The command queue: the channel between a host and the app.
2//!
3//! A host calls [`AutomationQueue::submit`] with an [`Envelope`] and gets a
4//! receiver for the [`Reply`]. The app drains the queue at three fixed points
5//! in the frame (§4.4): [`drain_input`](AutomationQueue::drain_input) in
6//! `raw_input_hook`, then [`drain_app`](AutomationQueue::drain_app) for
7//! `Phase::Mutate` at the top of `ui` and for `Phase::Read` at the bottom. A
8//! command waits in the queue until its phase comes round.
9//!
10//! A desktop window is not always drawn: a Wayland compositor sends no frame
11//! callbacks to a window on a hidden workspace, and winit then withholds
12//! `RedrawRequested` indefinitely. So no command waits for one. A submit calls
13//! the [waker](AutomationQueue::set_waker), which wakes the desktop event
14//! loop independently of egui's repaint bookkeeping, and the loop dispatches
15//! the queue's head with [`drain_frameless`](AutomationQueue::drain_frameless)
16//! (`desktop_loop`). What a command needs first is its [`Need`]: app state
17//! (dispatched at once), a current layout (dispatched at once while the last
18//! pass is current, else after one), or a pass of its own (input egui must
19//! apply, a capture of what a pass draws). For the last two the loop runs an
20//! egui pass itself, on demand, without presenting it
21//! ([`needs_pass`](AutomationQueue::needs_pass)); the pass drains the queue
22//! exactly as a drawn frame does, so the queue's order is kept.
23use crate::automation::command::{lookup, Ctx, Envelope, Handler, Notice, NoticeKind, Outcome, Phase, Reply};
24use crate::automation::pointer::Pointer;
25use std::collections::VecDeque;
26use std::sync::mpsc::{channel, Receiver, Sender};
27use std::sync::{Arc, Mutex};
28
29pub struct Pending {
30    pub envelope: Envelope,
31    pub reply: Sender<Reply>,
32    deadline: Option<std::time::Instant>,
33}
34
35impl Pending {
36    fn expired(&self, frame: u64) -> bool {
37        if self.deadline.is_some_and(|d| std::time::Instant::now() >= d) {
38            let _ = self.reply.send(Reply::err(self.envelope.id, frame,
39                "host_deadline_expired: command expired before dispatch; no effects", vec![]));
40            true
41        } else { false }
42    }
43}
44
45struct Awaiting {
46    token: u64,
47    region: crate::automation::cmd_capture::Region,
48    document: Option<String>,
49    pending: Pending,
50}
51
52struct Polling {
53    pending: Pending,
54    phase: Phase,
55    poller: crate::automation::command::Poller,
56    /// The document handle the command was bound to by its context: the
57    /// eventual reply is tagged with that document's context, as an
58    /// immediate one is.
59    bound: Option<String>,
60}
61
62#[derive(Default)]
63struct Inner {
64    pending: VecDeque<Pending>,
65    awaiting: Vec<Awaiting>,
66    polling: Vec<Polling>,
67    notices: Vec<Notice>,
68    /// How many of `notices` have already ridden out on a command REPLY.
69    /// See [`Inner::reply_notices`].
70    notices_replied: usize,
71    pointer: Pointer,
72    next_token: u64,
73    frame: u64,
74    poisoned: Option<String>,
75    /// The document the last drawn frame showed: `(handle, committed revision)`
76    /// ([`AutomationQueue::publish_view`]), which a capture landing now is of.
77    view: Option<(String, u64)>,
78    /// When the last pass (drawn or on demand) published its layout.
79    layout_at: Option<web_time::Instant>,
80    /// Something may have changed the layout since that pass: a command that
81    /// writes, work that finished, a resize.
82    layout_dirty: bool,
83    /// The pass now running applied input: what the input changed is laid
84    /// out by the pass after it (egui draws once more after input), so this
85    /// pass's layout is not the current one for long.
86    input_this_pass: bool,
87}
88
89impl Inner {
90    /// The last pass's layout still stands ([`Need::Layout`]).
91    fn layout_current(&self) -> bool {
92        !self.layout_dirty && self.layout_at.is_some_and(|at| at.elapsed() < LAYOUT_FRESH)
93    }
94
95    /// What the queue's head needs, if there is one.
96    fn front_need(&self) -> Option<Need> {
97        let p = self.pending.front()?;
98        Some(lookup(&p.envelope.cmd).map_or(Need::State, |spec| need(spec, &p.envelope.args)))
99    }
100
101    /// The head can be dispatched now, without a pass.
102    fn front_dispatchable(&self) -> bool {
103        match self.front_need() {
104            Some(Need::State) => true,
105            Some(Need::Layout) => self.layout_current(),
106            _ => false,
107        }
108    }
109}
110
111/// Most notices kept. The app pushes one per toast whether or not a host is
112/// listening, so an ordinary session (the `automation` feature is on by
113/// default) must not grow a buffer for a reader that never arrives; the oldest
114/// go first, like the toast overlay's own cap.
115const MAX_NOTICES: usize = 256;
116
117impl Inner {
118    /// The notices a command REPLY carries: the ones that have not gone out on
119    /// an earlier reply.
120    ///
121    /// Two readers share this one buffer, and each must see every notice once.
122    /// A reply piggy-backs the new ones so an agent reads a refusal in the
123    /// answer to the call that caused it; the `notices` COMMAND drains the whole
124    /// buffer on its own cursor, so "every notice since the last drain" stays
125    /// true no matter how many other calls went by in between. Taking the buffer
126    /// for the replies — which is what this used to do — made `notices` report
127    /// whatever happened to arrive after the last call, which for a script that
128    /// waits before reading is nothing at all.
129    fn reply_notices(&mut self) -> Vec<Notice> {
130        let from = self.notices_replied.min(self.notices.len());
131        self.notices_replied = self.notices.len();
132        self.notices[from..].to_vec()
133    }
134}
135
136/// Wakes the thread that owns the app when work is queued.
137pub type Waker = Arc<dyn Fn() + Send + Sync>;
138
139/// What a command needs before it can be dispatched outside a drawn pass.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Need {
142    /// Nothing: it reads or writes app state, which a pass has no part in.
143    State,
144    /// A current layout: what only a pass publishes (widget rects, the
145    /// viewport rect, focus, the dock's rendered panes). Dispatched at once
146    /// while the last pass is current ([`LAYOUT_FRESH`], nothing changed
147    /// since), else after one.
148    Layout,
149    /// A pass of its own: input egui must apply in a pass, or a capture of
150    /// what a pass draws.
151    Pass,
152}
153
154/// App commands that need a pass of their own: the captures (the image IS a
155/// pass), the presentation loan a capture series runs under, and `ping`, which
156/// answers from the next pass (the composite tools step the app with it).
157pub const PASS_BOUND: &[&str] = &["screenshot", "illustration_presentation", "ping"];
158
159/// App commands that read the layout a pass publishes. `state_get` and
160/// `state_schema` are here only for a name that is not a plain-state blob
161/// (`automation::state_blobs`): those are built from the app at dispatch.
162pub const LAYOUT_BOUND: &[&str] = &["hit_rects", "state_list", "state_get", "state_schema", "locate", "pick"];
163
164/// How long the last pass's layout stands for a layout read when nothing has
165/// changed since: one frame at 60 Hz, the age a drawn window's layout has.
166/// Older than that, time alone may have moved it (a toast expiring, the
167/// working indicator appearing), so the read gets a pass of its own.
168pub const LAYOUT_FRESH: std::time::Duration = std::time::Duration::from_millis(16);
169
170/// What `spec` called with `args` needs ([`Need`]).
171pub fn need(spec: &crate::automation::command::CommandSpec, args: &serde_json::Value) -> Need {
172    match spec.handler {
173        // A read-only input command (`pointer_state`, which `session_info`
174        // issues) emits no events and only reads the virtual pointer; every
175        // input queued before it has already been applied to that pointer.
176        Handler::Input(_) if spec.phase == Phase::Input && spec.annotations.read_only => Need::State,
177        Handler::Input(_) => Need::Pass,
178        Handler::App(_) if spec.phase == Phase::Input || PASS_BOUND.contains(&spec.name) => Need::Pass,
179        Handler::App(_) if matches!(spec.name, "state_get" | "state_schema") => {
180            let names = args.get("names").and_then(serde_json::Value::as_array);
181            let state = names.is_some_and(|names| {
182                names.iter().all(|n| n.as_str().is_some_and(crate::automation::state_blobs::is_state_blob))
183            });
184            if state { Need::State } else { Need::Layout }
185        }
186        Handler::App(_) if LAYOUT_BOUND.contains(&spec.name) => Need::Layout,
187        Handler::App(_) => Need::State,
188    }
189}
190
191/// What became of a command whose host stopped waiting for it
192/// ([`AutomationQueue::withdraw`]).
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Withdrawn {
195    /// It was still queued and has been removed: it never ran.
196    NotDispatched,
197    /// A handler accepted it (it is capturing or polling), or it already
198    /// answered: it may have had effects.
199    Accepted,
200}
201
202#[derive(Default)]
203pub struct AutomationQueue {
204    inner: Mutex<Inner>,
205    ctx: Mutex<Option<egui::Context>>,
206    waker: Mutex<Option<Waker>>,
207}
208
209impl AutomationQueue {
210    pub fn new() -> Arc<Self> {
211        Arc::new(Self::default())
212    }
213
214    /// Give the queue the egui context so a submit can wake an idle frame loop.
215    pub fn attach(&self, ctx: &egui::Context) {
216        *self.ctx.lock().unwrap() = Some(ctx.clone());
217        if self.has_pending() { ctx.request_repaint(); }
218    }
219
220    /// Install the app thread's waker (the desktop event loop's proxy). Work
221    /// queued before it was installed is not stranded: the waker fires at once.
222    pub fn set_waker(&self, waker: Waker) {
223        *self.waker.lock().unwrap() = Some(waker.clone());
224        if self.has_pending() { waker(); }
225    }
226
227    fn wake(&self) {
228        let waker = self.waker.lock().unwrap().clone();
229        if let Some(waker) = waker { waker(); }
230        if let Some(ctx) = self.ctx.lock().unwrap().as_ref() {
231            ctx.request_repaint();
232        }
233    }
234
235    /// Enqueue a command. The reply arrives on the returned receiver once the
236    /// app has reached the command's phase (or at once, for an unknown command
237    /// or a poisoned session).
238    pub fn submit(&self, envelope: Envelope) -> Receiver<Reply> {
239        self.submit_before(envelope, None)
240    }
241
242    /// A deadline applies before dispatch, never to work already accepted by a handler.
243    pub fn submit_before(&self, envelope: Envelope, deadline: Option<std::time::Instant>) -> Receiver<Reply> {
244        let (tx, rx) = channel();
245        let mut inner = self.inner.lock().unwrap();
246        if let Some(why) = &inner.poisoned {
247            let _ = tx.send(Reply::err(envelope.id, inner.frame, format!("session poisoned: {why}"), vec![]));
248            return rx;
249        }
250        if lookup(&envelope.cmd).is_none() {
251            let _ = tx.send(Reply::err(envelope.id, inner.frame, format!("unknown command `{}`", envelope.cmd), vec![]));
252            return rx;
253        }
254        inner.pending.push_back(Pending { envelope, reply: tx, deadline });
255        // Notify only after the command is visible to the app thread: a wake
256        // that arrives first finds it queued.
257        drop(inner);
258        self.wake();
259        rx
260    }
261
262    /// The host stopped waiting for command `id`. A command still queued is
263    /// removed and answered as never dispatched; anything else was accepted.
264    pub fn withdraw(&self, id: u64) -> Withdrawn {
265        let mut inner = self.inner.lock().unwrap();
266        let frame = inner.frame;
267        if let Some(index) = inner.pending.iter().position(|p| p.envelope.id == id) {
268            let p = inner.pending.remove(index).expect("index from position");
269            let _ = p.reply.send(Reply::err(id, frame, "host_deadline_expired: command expired before dispatch; no effects", vec![]));
270            return Withdrawn::NotDispatched;
271        }
272        Withdrawn::Accepted
273    }
274
275    /// Whether [`drain_frameless`](Self::drain_frameless) has anything to do:
276    /// a head it can dispatch without a pass, or polled work.
277    pub fn has_frameless_work(&self) -> bool {
278        let inner = self.inner.lock().unwrap();
279        !inner.polling.is_empty() || inner.front_dispatchable()
280    }
281
282    /// Whether the queue's head waits for a pass: input, a capture, or a
283    /// layout read with no current layout. The desktop loop runs one on demand.
284    pub fn needs_pass(&self) -> bool {
285        let inner = self.inner.lock().unwrap();
286        inner.front_need().is_some() && !inner.front_dispatchable()
287    }
288
289    /// The layout may have changed outside a command (a resize, finished
290    /// background work): the next layout read gets a pass.
291    pub fn mark_layout_dirty(&self) {
292        self.inner.lock().unwrap().layout_dirty = true;
293    }
294
295    /// A pass (drawn or on demand) has just published its layout and answered
296    /// its reads (`BrepApp::drain_reads`).
297    pub fn note_pass(&self) {
298        let mut inner = self.inner.lock().unwrap();
299        inner.layout_at = Some(web_time::Instant::now());
300        inner.layout_dirty = std::mem::take(&mut inner.input_this_pass);
301    }
302
303    /// Record something the session should know about — a toast the user would
304    /// have seen, a refusal, a panic. The app's own toast lane feeds this every
305    /// frame (`BrepApp::ui`), so `notices` reports what was on screen.
306    pub fn push_notice(&self, kind: NoticeKind, text: impl Into<String>) {
307        let mut inner = self.inner.lock().unwrap();
308        let frame = inner.frame;
309        inner.notices.push(Notice { kind, frame, text: text.into() });
310        let overflow = inner.notices.len().saturating_sub(MAX_NOTICES);
311        if overflow > 0 {
312            inner.notices.drain(0..overflow);
313            inner.notices_replied = inner.notices_replied.saturating_sub(overflow);
314        }
315    }
316
317    /// Drain for the `notices` COMMAND: everything since the last drain. The
318    /// reply cursor goes back to the start with them — the buffer is empty, so
319    /// the reply this drain rides out on carries nothing twice.
320    pub fn take_notices(&self) -> Vec<Notice> {
321        let mut inner = self.inner.lock().unwrap();
322        inner.notices_replied = 0;
323        std::mem::take(&mut inner.notices)
324    }
325
326    pub fn pointer(&self) -> Pointer {
327        self.inner.lock().unwrap().pointer.clone()
328    }
329
330    pub fn frame(&self) -> u64 {
331        self.inner.lock().unwrap().frame
332    }
333
334    pub fn has_pending(&self) -> bool {
335        let inner = self.inner.lock().unwrap();
336        !inner.pending.is_empty() || !inner.awaiting.is_empty() || !inner.polling.is_empty()
337    }
338
339    /// Whether a command has started work the queue is polling (see
340    /// `Outcome::Poll`): a host keeps stepping frames for it past its usual
341    /// frame budget, since the work is advancing, not stuck.
342    pub fn is_polling(&self, id: u64) -> bool {
343        self.inner.lock().unwrap().polling.iter().any(|p| p.pending.envelope.id == id)
344    }
345
346    /// Mark the session unusable (a panic unwound through the app). Every
347    /// pending and future command is answered with the reason.
348    pub fn poison(&self, why: impl Into<String>) {
349        let why = why.into();
350        let mut inner = self.inner.lock().unwrap();
351        inner.poisoned = Some(why.clone());
352        let frame = inner.frame;
353        for p in inner.pending.drain(..) {
354            let _ = p.reply.send(Reply::err(p.envelope.id, frame, format!("session poisoned: {why}"), vec![]));
355        }
356        for a in inner.awaiting.drain(..) {
357            let _ = a.pending.reply.send(Reply::err(a.pending.envelope.id, frame, format!("session poisoned: {why}"), vec![]));
358        }
359    }
360
361    pub fn is_poisoned(&self) -> bool {
362        self.inner.lock().unwrap().poisoned.is_some()
363    }
364
365    /// Phase 1 of the frame: apply input commands as `egui::Event`s and
366    /// complete captures whose `Event::Screenshot` arrived. `view` is the
367    /// viewport rect of the last frame (for `region: viewport` crops).
368    /// `ctx` is the app's: the pointer reads egui's clock from it (the
369    /// headless host leaves `raw.time` unset, and egui adds `predicted_dt`),
370    /// and a `click_gap` resets its click history.
371    pub fn drain_input(&self, raw: &mut egui::RawInput, frame: u64, ppp: f32, view: Option<egui::Rect>, ctx: &egui::Context) {
372        let mut inner = self.inner.lock().unwrap();
373        inner.frame = frame;
374        inner.pointer.now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time) + f64::from(raw.predicted_dt));
375
376        // Screenshots that landed in this frame's input.
377        if !inner.awaiting.is_empty() {
378            let mut done = Vec::new();
379            for event in &raw.events {
380                if let egui::Event::Screenshot { user_data, image, .. } = event {
381                    let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied();
382                    if let Some(token) = token {
383                        if let Some(pos) = inner.awaiting.iter().position(|a| a.token == token) {
384                            let a = inner.awaiting.remove(pos);
385                            done.push((a, image.clone()));
386                        }
387                    }
388                }
389            }
390            for (a, image) in done {
391                let notices = inner.reply_notices();
392                let shown = inner.view.clone();
393                // The image is of the frame drawn last: it shows `shown`. A
394                // capture asked for while another document was visible is
395                // refused rather than handed back as that document's picture.
396                let switched = match (&a.document, &shown) {
397                    (Some(asked), Some((drawn, _))) => asked != drawn,
398                    _ => false,
399                };
400                let result = if switched {
401                    Err("document_not_active: the visible document changed between the request and the capture".to_string())
402                } else {
403                    crate::automation::cmd_capture::encode_capture(&image, ppp, view, &a.region).map(|(mut json, png)| {
404                        if let Some((handle, revision)) = &shown {
405                            json["document"] = serde_json::json!({ "documentId": handle, "revision": revision.to_string() });
406                        }
407                        (json, png)
408                    })
409                };
410                let reply = match result {
411                    Ok((json, png)) => {
412                        let mut r = Reply::ok(a.pending.envelope.id, frame, json, notices);
413                        r.blob = Some(png);
414                        r
415                    }
416                    Err(e) => Reply::err(a.pending.envelope.id, frame, e, notices),
417                };
418                let _ = a.pending.reply.send(reply);
419            }
420        }
421
422        // Input-phase commands, in order.
423        let mut keep = VecDeque::new();
424        let mut events = Vec::new();
425        while let Some(p) = inner.pending.pop_front() {
426            if p.expired(frame) { continue; }
427            let Some(spec) = lookup(&p.envelope.cmd) else { continue };
428            match (&spec.handler, spec.phase) {
429                (Handler::Input(f), Phase::Input) => {
430                    let result = f(&mut inner.pointer, p.envelope.args.clone(), &mut events);
431                    let notices = inner.reply_notices();
432                    let reply = match result {
433                        Ok(v) => Reply::ok(p.envelope.id, frame, v, notices),
434                        Err(e) => Reply::err(p.envelope.id, frame, e, notices),
435                    };
436                    let _ = p.reply.send(reply);
437                }
438                _ => keep.push_back(p),
439            }
440        }
441        inner.pending = keep;
442        // A `click_gap` this frame: a fresh `PointerState` before the pass
443        // begins, so egui has no click to count the next one against. The
444        // pass builds on the state this replaces (`InputState::begin_pass`).
445        if std::mem::take(&mut inner.pointer.reset_clicks) {
446            ctx.input_mut(|i| i.pointer = egui::PointerState::default());
447        }
448        raw.modifiers = inner.pointer.modifiers.egui();
449        inner.input_this_pass = !events.is_empty();
450        raw.events.extend(events);
451    }
452
453    /// Phases 2 and 3 of the frame. Runs every pending command registered for
454    /// `phase`, in order.
455    pub fn drain_app(&self, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) {
456        let batch: Vec<Pending> = {
457            let mut inner = self.inner.lock().unwrap();
458            inner.frame = frame;
459            let mut keep = VecDeque::new();
460            let mut batch = Vec::new();
461            while let Some(p) = inner.pending.pop_front() {
462                match lookup(&p.envelope.cmd) {
463                    Some(spec) if spec.phase == phase => batch.push(p),
464                    _ => keep.push_back(p),
465                }
466            }
467            inner.pending = keep;
468            batch
469        };
470        for p in batch {
471            self.dispatch(p, phase, ctx, frame);
472        }
473        if self.poll(phase, ctx, frame) {
474            ctx.egui.request_repaint();
475        }
476    }
477
478    /// Dispatch without a pass (the desktop loop's service step): the
479    /// queue's head, in FIFO order, up to the first command that needs a pass
480    /// ([`needs_pass`](Self::needs_pass), which the loop then runs); then one
481    /// poll of the work they started. Returns whether polled work is still
482    /// outstanding. Runs on the app's thread, so mutations stay sequential
483    /// with the UI's own writers.
484    pub fn drain_frameless(&self, ctx: &mut Ctx<'_>) -> bool {
485        let frame = self.frame();
486        loop {
487            let next = {
488                let mut inner = self.inner.lock().unwrap();
489                let ready = inner.front_dispatchable();
490                match inner.pending.front().and_then(|p| lookup(&p.envelope.cmd)) {
491                    Some(spec) if ready => {
492                        let p = inner.pending.pop_front().expect("front exists");
493                        if let Handler::Input(f) = &spec.handler {
494                            // A read-only input command: answered here, as the
495                            // frame's input phase would answer it.
496                            if !p.expired(frame) {
497                                let mut events = Vec::new();
498                                let result = f(&mut inner.pointer, p.envelope.args.clone(), &mut events);
499                                debug_assert!(events.is_empty(), "{} is read-only but emitted input", spec.name);
500                                let notices = inner.reply_notices();
501                                let reply = match result {
502                                    Ok(v) => Reply::ok(p.envelope.id, frame, v, notices),
503                                    Err(e) => Reply::err(p.envelope.id, frame, e, notices),
504                                };
505                                let _ = p.reply.send(reply);
506                            }
507                            continue;
508                        }
509                        Some((p, spec.phase))
510                    }
511                    _ => None,
512                }
513            };
514            let Some((p, phase)) = next else { break };
515            self.dispatch(p, phase, ctx, frame);
516        }
517        let mutate = self.poll(Phase::Mutate, ctx, frame);
518        let read = self.poll(Phase::Read, ctx, frame);
519        mutate || read
520    }
521
522    /// Run one command's handler and answer (or park) it.
523    fn dispatch(&self, p: Pending, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) {
524        if p.expired(frame) { return; }
525        let spec = lookup(&p.envelope.cmd).expect("checked at submit");
526        let Handler::App(f) = &spec.handler else { return };
527        if !spec.annotations.read_only {
528            // What it writes may move the layout: the next layout read waits
529            // for a pass.
530            self.mark_layout_dirty();
531        }
532        // A document context is checked HERE, on the frame thread, right
533        // before the handler: the only point atomic with the UI's own
534        // writers. It is stripped from the handler's arguments.
535        let (args, bound) = match bind_context(&ctx.app.docs, phase, &p.envelope.cmd, p.envelope.args.clone()) {
536            Ok(bound) => bound,
537            Err(e) => {
538                let mut inner = self.inner.lock().unwrap();
539                let notices = inner.reply_notices();
540                let _ = p.reply.send(Reply::err(p.envelope.id, frame, e, notices));
541                return;
542            }
543        };
544        let before = refused_counts(&ctx.app.docs);
545        let route = bound.as_ref().and_then(|b| b.route).map(|index| ctx.app.docs.route_begin(index));
546        let result = f(ctx, args);
547        if let Some(visible) = route {
548            ctx.app.docs.route_end(visible);
549        }
550        let result = refused_by_lock(&ctx.app.docs, &before, result);
551        let handle = bound.map(|b| b.handle);
552        let result = match &handle {
553            Some(handle) => tag_context(&ctx.app.docs, handle, result),
554            None => result,
555        };
556        let mut inner = self.inner.lock().unwrap();
557        answer(&mut inner, p, phase, frame, result, handle);
558    }
559
560    /// Work started earlier: one poll each, in this phase. Returns whether
561    /// any is still outstanding.
562    fn poll(&self, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) -> bool {
563        let polling: Vec<Polling> = {
564            let mut inner = self.inner.lock().unwrap();
565            let (mine, rest) = std::mem::take(&mut inner.polling).into_iter().partition(|p| p.phase == phase);
566            inner.polling = rest;
567            mine
568        };
569        let mut still = false;
570        for mut entry in polling {
571            match (entry.poller)(ctx) {
572                None => {
573                    still = true;
574                    self.inner.lock().unwrap().polling.push(entry);
575                }
576                Some(result) => {
577                    // Finished work may have changed what a pass would show.
578                    self.mark_layout_dirty();
579                    let result = match &entry.bound {
580                        Some(handle) => tag_context(&ctx.app.docs, handle, result),
581                        None => result,
582                    };
583                    let mut inner = self.inner.lock().unwrap();
584                    answer(&mut inner, entry.pending, phase, frame, result, entry.bound);
585                    still |= !inner.polling.is_empty();
586                }
587            }
588        }
589        still
590    }
591
592    /// Record which document the frame just drawn shows (called at the end of
593    /// every app frame), so a capture is bound to what it pictures.
594    pub fn publish_view(&self, handle: &str, revision: u64) {
595        let mut inner = self.inner.lock().unwrap();
596        if inner.view.as_ref().is_none_or(|(h, r)| h != handle || *r != revision) {
597            inner.view = Some((handle.to_string(), revision));
598        }
599    }
600
601    /// Answer every capture still waiting for its image with `error` (an
602    /// on-demand pass could not draw it).
603    pub fn fail_captures(&self, error: &str) {
604        let mut inner = self.inner.lock().unwrap();
605        let frame = inner.frame;
606        for a in std::mem::take(&mut inner.awaiting) {
607            let notices = inner.reply_notices();
608            let _ = a.pending.reply.send(Reply::err(a.pending.envelope.id, frame, error.to_string(), notices));
609        }
610    }
611
612    /// A fresh token for a screenshot request.
613    pub fn next_token(&self) -> u64 {
614        let mut inner = self.inner.lock().unwrap();
615        inner.next_token += 1;
616        inner.next_token
617    }
618}
619
620/// The root `context` key of a command's arguments: which document the
621/// command is for and the committed revision its writer last saw
622/// (contract §3 `DocumentContext`). Optional — a command without one
623/// acts on the visible document as it always has.
624#[derive(serde::Deserialize)]
625#[serde(deny_unknown_fields, rename_all = "camelCase")]
626struct DocumentContext {
627    document_id: String,
628    expected_revision: String,
629}
630
631/// Commands that read or write only a document's ENGINE — its history, run,
632/// metadata, model and exports — and so may run on a background tab: the
633/// queue routes them there for the one handler call (`Documents::route_begin`)
634/// without touching what the user sees. Every other command acts on the
635/// visible document and refuses a background context (`document_not_active`).
636pub const ENGINE_ONLY: &[&str] = &[
637    "feature_stage",
638    "feature_stage_result",
639    "run_cancel",
640    "history_group_begin",
641    "history_group_end",
642    "feature_add_many",
643    "doc_import",
644    "doc_export",
645    "model_inspect",
646    "mass_properties",
647    "history_listing",
648    "frame_info",
649    "metadata_set",
650    "metadata_set_many",
651    "metadata_remove",
652];
653
654/// A command bound to a document by its context: the handle it named and,
655/// for an engine-only command addressed to a background tab, that tab.
656#[derive(Debug)]
657struct Bound {
658    handle: String,
659    route: Option<usize>,
660}
661
662/// Take `context` out of `args` and check it against the documents as they
663/// are now. Errors begin with the contract's stable code (`revision_conflict:
664/// …`), so a caller need not parse the prose.
665///
666/// An [`ENGINE_ONLY`] command may name a background tab and is routed there;
667/// any other command must name the visible one (`document_not_active`).
668/// While a Work part is edited in place every context refuses
669/// (`document_busy`): the command would write the hosted part, not the tab
670/// named. A Mutate-phase command requires the expected revision to be the
671/// current one; a Read-phase command only reports the current revision.
672fn bind_context(
673    docs: &crate::document::Documents,
674    phase: Phase,
675    cmd: &str,
676    mut args: serde_json::Value,
677) -> Result<(serde_json::Value, Option<Bound>), String> {
678    let Some(raw) = args.as_object_mut().and_then(|o| o.remove("context")) else {
679        return Ok((args, None));
680    };
681    // A command with no arguments still parses `{}` after the strip.
682    let context: DocumentContext = serde_json::from_value(raw)
683        .map_err(|e| format!("invalid_arguments: context must be {{documentId, expectedRevision}}: {e}"))?;
684    let expected = parse_revision(&context.expected_revision)?;
685    match crate::handles::parse("doc", &context.document_id) {
686        crate::handles::Parsed::Current => {}
687        crate::handles::Parsed::OtherEpoch => {
688            return Err("handle_epoch_mismatch: the document handle is from another run of the app".into())
689        }
690        crate::handles::Parsed::Malformed => return Err("invalid_arguments: documentId is not a document handle".into()),
691    }
692    let Some(index) = docs.index_of_handle(&context.document_id) else {
693        return Err("document_unknown: no open document has that handle (closed, or never opened)".into());
694    };
695    if docs.work_part_in_place() {
696        return Err("document_busy: a Work part is edited in place; a document command would write the part".into());
697    }
698    let route = if index == docs.active_index() {
699        None
700    } else if ENGINE_ONLY.contains(&cmd) {
701        Some(index)
702    } else {
703        return Err("document_not_active: this command acts on the visible document; activate the named one first".into());
704    };
705    let current = docs.get(index).map_or(0, |d| d.committed_revision());
706    if phase == Phase::Mutate && current != expected {
707        return Err(format!(
708            "revision_conflict: the document is at revision {current}, not {expected}; read it again before writing"
709        ));
710    }
711    Ok((args, Some(Bound { handle: context.document_id, route })))
712}
713
714/// A decimal unsigned 64-bit revision, canonical (no sign, no leading zero).
715fn parse_revision(text: &str) -> Result<u64, String> {
716    let canonical = text == "0" || (!text.is_empty() && !text.starts_with('0') && text.bytes().all(|b| b.is_ascii_digit()));
717    text.parse::<u64>()
718        .ok()
719        .filter(|_| canonical)
720        .ok_or_else(|| "invalid_arguments: expectedRevision must be a canonical unsigned decimal string".into())
721}
722
723/// A command that was bound to a document answers with that document's
724/// context as it stands after the handler: `{documentId, expectedRevision}` under the
725/// result's `context` key (an object result), so the writer's next
726/// expectation comes from the answer, not from a guess. A geometry run the
727/// handler started moves the revision again when it lands.
728fn tag_context(
729    docs: &crate::document::Documents,
730    handle: &str,
731    result: Result<Outcome, String>,
732) -> Result<Outcome, String> {
733    let context = docs.index_of_handle(handle).and_then(|i| docs.get(i)).map(|doc| {
734        serde_json::json!({ "documentId": handle, "expectedRevision": doc.committed_revision().to_string() })
735    });
736    let tag = |v: &mut serde_json::Value| {
737        if let (Some(object), Some(context)) = (v.as_object_mut(), context.clone()) {
738            object.insert("context".into(), context);
739        }
740    };
741    match result {
742        Ok(Outcome::Done(mut v)) => {
743            tag(&mut v);
744            Ok(Outcome::Done(v))
745        }
746        Ok(Outcome::Blob { mut json, bytes }) => {
747            tag(&mut json);
748            Ok(Outcome::Blob { json, bytes })
749        }
750        other => other,
751    }
752}
753
754/// Send (or park) one command's reply.
755fn answer(inner: &mut Inner, p: Pending, phase: Phase, frame: u64, result: Result<Outcome, String>, bound: Option<String>) {
756    match result {
757        Ok(Outcome::Done(v)) => {
758            let notices = inner.reply_notices();
759            let _ = p.reply.send(Reply::ok(p.envelope.id, frame, v, notices));
760        }
761        Ok(Outcome::Blob { json, bytes }) => {
762            let notices = inner.reply_notices();
763            let mut r = Reply::ok(p.envelope.id, frame, json, notices);
764            r.blob = Some(bytes);
765            let _ = p.reply.send(r);
766        }
767        // No reply yet — the capture answers when its image arrives, and
768        // takes the notices then (the cursor must not move here).
769        Ok(Outcome::AwaitScreenshot { token, region, document }) => {
770            inner.awaiting.push(Awaiting { token, region, document, pending: p });
771        }
772        Ok(Outcome::Poll(poller)) => inner.polling.push(Polling { pending: p, phase, poller, bound }),
773        Err(e) => {
774            let notices = inner.reply_notices();
775            let _ = p.reply.send(Reply::err(p.envelope.id, frame, e, notices));
776        }
777    }
778}
779
780/// Each open document's refused-edit count ([`crate::document::Document::access`]).
781fn refused_counts(docs: &crate::document::Documents) -> Vec<(u64, u64)> {
782    docs.iter().map(|d| (d.id(), d.engine.history.refused_user_edits())).collect()
783}
784
785/// A command that tried to change a read-only document (a PLM revision this
786/// user has not checked out, or a released one) did not change it — the
787/// document's history refused the write — so its reply is that refusal, not
788/// a success. A command that only reads, moves the camera, opens or closes a
789/// document refuses nothing and answers as it did.
790fn refused_by_lock(
791    docs: &crate::document::Documents,
792    before: &[(u64, u64)],
793    result: Result<Outcome, String>,
794) -> Result<Outcome, String> {
795    if result.is_err() {
796        return result;
797    }
798    for doc in docs.iter() {
799        let was = before.iter().find(|(id, _)| *id == doc.id()).map(|(_, n)| *n);
800        if was.is_some_and(|was| doc.engine.history.refused_user_edits() > was) {
801            if let crate::document::Access::ReadOnly { reason } = doc.access() {
802                return Err(format!("{} is read-only: {reason}", doc.title()));
803            }
804        }
805    }
806    result
807}
808