Skip to main content

ferridriver_script/bindings/
abort.rs

1//! WHATWG `AbortController` / `AbortSignal` (spec subset, no external
2//! deps). Enough of the standard for `fetch(..., { signal })`,
3//! `AbortSignal.timeout(ms)`, `AbortSignal.any([...])`, `onabort`,
4//! `addEventListener('abort', ...)`, `.aborted`, `.reason`,
5//! `.throwIfAborted()`.
6//!
7//! JS-visible reason/listeners are stored natively on a `'js`-generic
8//! class (no synthesized `__` properties). A separate `Send`/`Sync`
9//! [`AbortInner`] channel lets `fetch` await an abort from the request
10//! future and drop it (the spec's "abort the fetch").
11
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use rquickjs::class::Trace;
16use rquickjs::function::Opt;
17use rquickjs::{Class, Ctx, Function, Object, Value};
18
19/// Native, thread-safe side of a signal: lets a `fetch` request future
20/// observe an abort that happens on the JS thread and cancel itself.
21pub struct AbortInner {
22  aborted: AtomicBool,
23  notify: tokio::sync::Notify,
24  /// Best-effort message for the native rejection (the JS `.reason`
25  /// object stays on the class instance).
26  message: std::sync::Mutex<Option<String>>,
27}
28
29impl AbortInner {
30  fn new() -> Arc<Self> {
31    Arc::new(Self {
32      aborted: AtomicBool::new(false),
33      notify: tokio::sync::Notify::new(),
34      message: std::sync::Mutex::new(None),
35    })
36  }
37
38  pub fn is_aborted(&self) -> bool {
39    self.aborted.load(Ordering::Acquire)
40  }
41
42  /// Reason message for the `fetch` rejection ("This operation was
43  /// aborted" by default).
44  pub fn reason_message(&self) -> String {
45    self
46      .message
47      .lock()
48      .unwrap_or_else(std::sync::PoisonError::into_inner)
49      .clone()
50      .unwrap_or_else(|| "This operation was aborted".to_string())
51  }
52
53  fn mark(&self, message: Option<String>) {
54    *self.message.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = message;
55    self.aborted.store(true, Ordering::Release);
56    self.notify.notify_waiters();
57  }
58
59  /// Resolves the next time the signal aborts. (`Notify` only wakes
60  /// waiters registered before `notify_waiters`; callers must check
61  /// [`Self::is_aborted`] first to avoid the pre-abort race.)
62  pub async fn aborted(&self) {
63    self.notify.notified().await;
64  }
65}
66
67#[derive(Trace)]
68#[rquickjs::class(rename = "AbortSignal")]
69pub struct AbortSignalJs<'js> {
70  #[qjs(skip_trace)]
71  inner: Arc<AbortInner>,
72  #[qjs(skip_trace)]
73  aborted: bool,
74  reason: Option<Value<'js>>,
75  listeners: Vec<Function<'js>>,
76  onabort: Option<Function<'js>>,
77}
78
79#[allow(unsafe_code)]
80unsafe impl<'js> rquickjs::JsLifetime<'js> for AbortSignalJs<'js> {
81  type Changed<'to> = AbortSignalJs<'to>;
82}
83
84impl<'js> AbortSignalJs<'js> {
85  fn fresh() -> Self {
86    Self {
87      inner: AbortInner::new(),
88      aborted: false,
89      reason: None,
90      listeners: Vec::new(),
91      onabort: None,
92    }
93  }
94
95  /// The native channel a `fetch` future awaits on.
96  pub fn inner_of(signal: &Class<'js, AbortSignalJs<'js>>) -> Arc<AbortInner> {
97    signal.borrow().inner.clone()
98  }
99
100  /// Default abort reason: a duck-typed `{ name, message }` (no
101  /// `DOMException` class in this runtime). `name` is what abort-aware
102  /// libraries check.
103  fn default_reason(ctx: &Ctx<'js>, name: &str, message: &str) -> rquickjs::Result<Value<'js>> {
104    let o = Object::new(ctx.clone())?;
105    o.set("name", name)?;
106    o.set("message", message)?;
107    Ok(o.into_value())
108  }
109
110  fn reason_to_message(reason: Option<&Value<'js>>) -> Option<String> {
111    let r = reason?;
112    if let Some(s) = r.as_string().and_then(|s| s.to_string().ok()) {
113      return Some(s);
114    }
115    r.as_object()
116      .and_then(|o| o.get::<_, String>("message").ok())
117      .or(Some("This operation was aborted".to_string()))
118  }
119
120  /// Flip to aborted, store the reason, wake the native channel, fire
121  /// `onabort` then every `addEventListener('abort')` listener once.
122  fn run_abort(this: &Class<'js, AbortSignalJs<'js>>, reason: Value<'js>) {
123    {
124      let mut b = this.borrow_mut();
125      if b.aborted {
126        return;
127      }
128      b.aborted = true;
129      b.reason = Some(reason.clone());
130      b.inner.mark(Self::reason_to_message(Some(&reason)));
131    }
132    let (onabort, listeners) = {
133      let b = this.borrow();
134      (b.onabort.clone(), b.listeners.clone())
135    };
136    if let Some(cb) = onabort {
137      let _ = cb.call::<_, ()>((reason.clone(),));
138    }
139    for cb in listeners {
140      let _ = cb.call::<_, ()>((reason.clone(),));
141    }
142  }
143}
144
145#[rquickjs::methods(rename_all = "camelCase")]
146impl<'js> AbortSignalJs<'js> {
147  /// Spec: `AbortSignal` is not constructible (`new AbortSignal()`
148  /// throws). It exists only so the name/statics/`instanceof` are
149  /// present; instances come from `AbortController`, `AbortSignal.abort`,
150  /// `.timeout`, `.any`.
151  #[qjs(constructor)]
152  pub fn new(ctx: Ctx<'js>) -> rquickjs::Result<Self> {
153    Err(rquickjs::Exception::throw_type(&ctx, "Illegal constructor"))
154  }
155
156  #[qjs(get)]
157  pub fn aborted(&self) -> bool {
158    self.aborted
159  }
160
161  #[qjs(get)]
162  pub fn reason(&self) -> Option<Value<'js>> {
163    self.reason.clone()
164  }
165
166  #[qjs(rename = "throwIfAborted")]
167  pub fn throw_if_aborted(&self, ctx: Ctx<'js>) -> rquickjs::Result<()> {
168    if self.aborted {
169      let r = self.reason.clone().unwrap_or_else(|| Value::new_undefined(ctx.clone()));
170      return Err(ctx.throw(r));
171    }
172    Ok(())
173  }
174
175  #[qjs(get, rename = "onabort")]
176  pub fn get_onabort(&self) -> Option<Function<'js>> {
177    self.onabort.clone()
178  }
179
180  #[qjs(set, rename = "onabort")]
181  pub fn set_onabort(&mut self, cb: Opt<Function<'js>>) {
182    self.onabort = cb.0;
183  }
184
185  #[qjs(rename = "addEventListener")]
186  pub fn add_event_listener(&mut self, event: String, cb: Function<'js>) {
187    if event == "abort" {
188      self.listeners.push(cb);
189    }
190  }
191
192  #[qjs(rename = "removeEventListener")]
193  pub fn remove_event_listener(&mut self, event: String, cb: Function<'js>) {
194    if event == "abort" {
195      self.listeners.retain(|l| l != &cb);
196    }
197  }
198
199  /// `AbortSignal.abort(reason?)` — an already-aborted signal.
200  #[qjs(static)]
201  pub fn abort(ctx: Ctx<'js>, reason: Opt<Value<'js>>) -> rquickjs::Result<Class<'js, AbortSignalJs<'js>>> {
202    let inst = Class::instance(ctx.clone(), Self::fresh())?;
203    let r = match reason.0 {
204      Some(v) if !v.is_undefined() => v,
205      _ => Self::default_reason(&ctx, "AbortError", "This operation was aborted")?,
206    };
207    Self::run_abort(&inst, r);
208    Ok(inst)
209  }
210
211  /// `AbortSignal.timeout(ms)` — aborts with a `TimeoutError` after the
212  /// delay, driven on the JS event loop (`Ctx::spawn`).
213  #[qjs(static)]
214  pub fn timeout(ctx: Ctx<'js>, ms: u64) -> rquickjs::Result<Class<'js, AbortSignalJs<'js>>> {
215    let inst = Class::instance(ctx.clone(), Self::fresh())?;
216    let inst2 = inst.clone();
217    let ctx2 = ctx.clone();
218    ctx.spawn(async move {
219      tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
220      if let Ok(reason) = AbortSignalJs::default_reason(&ctx2, "TimeoutError", "The operation timed out") {
221        AbortSignalJs::run_abort(&inst2, reason);
222      }
223    });
224    Ok(inst)
225  }
226
227  /// `AbortSignal.any([...])` — aborts when any input signal aborts
228  /// (or immediately if one already has).
229  #[qjs(static)]
230  pub fn any(
231    ctx: Ctx<'js>,
232    signals: Vec<Class<'js, AbortSignalJs<'js>>>,
233  ) -> rquickjs::Result<Class<'js, AbortSignalJs<'js>>> {
234    let combined = Class::instance(ctx.clone(), Self::fresh())?;
235    for s in &signals {
236      let (is_aborted, reason) = {
237        let b = s.borrow();
238        (b.aborted, b.reason.clone())
239      };
240      if is_aborted {
241        let r = reason.unwrap_or_else(|| {
242          Self::default_reason(&ctx, "AbortError", "This operation was aborted")
243            .unwrap_or_else(|_| Value::new_undefined(ctx.clone()))
244        });
245        Self::run_abort(&combined, r);
246        return Ok(combined);
247      }
248    }
249    for s in &signals {
250      let combined2 = combined.clone();
251      let cb = Function::new(ctx.clone(), move |reason: Value<'js>| {
252        AbortSignalJs::run_abort(&combined2, reason);
253      })?;
254      s.borrow_mut().listeners.push(cb);
255    }
256    Ok(combined)
257  }
258}
259
260#[derive(Trace)]
261#[rquickjs::class(rename = "AbortController")]
262pub struct AbortControllerJs<'js> {
263  signal: Class<'js, AbortSignalJs<'js>>,
264}
265
266#[allow(unsafe_code)]
267unsafe impl<'js> rquickjs::JsLifetime<'js> for AbortControllerJs<'js> {
268  type Changed<'to> = AbortControllerJs<'to>;
269}
270
271#[rquickjs::methods(rename_all = "camelCase")]
272impl<'js> AbortControllerJs<'js> {
273  #[qjs(constructor)]
274  pub fn new(ctx: Ctx<'js>) -> rquickjs::Result<Self> {
275    Ok(Self {
276      signal: Class::instance(ctx, AbortSignalJs::fresh())?,
277    })
278  }
279
280  #[qjs(get)]
281  pub fn signal(&self) -> Class<'js, AbortSignalJs<'js>> {
282    self.signal.clone()
283  }
284
285  #[qjs(rename = "abort")]
286  pub fn abort(&self, ctx: Ctx<'js>, reason: Opt<Value<'js>>) -> rquickjs::Result<()> {
287    let r = match reason.0 {
288      Some(v) if !v.is_undefined() => v,
289      _ => AbortSignalJs::default_reason(&ctx, "AbortError", "This operation was aborted")?,
290    };
291    AbortSignalJs::run_abort(&self.signal, r);
292    Ok(())
293  }
294}