//! Servo engine bridge. use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; use std::sync::{Arc, Condvar, Mutex, mpsc}; use std::time::{Duration, Instant}; use anyhow::{Result, anyhow}; use dpi::PhysicalSize; use image::RgbaImage; use servo::{ ConsoleLogLevel, EventLoopWaker, JSValue, LoadStatus, NavigationRequest, Preferences, RenderingContext, ServoBuilder, SoftwareRenderingContext, UserContentManager, WebView, WebViewBuilder, WebViewDelegate, WebViewId, }; use servo_fetch::layout; const JS_EVAL_TIMEOUT: Duration = Duration::from_secs(10); /// Max wait before we re-check time-based conditions. pub(crate) const FALLBACK_WAIT: Duration = Duration::from_millis(5); const LAYOUT_JS: &str = include_str!("js/layout.js"); const MAX_CONSOLE_MESSAGES: usize = 100; const MAX_CONSOLE_MESSAGE_LEN: usize = 4096; const MAX_A11Y_NODES: usize = 100_000; const NOISE_REMOVAL_CSS: &str = "\ [aria-label*=\"cookie\" i], [aria-label*=\"consent\" i], \ [class*=\"cookie-banner\" i], [class*=\"cookie-consent\" i], \ [id*=\"cookie\" i][class*=\"banner\" i], \ [class*=\"newsletter-popup\" i], [class*=\"subscribe-modal\" i] \ { display: none !important; }"; /// Shared wake signal — `notify_all` signals, `wait_and_take` consumes. #[derive(Default)] pub(crate) struct WakeFlag { flag: Mutex, cv: Condvar, } impl WakeFlag { /// Block up to `timeout` for a signal, then clear the flag atomically. fn wait_and_take(&self, timeout: Duration) -> bool { let mut guard = self.flag.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !*guard { let (next, _) = self .cv .wait_timeout(guard, timeout) .unwrap_or_else(std::sync::PoisonError::into_inner); guard = next; } std::mem::replace(&mut *guard, false) } fn signal(&self) { *self.flag.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = true; self.cv.notify_all(); } } #[derive(Clone)] struct FlagWaker(Arc); impl EventLoopWaker for FlagWaker { fn clone_box(&self) -> Box { Box::new(self.clone()) } fn wake(&self) { self.0.signal(); } } thread_local! { /// Wake flag owned by `servo_thread`; exposed for `spin_loop` helpers. static WAKE: RefCell>> = const { RefCell::new(None) }; } /// Block up to `timeout` for the next Servo wake. pub(crate) fn wait_for_wake(timeout: Duration) { WAKE.with(|slot| { if let Some(flag) = slot.borrow().as_ref() { flag.wait_and_take(timeout); } else { std::thread::sleep(timeout); } }); } #[derive(Default)] struct WebViewState { loaded_at: Cell>, a11y_truncated: Cell, a11y_nodes: RefCell>, console_messages: RefCell>, } #[derive(Default)] struct SharedDelegate { states: RefCell>>, } impl SharedDelegate { fn register(&self, id: WebViewId) -> Rc { let state = Rc::new(WebViewState::default()); self.states.borrow_mut().insert(id, state.clone()); state } fn remove(&self, id: WebViewId) -> Option> { self.states.borrow_mut().remove(&id) } fn with_state(&self, id: WebViewId, f: impl FnOnce(&WebViewState) -> R) -> Option { let state = self.states.borrow().get(&id).cloned(); state.map(|s| f(&s)) } } /// A captured console message from the page. #[derive(serde::Serialize, Clone)] pub(crate) struct ConsoleMessage { pub level: ConsoleLevel, pub message: String, } /// Console message severity level. #[derive(Debug, Clone, Copy, serde::Serialize)] #[serde(rename_all = "lowercase")] pub(crate) enum ConsoleLevel { Log, Debug, Info, Warn, Error, Trace, } impl std::fmt::Display for ConsoleLevel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Log => f.write_str("log"), Self::Debug => f.write_str("debug"), Self::Info => f.write_str("info"), Self::Warn => f.write_str("warn"), Self::Error => f.write_str("error"), Self::Trace => f.write_str("trace"), } } } impl From for ConsoleLevel { fn from(level: ConsoleLogLevel) -> Self { match level { ConsoleLogLevel::Log => Self::Log, ConsoleLogLevel::Debug => Self::Debug, ConsoleLogLevel::Info => Self::Info, ConsoleLogLevel::Warn => Self::Warn, ConsoleLogLevel::Error => Self::Error, ConsoleLogLevel::Trace => Self::Trace, } } } impl WebViewDelegate for SharedDelegate { fn notify_load_status_changed(&self, webview: WebView, status: LoadStatus) { if status == LoadStatus::Complete { self.with_state(webview.id(), |s| { s.loaded_at.set(Some(Instant::now())); }); } } fn notify_new_frame_ready(&self, webview: WebView) { webview.paint(); } fn request_navigation(&self, _webview: WebView, navigation_request: NavigationRequest) { let is_http = matches!(navigation_request.url.scheme(), "http" | "https"); match navigation_request.url.host_str() { Some(host) if is_http && !crate::net::is_private_host(host) => navigation_request.allow(), _ => { eprintln!("warning: blocked navigation to {}", navigation_request.url); navigation_request.deny(); } } } fn notify_accessibility_tree_update(&self, webview: WebView, tree_update: servo::accesskit::TreeUpdate) { self.with_state(webview.id(), |state| { let mut nodes = state.a11y_nodes.borrow_mut(); for (id, node) in tree_update.nodes { if nodes.len() >= MAX_A11Y_NODES && !nodes.contains_key(&id) { if !state.a11y_truncated.get() { state.a11y_truncated.set(true); eprintln!("warning: accessibility tree truncated at {MAX_A11Y_NODES} nodes"); } continue; } nodes.insert(id, node); } }); } fn show_console_message(&self, webview: WebView, level: ConsoleLogLevel, message: String) { self.with_state(webview.id(), |state| { let mut msgs = state.console_messages.borrow_mut(); if msgs.len() < MAX_CONSOLE_MESSAGES { let message = if message.len() > MAX_CONSOLE_MESSAGE_LEN { let mut s = message; s.truncate(servo_fetch::sanitize::floor_char_boundary(&s, MAX_CONSOLE_MESSAGE_LEN)); s.push_str("… (truncated)"); s } else { message }; msgs.push(ConsoleMessage { level: level.into(), message, }); } }); } } /// Captured output of a single page load. #[derive(Default)] pub(crate) struct ServoPage { pub html: String, pub inner_text: Option, pub layout_json: Option, pub screenshot: Option, pub js_result: Option, pub accessibility_tree: Option, pub console_messages: Vec, } /// Parameters for a [`fetch_page`] call. pub(crate) struct FetchOptions<'a> { pub url: &'a str, pub timeout_secs: u64, /// Extra wait after Servo fires `LoadStatus::Complete`. pub settle_ms: u64, pub mode: FetchMode, } /// What to do once the page has loaded. Variants are mutually exclusive. pub(crate) enum FetchMode { Content { include_a11y: bool }, Screenshot { full_page: bool }, ExecuteJs { expression: String }, } struct FetchRequest { url: String, timeout_secs: u64, settle_ms: u64, mode: FetchMode, reply: mpsc::Sender>, } struct PendingFetch { webview: WebView, request: FetchRequest, deadline: Instant, state: Rc, dedicated_ctx: Option>, } struct Engine { requests: mpsc::SyncSender, wake: Arc, } /// Servo engine — lives for the process lifetime. Shutdown is via process exit. static ENGINE: std::sync::OnceLock = std::sync::OnceLock::new(); /// Fetch a page via Servo. First call spawns a persistent Servo thread. pub(crate) fn fetch_page(opts: FetchOptions<'_>) -> Result { /// Max outstanding requests queued toward the engine. const PENDING_CAPACITY: usize = 64; let engine = ENGINE.get_or_init(|| { let (tx, rx) = mpsc::sync_channel::(PENDING_CAPACITY); let wake = Arc::new(WakeFlag::default()); let wake_for_thread = wake.clone(); std::thread::Builder::new() .name("servo-engine".into()) .spawn(move || servo_thread(rx, wake_for_thread)) .expect("failed to spawn servo thread"); Engine { requests: tx, wake } }); let (reply_tx, reply_rx) = mpsc::channel(); let deadline = Duration::from_secs(opts.timeout_secs) + Duration::from_millis(opts.settle_ms) + Duration::from_secs(2); engine .requests .send(FetchRequest { url: opts.url.to_string(), timeout_secs: opts.timeout_secs, settle_ms: opts.settle_ms, mode: opts.mode, reply: reply_tx, }) .map_err(|_| anyhow!("Servo engine is not running (it may have crashed on a previous request)"))?; // Nudge the engine so it checks the request queue even if it was idle. engine.wake.signal(); match reply_rx.recv_timeout(deadline) { Ok(result) => result, Err(mpsc::RecvTimeoutError::Timeout) => { Err(anyhow!("Servo engine did not respond within {}s", deadline.as_secs())) } Err(mpsc::RecvTimeoutError::Disconnected) => Err(anyhow!("Servo engine crashed while processing this page")), } } #[expect( clippy::needless_pass_by_value, reason = "the thread owns its receiver for its lifetime" )] fn servo_thread(request_rx: mpsc::Receiver, wake: Arc) { let (rc_ctx, servo) = match build_servo(FlagWaker(wake.clone())) { Ok(pair) => pair, Err(e) => { if let Ok(req) = request_rx.recv() { let _ = req.reply.send(Err(e.context("Servo initialization failed"))); } return; } }; WAKE.with(|slot| *slot.borrow_mut() = Some(wake.clone())); let delegate = Rc::new(SharedDelegate::default()); let ucm = Rc::new(UserContentManager::new(&servo)); ucm.add_stylesheet(Rc::new(create_noise_removal_stylesheet())); let mut pending: HashMap = HashMap::new(); loop { while let Ok(req) = request_rx.try_recv() { accept_request(&servo, &rc_ctx, &delegate, &ucm, req, &mut pending); } if pending.is_empty() { // Idle: block until a new request nudges us or the channel hangs up. match request_rx.recv() { Ok(req) => accept_request(&servo, &rc_ctx, &delegate, &ucm, req, &mut pending), Err(_) => return, } continue; } servo.spin_event_loop(); harvest(&servo, &delegate, &mut pending); if !pending.is_empty() { // Wait for Servo to wake us or the next pending deadline, whichever is sooner. let now = Instant::now(); let next = pending .values() .map(|p| { p.state .loaded_at .get() .map_or(p.deadline, |t| t + Duration::from_millis(p.request.settle_ms)) }) .min() .map_or(FALLBACK_WAIT, |t| t.saturating_duration_since(now).min(FALLBACK_WAIT)); wake.wait_and_take(next); } } } fn accept_request( servo: &servo::Servo, rc_ctx: &Rc, delegate: &Rc, ucm: &Rc, req: FetchRequest, pending: &mut HashMap, ) { match start_fetch(servo, rc_ctx, delegate, ucm, req) { Ok(p) => { pending.insert(p.webview.id(), p); } Err((req, err)) => { let _ = req.reply.send(Err(err)); } } } fn harvest(servo: &servo::Servo, delegate: &Rc, pending: &mut HashMap) { let now = Instant::now(); let finished: Vec = pending .iter() .filter_map(|(id, p)| { let settled = p .state .loaded_at .get() .is_some_and(|t| now.duration_since(t) >= Duration::from_millis(p.request.settle_ms)); (settled || now > p.deadline).then_some(*id) }) .collect(); for id in finished { let Some(p) = pending.remove(&id) else { continue }; let result = finish_fetch(servo, &p); delegate.remove(id); drop(p.webview); let _ = p.request.reply.send(result); } } fn start_fetch( servo: &servo::Servo, rc_ctx: &Rc, delegate: &Rc, ucm: &Rc, req: FetchRequest, ) -> std::result::Result { let parsed_url = match url::Url::parse(&req.url) { Ok(u) => u, Err(e) => return Err((req, anyhow!("bad url: {e}"))), }; let dedicated_ctx = if matches!(req.mode, FetchMode::Screenshot { .. }) { let size = PhysicalSize::new(layout::VIEWPORT_WIDTH, layout::VIEWPORT_HEIGHT); match SoftwareRenderingContext::new(size) { Ok(ctx) => { if let Err(e) = ctx.make_current() { return Err((req, anyhow!("failed to make screenshot context current: {e:?}"))); } Some(Rc::new(ctx)) } Err(e) => return Err((req, anyhow!("failed to create screenshot context: {e:?}"))), } } else { None }; let rc_dyn: Rc = match dedicated_ctx.as_ref() { Some(ctx) => ctx.clone(), None => rc_ctx.clone(), }; let delegate_dyn: Rc = delegate.clone(); let webview = WebViewBuilder::new(servo, rc_dyn) .url(parsed_url) .delegate(delegate_dyn) .user_content_manager(ucm.clone()) .build(); if matches!(req.mode, FetchMode::Content { include_a11y: true }) { webview.set_accessibility_active(true); } let state = delegate.register(webview.id()); let deadline = Instant::now() + Duration::from_secs(req.timeout_secs); Ok(PendingFetch { webview, request: req, deadline, state, dedicated_ctx, }) } fn finish_fetch(servo: &servo::Servo, p: &PendingFetch) -> Result { if p.state.loaded_at.get().is_none() && Instant::now() > p.deadline { return Err(anyhow!( "page load timed out after {timeout}s (try increasing --timeout)", timeout = p.request.timeout_secs, )); } if let Some(ref ctx) = p.dedicated_ctx { let _ = ctx.make_current(); } wait_for_ready_state(servo, &p.webview, p.deadline); let html = eval_js(servo, &p.webview, "document.documentElement.outerHTML")?; let inner_text = eval_js(servo, &p.webview, "document.body.innerText").ok(); let layout_json = eval_js(servo, &p.webview, LAYOUT_JS).ok(); let (screenshot, js_result) = match &p.request.mode { FetchMode::Screenshot { full_page } => ( crate::screenshot::capture(servo, &p.webview, *full_page, p.request.timeout_secs), None, ), FetchMode::ExecuteJs { expression } => (None, Some(eval_js(servo, &p.webview, expression)?)), FetchMode::Content { .. } => (None, None), }; let accessibility_tree = { let mut nodes = p.state.a11y_nodes.borrow_mut(); if nodes.is_empty() { None } else { for node in nodes.values_mut() { if node.role() == servo::accesskit::Role::PasswordInput { node.clear_value(); } } serde_json::to_string(&*nodes).ok() } }; Ok(ServoPage { html, inner_text, layout_json, screenshot, js_result, accessibility_tree, console_messages: p.state.console_messages.borrow_mut().drain(..).collect(), }) } fn build_servo(waker: FlagWaker) -> Result<(Rc, servo::Servo)> { let size = PhysicalSize::new(layout::VIEWPORT_WIDTH, layout::VIEWPORT_HEIGHT); let ctx = { let _guard = stderr_guard::StderrGuard::suppress(); let ctx = SoftwareRenderingContext::new(size).map_err(|e| anyhow!("failed to create rendering context: {e:?}"))?; ctx.make_current() .map_err(|e| anyhow!("failed to make context current: {e:?}"))?; ctx }; let prefs = Preferences { accessibility_enabled: true, dom_webgpu_enabled: false, dom_webxr_enabled: false, dom_serviceworker_enabled: false, dom_bluetooth_enabled: false, user_agent: std::env::var("SERVO_FETCH_USER_AGENT") .ok() .filter(|s| !s.is_empty()) .unwrap_or_else(|| format!("Mozilla/5.0 (compatible; servo-fetch/{})", env!("CARGO_PKG_VERSION"))), ..Preferences::default() }; let rc = Rc::new(ctx); let servo = ServoBuilder::default() .preferences(prefs) .event_loop_waker(Box::new(waker)) .build(); Ok((rc, servo)) } fn create_noise_removal_stylesheet() -> servo::user_contents::UserStyleSheet { let url = url::Url::parse("servo-fetch://user-stylesheet/noise-removal").expect("static URL is well-formed"); servo::user_contents::UserStyleSheet::new(NOISE_REMOVAL_CSS.to_string(), url) } /// Wait for `document.readyState` to reach `"complete"`. /// /// TODO(upstream): Servo's `LoadStatus::Complete` fires before the DOM is /// fully parsed on pages with heavy inline scripts (e.g. amazon.co.jp); see /// servo/servo#41972. fn wait_for_ready_state(servo: &servo::Servo, webview: &WebView, deadline: Instant) { while Instant::now() < deadline { servo.spin_event_loop(); if matches!(eval_js(servo, webview, "document.readyState"), Ok(s) if s == "complete") { return; } wait_for_wake(FALLBACK_WAIT); } eprintln!("warning: document did not finish loading; content may be incomplete"); } pub(crate) fn eval_js(servo: &servo::Servo, webview: &WebView, script: &str) -> Result { let result: Rc>>> = Rc::new(RefCell::new(None)); let cb_result = result.clone(); webview.evaluate_javascript(script, move |js_result| { let val = match js_result { Ok(JSValue::String(s)) => Ok(s), Ok(JSValue::Undefined | JSValue::Null) => Ok(String::new()), Ok(JSValue::Boolean(b)) => Ok(b.to_string()), Ok(JSValue::Number(n)) => Ok(n.to_string()), Ok(other) => jsvalue_to_json(&other).and_then(|v| serde_json::to_string(&v).map_err(|e| anyhow!("{e}"))), Err(e) => Err(anyhow!("JS eval error: {e:?}")), }; *cb_result.borrow_mut() = Some(val); }); let deadline = Instant::now() + JS_EVAL_TIMEOUT; loop { servo.spin_event_loop(); if let Some(val) = result.borrow_mut().take() { return val; } if Instant::now() > deadline { return Err(anyhow!("timeout waiting for JS evaluation")); } wait_for_wake(FALLBACK_WAIT); } } fn jsvalue_to_json(val: &JSValue) -> Result { const MAX_DEPTH: u8 = 64; fn convert(val: &JSValue, depth: u8) -> Result { if depth >= MAX_DEPTH { return Err(anyhow!("JS value nested too deeply (>{MAX_DEPTH} levels)")); } Ok(match val { JSValue::Undefined | JSValue::Null => serde_json::Value::Null, JSValue::Boolean(b) => serde_json::Value::Bool(*b), JSValue::Number(n) => serde_json::json!(n), JSValue::String(s) | JSValue::Element(s) | JSValue::ShadowRoot(s) | JSValue::Frame(s) | JSValue::Window(s) => serde_json::Value::String(s.clone()), JSValue::Array(arr) => { let items: Result> = arr.iter().map(|v| convert(v, depth + 1)).collect(); serde_json::Value::Array(items?) } JSValue::Object(map) => { let entries: Result> = map .iter() .map(|(k, v)| Ok((k.clone(), convert(v, depth + 1)?))) .collect(); serde_json::Value::Object(entries?) } }) } convert(val, 0) } #[cfg(test)] mod tests { use super::*; #[test] fn console_level_display() { assert_eq!(ConsoleLevel::Log.to_string(), "log"); assert_eq!(ConsoleLevel::Debug.to_string(), "debug"); assert_eq!(ConsoleLevel::Info.to_string(), "info"); assert_eq!(ConsoleLevel::Warn.to_string(), "warn"); assert_eq!(ConsoleLevel::Error.to_string(), "error"); assert_eq!(ConsoleLevel::Trace.to_string(), "trace"); } #[test] fn console_level_serializes_lowercase() { let json = serde_json::to_string(&ConsoleLevel::Warn).unwrap(); assert_eq!(json, "\"warn\""); } #[test] fn console_message_serializes() { let msg = ConsoleMessage { level: ConsoleLevel::Error, message: "test".into(), }; let json = serde_json::to_string(&msg).unwrap(); assert!(json.contains("\"level\":\"error\"")); assert!(json.contains("\"message\":\"test\"")); } #[test] fn servo_page_default_is_empty() { let page = ServoPage::default(); assert!(page.html.is_empty()); assert!(page.inner_text.is_none()); assert!(page.layout_json.is_none()); assert!(page.screenshot.is_none()); assert!(page.js_result.is_none()); assert!(page.accessibility_tree.is_none()); assert!(page.console_messages.is_empty()); } #[test] fn jsvalue_to_json_primitives() { assert_eq!(jsvalue_to_json(&JSValue::Null).unwrap(), serde_json::Value::Null); assert_eq!(jsvalue_to_json(&JSValue::Undefined).unwrap(), serde_json::Value::Null); assert_eq!( jsvalue_to_json(&JSValue::Boolean(true)).unwrap(), serde_json::json!(true) ); assert_eq!( jsvalue_to_json(&JSValue::Number(42.0)).unwrap(), serde_json::json!(42.0) ); assert_eq!( jsvalue_to_json(&JSValue::String("hello".into())).unwrap(), serde_json::json!("hello") ); } #[test] fn jsvalue_to_json_array() { let val = JSValue::Array(vec![JSValue::Number(1.0), JSValue::String("two".into())]); let result = jsvalue_to_json(&val).unwrap(); assert_eq!(result, serde_json::json!([1.0, "two"])); } #[test] fn jsvalue_to_json_nested_depth_limit() { let mut val = JSValue::Null; for _ in 0..65 { val = JSValue::Array(vec![val]); } assert!(jsvalue_to_json(&val).is_err()); } #[test] fn wake_flag_signal_releases_waiter() { let wake = Arc::new(WakeFlag::default()); let w = wake.clone(); let handle = std::thread::spawn(move || w.wait_and_take(Duration::from_secs(5))); std::thread::sleep(Duration::from_millis(10)); wake.signal(); assert!(handle.join().unwrap(), "waiter should observe the signal"); } #[test] fn wake_flag_wait_and_take_clears() { let wake = WakeFlag::default(); wake.signal(); assert!(wake.wait_and_take(Duration::from_millis(10))); assert!(!wake.wait_and_take(Duration::from_millis(10))); } #[test] fn wake_flag_timeout_returns_false() { let wake = WakeFlag::default(); assert!( !wake.wait_and_take(Duration::from_millis(1)), "should return false on timeout" ); } #[test] fn console_level_from_servo() { assert!(matches!(ConsoleLevel::from(ConsoleLogLevel::Log), ConsoleLevel::Log)); assert!(matches!( ConsoleLevel::from(ConsoleLogLevel::Debug), ConsoleLevel::Debug )); assert!(matches!(ConsoleLevel::from(ConsoleLogLevel::Info), ConsoleLevel::Info)); assert!(matches!(ConsoleLevel::from(ConsoleLogLevel::Warn), ConsoleLevel::Warn)); assert!(matches!( ConsoleLevel::from(ConsoleLogLevel::Error), ConsoleLevel::Error )); assert!(matches!( ConsoleLevel::from(ConsoleLogLevel::Trace), ConsoleLevel::Trace )); } #[test] fn jsvalue_to_json_element_variants() { assert_eq!( jsvalue_to_json(&JSValue::Element("div".into())).unwrap(), serde_json::json!("div") ); assert_eq!( jsvalue_to_json(&JSValue::ShadowRoot("sr".into())).unwrap(), serde_json::json!("sr") ); assert_eq!( jsvalue_to_json(&JSValue::Frame("f".into())).unwrap(), serde_json::json!("f") ); assert_eq!( jsvalue_to_json(&JSValue::Window("w".into())).unwrap(), serde_json::json!("w") ); } #[test] fn jsvalue_to_json_object() { let mut map = HashMap::new(); map.insert("key".to_string(), JSValue::Number(1.0)); let val = JSValue::Object(map); let result = jsvalue_to_json(&val).unwrap(); assert_eq!(result, serde_json::json!({"key": 1.0})); } #[test] fn webview_state_default() { let state = WebViewState::default(); assert!(state.loaded_at.get().is_none(), "loaded_at should be None"); assert!(!state.a11y_truncated.get(), "a11y_truncated should be false"); assert!(state.a11y_nodes.borrow().is_empty(), "a11y_nodes should be empty"); assert!( state.console_messages.borrow().is_empty(), "console_messages should be empty" ); } } // macOS's Apple Silicon OpenGL driver writes noise to fd 2 via fprintf. #[cfg(unix)] mod stderr_guard { use std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd}; pub(crate) struct StderrGuard { saved_fd: Option, } impl StderrGuard { #[allow(unsafe_code)] pub(crate) fn suppress() -> Self { let saved = unsafe { libc::dup(2) }; if saved < 0 { return Self { saved_fd: None }; } let saved_fd = unsafe { OwnedFd::from_raw_fd(saved) }; unsafe { libc::fcntl(saved_fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) }; let Ok(devnull) = std::fs::File::open("/dev/null") else { return Self { saved_fd: None }; }; let null_fd = devnull.into_raw_fd(); unsafe { libc::dup2(null_fd, 2); libc::close(null_fd); } Self { saved_fd: Some(saved_fd), } } } impl Drop for StderrGuard { #[allow(unsafe_code)] fn drop(&mut self) { if let Some(ref fd) = self.saved_fd { unsafe { libc::dup2(fd.as_raw_fd(), 2); } } } } } #[cfg(not(unix))] mod stderr_guard { pub(crate) struct StderrGuard; impl StderrGuard { pub(crate) fn suppress() -> Self { Self } } }