Skip to main content

xa11y_core/
input.rs

1//! Input simulation: synthesised pointer and keyboard events.
2//!
3//! Input simulation is **separate from** the accessibility action layer
4//! ([`crate::Provider`], [`crate::Element`], [`crate::Locator`]). The two
5//! mechanisms are fundamentally different:
6//!
7//! - **Accessibility actions** (`element.press()`, `locator.toggle()`) call
8//!   the platform's a11y API directly. They work without the target window
9//!   being focused or visible, are deterministic, and are the preferred way
10//!   to drive a UI.
11//! - **Input simulation** ([`InputSim`]) generates OS-level pointer/keyboard
12//!   events at the system event layer. Use it only for interactions that have
13//!   no a11y equivalent (drag-and-drop, scroll wheels, complex shortcut
14//!   sequences). Most platforms require the target window to be foregrounded
15//!   and require additional permissions (Accessibility + Input Monitoring on
16//!   macOS, Wayland portal grants on Linux, etc.).
17//!
18//! There is **no implicit bridge** between the two: an accessibility-action
19//! failure never falls back to input simulation, and [`InputSim`] never
20//! inspects or auto-resolves the a11y tree on behalf of the caller. If you
21//! want to click an element, you compute its bounds (via the a11y API) and
22//! pass them in — see [`IntoPoint`] and [`point_for`].
23//!
24//! # Layout
25//!
26//! [`InputSim`] exposes two sub-handles:
27//!
28//! - [`InputSim::mouse`] → [`Mouse`] for pointer operations (`click`, `drag`,
29//!   `scroll`, `down`/`up`).
30//! - [`InputSim::keyboard`] → [`Keyboard`] for key operations (`press`,
31//!   `chord`, `down`/`up`, `type_text`).
32//!
33//! Modifier keys (`Shift`, `Ctrl`, `Alt`, `Meta`) are regular variants of
34//! [`Key`] — there is no separate `Modifier` type. `Key::Char(c)` represents
35//! the unshifted physical key; use [`Key::Shift`] explicitly for uppercase
36//! or shifted symbols (see [`Key`] for the rationale).
37
38use std::sync::Arc;
39use std::time::Duration;
40
41use crate::element::{Element, Rect};
42use crate::error::{Error, Result};
43
44// ── Geometry ────────────────────────────────────────────────────────
45
46/// A 2D point in screen coordinates.
47///
48/// Coordinates are integer screen pixels in the platform's native coordinate
49/// space. On macOS this is points (the OS handles HiDPI scaling for input
50/// events); on Windows and Linux this is physical pixels. Origin is top-left
51/// of the primary display; negative values are valid on multi-monitor setups.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub struct Point {
54    pub x: i32,
55    pub y: i32,
56}
57
58impl Point {
59    pub const fn new(x: i32, y: i32) -> Self {
60        Self { x, y }
61    }
62}
63
64/// Where on an element to land a pointer event.
65///
66/// All anchors are computed against the element's [`Rect`] *at the time of the
67/// input call*, not at element-fetch time — but only if the caller supplies a
68/// fresh element. `InputSim` will not re-traverse the a11y tree on its own.
69#[derive(Debug, Clone, Copy, PartialEq, Default)]
70pub enum Anchor {
71    #[default]
72    Center,
73    TopLeft,
74    TopRight,
75    BottomLeft,
76    BottomRight,
77    /// Pixel offset from the element's top-left corner.
78    Offset {
79        dx: i32,
80        dy: i32,
81    },
82}
83
84/// Compute a [`Point`] inside a [`Rect`] using the given [`Anchor`].
85pub fn anchor_point(rect: &Rect, anchor: Anchor) -> Point {
86    let (x, y, w, h) = (rect.x, rect.y, rect.width as i32, rect.height as i32);
87    match anchor {
88        Anchor::Center => Point::new(x + w / 2, y + h / 2),
89        Anchor::TopLeft => Point::new(x, y),
90        Anchor::TopRight => Point::new(x + w, y),
91        Anchor::BottomLeft => Point::new(x, y + h),
92        Anchor::BottomRight => Point::new(x + w, y + h),
93        Anchor::Offset { dx, dy } => Point::new(x + dx, y + dy),
94    }
95}
96
97/// Resolve an [`Element`]'s current bounds to a screen [`Point`] using `anchor`.
98///
99/// Reads `element.bounds`. Returns [`Error::NoElementBounds`] if the element
100/// has no bounds (e.g. an off-screen or virtual node).
101///
102/// **Staleness:** `Element` is a snapshot — its bounds were captured when the
103/// caller fetched it from the provider. If the UI may have moved since then,
104/// re-fetch the element first (e.g. via [`crate::Locator`]).
105pub fn point_for(element: &Element, anchor: Anchor) -> Result<Point> {
106    let bounds = element.bounds.ok_or(Error::NoElementBounds)?;
107    Ok(anchor_point(&bounds, anchor))
108}
109
110// ── Targets ─────────────────────────────────────────────────────────
111
112/// A target that can be lowered to a screen [`Point`].
113///
114/// Implemented for:
115/// - [`Point`] and `(i32, i32)` — used as-is.
116/// - `&`[`Element`] — uses the element's `bounds` field at the call site, with
117///   [`Anchor::Center`]. For a non-default anchor, call [`point_for`] yourself
118///   and pass the resulting `Point`.
119///
120/// Not implemented for [`crate::Locator`]: the caller must explicitly resolve
121/// the locator to an `Element` first. This keeps the cost of provider traffic
122/// (and the failure mode) visible at the call site.
123pub trait IntoPoint {
124    fn into_point(self) -> Result<Point>;
125}
126
127impl IntoPoint for Point {
128    fn into_point(self) -> Result<Point> {
129        Ok(self)
130    }
131}
132
133impl IntoPoint for (i32, i32) {
134    fn into_point(self) -> Result<Point> {
135        Ok(Point::new(self.0, self.1))
136    }
137}
138
139impl IntoPoint for &Element {
140    fn into_point(self) -> Result<Point> {
141        point_for(self, Anchor::Center)
142    }
143}
144
145// ── Pointer ─────────────────────────────────────────────────────────
146
147/// A mouse button.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
149pub enum MouseButton {
150    #[default]
151    Left,
152    Right,
153    Middle,
154}
155
156/// Direction and magnitude of a scroll event, in platform "ticks" (typically
157/// one notch of a physical scroll wheel). Positive `dy` scrolls content
158/// downward (i.e. moves the viewport up); positive `dx` scrolls right.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
160pub struct ScrollDelta {
161    pub dx: i32,
162    pub dy: i32,
163}
164
165impl ScrollDelta {
166    pub const fn new(dx: i32, dy: i32) -> Self {
167        Self { dx, dy }
168    }
169
170    pub const fn vertical(dy: i32) -> Self {
171        Self { dx: 0, dy }
172    }
173
174    pub const fn horizontal(dx: i32) -> Self {
175        Self { dx, dy: 0 }
176    }
177}
178
179// ── Keyboard ────────────────────────────────────────────────────────
180
181/// A keyboard key.
182///
183/// Modifier keys (`Shift`, `Ctrl`, `Alt`, `Meta`) are regular variants of this
184/// enum — they are not a separate type. This matches the physical reality that
185/// modifiers are keys like any other, and the convention of Playwright,
186/// Puppeteer, Selenium, pyautogui, `SendInput`, and `XTest`.
187///
188/// # `Key::Char` semantics
189///
190/// `Key::Char(c)` represents **the physical key labeled with the unshifted
191/// character `c`**. It does **not** auto-synthesise `Shift`. To produce an
192/// uppercase letter or shifted symbol, hold [`Key::Shift`] explicitly:
193///
194/// ```ignore
195/// // Cmd+A (select all):
196/// keyboard.chord(Key::Char('a'), &[Key::Meta]);
197///
198/// // Uppercase 'A':
199/// keyboard.chord(Key::Char('a'), &[Key::Shift]);
200/// ```
201///
202/// For this reason, `Key::Char` **rejects ASCII uppercase letters at the API
203/// boundary** ([`Error::InvalidActionData`]). This prevents the common
204/// footgun where `chord(Key::Char('K'), &[Key::Meta])` is read as "Cmd+K"
205/// but would silently mean "Cmd+Shift+K" under auto-shift semantics.
206///
207/// To type arbitrary text (with IME support and correct case handling), use
208/// [`Keyboard::type_text`] — `Key` is for single-key presses.
209///
210/// # `Meta`
211///
212/// `Meta` is the platform's "command" modifier: Cmd on macOS, Win on Windows,
213/// Super on Linux. Backends are responsible for the platform mapping.
214#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215pub enum Key {
216    /// A printable character (lowercase, no shifted symbols). Backends
217    /// translate this to the matching physical key. See the type-level docs
218    /// for the rationale on rejecting uppercase letters.
219    Char(char),
220
221    // Modifiers (held-key form — combine with other keys via `chord`).
222    Shift,
223    Ctrl,
224    Alt,
225    Meta,
226
227    Enter,
228    Escape,
229    Backspace,
230    Tab,
231    Space,
232    Delete,
233    Insert,
234
235    ArrowUp,
236    ArrowDown,
237    ArrowLeft,
238    ArrowRight,
239
240    Home,
241    End,
242    PageUp,
243    PageDown,
244
245    /// A function key. `n` is 1-based (`F(1)` = F1).
246    F(u8),
247}
248
249impl Key {
250    /// Validate a key for use at the API boundary.
251    ///
252    /// Returns [`Error::InvalidActionData`] for `Key::Char` with an ASCII
253    /// uppercase letter — callers must lowercase and hold [`Key::Shift`]
254    /// explicitly. See the type-level docs.
255    pub(crate) fn validate(&self) -> Result<()> {
256        if let Key::Char(c) = self {
257            if c.is_ascii_uppercase() {
258                return Err(Error::InvalidActionData {
259                    message: format!(
260                        "Key::Char('{c}') is uppercase; use Key::Char('{}') \
261                         with Key::Shift held to produce an uppercase letter",
262                        c.to_ascii_lowercase()
263                    ),
264                });
265            }
266        }
267        Ok(())
268    }
269}
270
271// ── Click / drag option structs ─────────────────────────────────────
272
273/// Options for [`Mouse::click_with`].
274#[derive(Debug, Clone)]
275pub struct ClickOptions {
276    pub button: MouseButton,
277    /// Number of consecutive clicks (1 = single, 2 = double, …).
278    pub count: u32,
279    /// Keys held (pressed but not released) for the duration of the click —
280    /// typically modifier keys like [`Key::Shift`] or [`Key::Meta`].
281    pub held: Vec<Key>,
282    /// Anchor used when the target is an [`Element`]. Ignored for raw points.
283    pub anchor: Anchor,
284}
285
286impl Default for ClickOptions {
287    fn default() -> Self {
288        Self {
289            button: MouseButton::Left,
290            count: 1,
291            held: Vec::new(),
292            anchor: Anchor::Center,
293        }
294    }
295}
296
297/// Options for [`Mouse::drag_with`].
298#[derive(Debug, Clone)]
299pub struct DragOptions {
300    pub button: MouseButton,
301    /// Keys held for the duration of the drag.
302    pub held: Vec<Key>,
303    /// Total time over which the drag is performed. Backends interpolate
304    /// pointer movement across this duration.
305    pub duration: Duration,
306}
307
308impl Default for DragOptions {
309    fn default() -> Self {
310        Self {
311            button: MouseButton::Left,
312            held: Vec::new(),
313            duration: Duration::from_millis(150),
314        }
315    }
316}
317
318// ── Backend trait ───────────────────────────────────────────────────
319
320/// Platform backend trait for synthesised user input.
321///
322/// Implementors generate OS-level pointer and keyboard events. Most methods
323/// correspond to a single low-level operation; a few (marked "provided") are
324/// synthesised by default but may be overridden when a platform has a
325/// higher-fidelity primitive.
326///
327/// **This trait is intentionally separate from [`crate::Provider`].** A
328/// backend that only knows how to read the accessibility tree should not
329/// implement `InputProvider`, and vice versa. Crates may implement both for
330/// the same platform but the two surfaces never call into each other.
331///
332/// # Errors
333///
334/// Implementations should return:
335/// - [`Error::PermissionDenied`] when the OS denies the synthesis permission.
336/// - [`Error::Unsupported`] when the operation has no platform implementation
337///   (e.g. pointer warp on a session that disallows it). Do **not** silently
338///   degrade — surface the missing capability per Tenet 1.
339/// - [`Error::Platform`] for raw OS failures.
340pub trait InputProvider: Send + Sync {
341    // ── Pointer (required) ──────────────────────────────────────────
342
343    /// Move the pointer to `to` without pressing any buttons.
344    fn pointer_move(&self, to: Point) -> Result<()>;
345
346    /// Press `button` at the current pointer location (no release).
347    fn pointer_down(&self, button: MouseButton) -> Result<()>;
348
349    /// Release `button` at the current pointer location.
350    fn pointer_up(&self, button: MouseButton) -> Result<()>;
351
352    /// Click `button` at `at`, repeated `count` times. The backend is
353    /// responsible for honouring the OS double-click interval when
354    /// `count > 1` and for any platform-specific click-state bookkeeping
355    /// (e.g. `kCGMouseEventClickState` on macOS).
356    fn pointer_click(&self, at: Point, button: MouseButton, count: u32) -> Result<()>;
357
358    /// Scroll by `delta` ticks at `at`.
359    fn pointer_scroll(&self, at: Point, delta: ScrollDelta) -> Result<()>;
360
361    // ── Keyboard (required) ─────────────────────────────────────────
362
363    /// Press `key` (no release). Use [`key_up`](Self::key_up) to release.
364    ///
365    /// Modifiers are just keys: hold `Key::Shift` via `key_down(&Key::Shift)`.
366    fn key_down(&self, key: &Key) -> Result<()>;
367
368    /// Release `key`.
369    fn key_up(&self, key: &Key) -> Result<()>;
370
371    /// Type `text` as literal user input.
372    ///
373    /// Backends should prefer the OS's text-input path (with IME support)
374    /// over synthesising individual key presses where possible.
375    fn type_text(&self, text: &str) -> Result<()>;
376
377    // ── Pointer (provided, override for platform fidelity) ──────────
378
379    /// Press `button` at `from`, interpolate to `to` over `duration`, release.
380    ///
381    /// The default synthesis posts `pointer_down` → a series of `pointer_move`
382    /// calls (≈60 Hz cadence) → `pointer_up`. Backends **should override** to
383    /// emit platform-specific drag events where they differ from move events
384    /// — on macOS, drag-and-drop source apps filter for
385    /// `kCGEventLeftMouseDragged`, which is distinct from
386    /// `kCGEventMouseMoved`. On Windows and X11 the default synthesis is
387    /// usually sufficient.
388    fn pointer_drag(
389        &self,
390        from: Point,
391        to: Point,
392        button: MouseButton,
393        duration: Duration,
394    ) -> Result<()> {
395        const STEP: Duration = Duration::from_millis(16);
396        self.pointer_move(from)?;
397        self.pointer_down(button)?;
398        let steps = (duration.as_millis() / STEP.as_millis().max(1)).max(1) as i32;
399        for i in 1..=steps {
400            let t = i as f64 / steps as f64;
401            let x = from.x + ((to.x - from.x) as f64 * t).round() as i32;
402            let y = from.y + ((to.y - from.y) as f64 * t).round() as i32;
403            self.pointer_move(Point::new(x, y))?;
404            if i < steps {
405                std::thread::sleep(STEP);
406            }
407        }
408        self.pointer_up(button)
409    }
410}
411
412// ── Public façade ───────────────────────────────────────────────────
413
414/// Synthesises OS-level pointer and keyboard events.
415///
416/// `InputSim` is a thin façade over an [`InputProvider`] backend. Methods are
417/// organised by input device: [`InputSim::mouse`] returns a [`Mouse`] handle
418/// with pointer operations, [`InputSim::keyboard`] returns a [`Keyboard`]
419/// handle with key operations. This structure matches Playwright and
420/// Puppeteer's `page.mouse.*` / `page.keyboard.*` layout and keeps the combo
421/// verbs (`click`, `press`) unambiguous even though `Element::press` exists
422/// at the a11y layer.
423///
424/// Use this only when the accessibility action layer cannot express the
425/// interaction you need — see the [module docs](self) for the rationale.
426///
427/// `InputSim` is cheap to clone (it shares the backend via `Arc`).
428///
429/// # Example
430///
431/// ```ignore
432/// # use xa11y_core::{input::*, Element};
433/// # fn go(sim: InputSim, button: Element) -> xa11y_core::Result<()> {
434/// sim.mouse().click(&button)?;
435/// sim.keyboard().chord(Key::Char('a'), &[Key::Meta])?; // Cmd/Ctrl+A
436/// sim.keyboard().type_text("hello")?;
437/// # Ok(()) }
438/// ```
439#[derive(Clone)]
440pub struct InputSim {
441    backend: Arc<dyn InputProvider>,
442}
443
444impl InputSim {
445    pub fn new(backend: Arc<dyn InputProvider>) -> Self {
446        Self { backend }
447    }
448
449    /// Get the backing provider for advanced or composite sequences.
450    pub fn backend(&self) -> &Arc<dyn InputProvider> {
451        &self.backend
452    }
453
454    /// Handle for pointer operations.
455    pub fn mouse(&self) -> Mouse<'_> {
456        Mouse {
457            backend: &self.backend,
458        }
459    }
460
461    /// Handle for keyboard operations.
462    pub fn keyboard(&self) -> Keyboard<'_> {
463        Keyboard {
464            backend: &self.backend,
465        }
466    }
467
468    /// Resolve an element's current bounds to a screen point using `anchor`.
469    /// Equivalent to the free function [`point_for`].
470    pub fn point_for(&self, element: &Element, anchor: Anchor) -> Result<Point> {
471        point_for(element, anchor)
472    }
473}
474
475/// Pointer operations. Obtain via [`InputSim::mouse`].
476pub struct Mouse<'a> {
477    backend: &'a Arc<dyn InputProvider>,
478}
479
480impl Mouse<'_> {
481    /// Left-click `target` once at its [`Anchor::Center`] (for elements) or
482    /// at the literal point.
483    pub fn click(&self, target: impl IntoPoint) -> Result<()> {
484        let pt = target.into_point()?;
485        self.backend.pointer_click(pt, MouseButton::Left, 1)
486    }
487
488    /// Click with explicit options (button, count, held keys, anchor).
489    ///
490    /// `opts.anchor` is used only when `target` is an [`Element`]; for raw
491    /// points it is ignored.
492    pub fn click_with(&self, target: ClickTarget<'_>, opts: ClickOptions) -> Result<()> {
493        for k in &opts.held {
494            k.validate()?;
495        }
496        let pt = match target {
497            ClickTarget::Point(p) => p,
498            ClickTarget::Element(el) => point_for(el, opts.anchor)?,
499        };
500        with_keys_held(self.backend.as_ref(), &opts.held, || {
501            self.backend.pointer_click(pt, opts.button, opts.count)
502        })
503    }
504
505    /// Convenience for a left double-click at `target`.
506    pub fn double_click(&self, target: impl IntoPoint) -> Result<()> {
507        let pt = target.into_point()?;
508        self.backend.pointer_click(pt, MouseButton::Left, 2)
509    }
510
511    /// Convenience for a right-click at `target`.
512    pub fn right_click(&self, target: impl IntoPoint) -> Result<()> {
513        let pt = target.into_point()?;
514        self.backend.pointer_click(pt, MouseButton::Right, 1)
515    }
516
517    /// Press `button` at the current pointer location (no release).
518    pub fn down(&self, button: MouseButton) -> Result<()> {
519        self.backend.pointer_down(button)
520    }
521
522    /// Release `button` at the current pointer location.
523    pub fn up(&self, button: MouseButton) -> Result<()> {
524        self.backend.pointer_up(button)
525    }
526
527    /// Move the pointer to `target` without pressing any buttons.
528    pub fn move_to(&self, target: impl IntoPoint) -> Result<()> {
529        let pt = target.into_point()?;
530        self.backend.pointer_move(pt)
531    }
532
533    /// Press the left button at `from`, move to `to`, release. Default
534    /// duration: 150 ms. Use [`drag_with`](Self::drag_with) to customise.
535    pub fn drag(&self, from: impl IntoPoint, to: impl IntoPoint) -> Result<()> {
536        let from = from.into_point()?;
537        let to = to.into_point()?;
538        self.backend
539            .pointer_drag(from, to, MouseButton::Left, Duration::from_millis(150))
540    }
541
542    /// Drag with explicit options.
543    pub fn drag_with(
544        &self,
545        from: impl IntoPoint,
546        to: impl IntoPoint,
547        opts: DragOptions,
548    ) -> Result<()> {
549        for k in &opts.held {
550            k.validate()?;
551        }
552        let from = from.into_point()?;
553        let to = to.into_point()?;
554        with_keys_held(self.backend.as_ref(), &opts.held, || {
555            self.backend
556                .pointer_drag(from, to, opts.button, opts.duration)
557        })
558    }
559
560    /// Scroll at `target` by `delta` ticks.
561    pub fn scroll(&self, target: impl IntoPoint, delta: ScrollDelta) -> Result<()> {
562        let pt = target.into_point()?;
563        self.backend.pointer_scroll(pt, delta)
564    }
565}
566
567/// Keyboard operations. Obtain via [`InputSim::keyboard`].
568pub struct Keyboard<'a> {
569    backend: &'a Arc<dyn InputProvider>,
570}
571
572impl Keyboard<'_> {
573    /// Tap `key` (press + release) with no other keys held.
574    pub fn press(&self, key: Key) -> Result<()> {
575        key.validate()?;
576        self.backend.key_down(&key)?;
577        self.backend.key_up(&key)
578    }
579
580    /// Tap `key` while `held` are held down.
581    ///
582    /// Modifiers are ordinary keys in this API — pass `Key::Shift`,
583    /// `Key::Ctrl`, `Key::Alt`, or `Key::Meta` via `held`.
584    ///
585    /// ```ignore
586    /// // Cmd/Ctrl+A:
587    /// keyboard.chord(Key::Char('a'), &[Key::Meta])?;
588    /// ```
589    pub fn chord(&self, key: Key, held: &[Key]) -> Result<()> {
590        key.validate()?;
591        for k in held {
592            k.validate()?;
593        }
594        with_keys_held(self.backend.as_ref(), held, || {
595            self.backend.key_down(&key)?;
596            self.backend.key_up(&key)
597        })
598    }
599
600    /// Press `key` without releasing. Pair with [`up`](Self::up).
601    pub fn down(&self, key: Key) -> Result<()> {
602        key.validate()?;
603        self.backend.key_down(&key)
604    }
605
606    /// Release a previously pressed key.
607    pub fn up(&self, key: Key) -> Result<()> {
608        key.validate()?;
609        self.backend.key_up(&key)
610    }
611
612    /// Type literal text into whichever element currently has keyboard focus.
613    ///
614    /// `Keyboard` does not focus the target for you — call the appropriate
615    /// accessibility action (e.g. `Element::focus` via the provider) first.
616    ///
617    /// Unlike [`press`](Self::press), this accepts any text (including
618    /// uppercase and shifted symbols); backends handle the case/shift synthesis.
619    pub fn type_text(&self, text: &str) -> Result<()> {
620        self.backend.type_text(text)
621    }
622}
623
624/// Explicit target for [`Mouse::click_with`]: either a raw point or an
625/// element to anchor against.
626pub enum ClickTarget<'a> {
627    Point(Point),
628    Element(&'a Element),
629}
630
631impl From<Point> for ClickTarget<'_> {
632    fn from(p: Point) -> Self {
633        Self::Point(p)
634    }
635}
636
637impl From<(i32, i32)> for ClickTarget<'_> {
638    fn from(t: (i32, i32)) -> Self {
639        Self::Point(Point::new(t.0, t.1))
640    }
641}
642
643impl<'a> From<&'a Element> for ClickTarget<'a> {
644    fn from(el: &'a Element) -> Self {
645        Self::Element(el)
646    }
647}
648
649/// Run `body` with each key in `keys` held down, releasing them all (in
650/// reverse order) before returning. Errors during release are returned only
651/// when `body` succeeded — a body failure takes precedence.
652fn with_keys_held<F>(backend: &dyn InputProvider, keys: &[Key], body: F) -> Result<()>
653where
654    F: FnOnce() -> Result<()>,
655{
656    for k in keys {
657        backend.key_down(k)?;
658    }
659    let result = body();
660    let mut release_err: Option<Error> = None;
661    for k in keys.iter().rev() {
662        if let Err(e) = backend.key_up(k) {
663            // Keep the first release error so we can surface it if the body
664            // succeeded; if the body already failed, the body error wins.
665            if release_err.is_none() {
666                release_err = Some(e);
667            }
668        }
669    }
670    match (result, release_err) {
671        (Err(e), _) => Err(e),
672        (Ok(()), Some(e)) => Err(e),
673        (Ok(()), None) => Ok(()),
674    }
675}