Skip to main content

bao_browser/
page_pool.rs

1// @trace REQ-LIB-001 REQ-LIB-004 [entity:PagePool]
2// @trace REQ-BRW-003: Multi-page pool with idle eviction
3// @trace REQ-LIB-001: Headless multi-page management API
4use std::cell::RefCell;
5use std::collections::HashMap;
6use std::rc::Rc;
7use std::time::{Duration, Instant};
8
9use dpi::PhysicalSize;
10use servo::Servo;
11
12use crate::config::{BaoConfig, PageConfig};
13use crate::delegate::BaoServoDelegate;
14use crate::error::BrowserError;
15use crate::page::PageHandle;
16
17pub struct PoolStats {
18    pub active: usize,
19    pub idle: usize,
20    pub total_created: usize,
21    pub total_destroyed: usize,
22}
23
24struct IdleEntry {
25    page: PageHandle,
26    idle_since: Instant,
27}
28
29pub struct PagePool {
30    servo: Rc<Servo>,
31    servo_delegate: Rc<BaoServoDelegate>,
32    active_pages: RefCell<HashMap<usize, PageHandle>>,
33    idle_pages: RefCell<HashMap<usize, IdleEntry>>,
34    max_total: usize,
35    idle_ttl: Duration,
36    default_viewport: PhysicalSize<u32>,
37    next_id: RefCell<usize>,
38    total_created: RefCell<usize>,
39    total_destroyed: RefCell<usize>,
40}
41
42impl PagePool {
43    pub fn new(servo: Rc<Servo>, servo_delegate: Rc<BaoServoDelegate>, config: &BaoConfig) -> Self {
44        PagePool {
45            servo,
46            servo_delegate,
47            active_pages: RefCell::new(HashMap::new()),
48            idle_pages: RefCell::new(HashMap::new()),
49            max_total: config.max_pages,
50            idle_ttl: config.idle_ttl,
51            default_viewport: PhysicalSize::new(
52                config.default_viewport_width,
53                config.default_viewport_height,
54            ),
55            next_id: RefCell::new(1),
56            total_created: RefCell::new(0),
57            total_destroyed: RefCell::new(0),
58        }
59    }
60
61    pub fn create_page(&self, config: &PageConfig) -> Result<PageHandle, BrowserError> {
62        let total = self.active_pages.borrow().len() + self.idle_pages.borrow().len();
63        if total >= self.max_total {
64            return Err(BrowserError::Init(format!(
65                "page limit exceeded: {total}/{}",
66                self.max_total
67            )));
68        }
69
70        let id = {
71            let mut next = self.next_id.borrow_mut();
72            let id = *next;
73            *next += 1;
74            id
75        };
76
77        let page = PageHandle::new(
78            Rc::clone(&self.servo),
79            Rc::clone(&self.servo_delegate),
80            config,
81            self.default_viewport,
82            id,
83        )?;
84
85        // Eager Node Realm init — REQ-SEC-002: eliminate lazy init path
86        page.wait_for_pipeline_ready(Duration::from_secs(10))?;
87        let stealth = config.stealth_profile.is_some();
88        crate::runtime_bridge::inject_all(&page, stealth)?;
89
90        self.active_pages.borrow_mut().insert(id, page.clone());
91        *self.total_created.borrow_mut() += 1;
92
93        Ok(page)
94    }
95
96    pub fn get_page(&self, id: usize) -> Option<PageHandle> {
97        if let Some(page) = self.active_pages.borrow().get(&id) {
98            return Some(page.clone());
99        }
100        if let Some(entry) = self.idle_pages.borrow_mut().remove(&id) {
101            self.active_pages
102                .borrow_mut()
103                .insert(id, entry.page.clone());
104            return Some(entry.page);
105        }
106        None
107    }
108
109    pub fn close_page(&self, id: usize) -> Result<(), BrowserError> {
110        if let Some(page) = self.active_pages.borrow_mut().remove(&id) {
111            page.close()?;
112            *self.total_destroyed.borrow_mut() += 1;
113            return Ok(());
114        }
115        if let Some(entry) = self.idle_pages.borrow_mut().remove(&id) {
116            entry.page.close()?;
117            *self.total_destroyed.borrow_mut() += 1;
118            return Ok(());
119        }
120        Err(BrowserError::Init(format!("page {id} not found")))
121    }
122
123    pub fn release_page(&self, id: usize) {
124        if let Some(page) = self.active_pages.borrow_mut().remove(&id) {
125            self.idle_pages.borrow_mut().insert(
126                id,
127                IdleEntry {
128                    page,
129                    idle_since: Instant::now(),
130                },
131            );
132        }
133    }
134
135    pub fn check_idle_pages(&self) -> usize {
136        let mut reclaimed = 0;
137        let expired: Vec<usize> = self
138            .idle_pages
139            .borrow()
140            .iter()
141            .filter(|(_, entry)| entry.idle_since.elapsed() > self.idle_ttl)
142            .map(|(id, _)| *id)
143            .collect();
144
145        for id in expired {
146            if let Some(entry) = self.idle_pages.borrow_mut().remove(&id) {
147                let _ = entry.page.close();
148                *self.total_destroyed.borrow_mut() += 1;
149                reclaimed += 1;
150            }
151        }
152
153        reclaimed
154    }
155
156    pub fn stats(&self) -> PoolStats {
157        PoolStats {
158            active: self.active_pages.borrow().len(),
159            idle: self.idle_pages.borrow().len(),
160            total_created: *self.total_created.borrow(),
161            total_destroyed: *self.total_destroyed.borrow(),
162        }
163    }
164
165    pub fn close_all(&self) {
166        for (_, page) in self.active_pages.borrow_mut().drain() {
167            let _ = page.close();
168            *self.total_destroyed.borrow_mut() += 1;
169        }
170        for (_, entry) in self.idle_pages.borrow_mut().drain() {
171            let _ = entry.page.close();
172            *self.total_destroyed.borrow_mut() += 1;
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn pool_stats_construction() {
183        let stats = PoolStats {
184            active: 3,
185            idle: 2,
186            total_created: 10,
187            total_destroyed: 5,
188        };
189        assert_eq!(stats.active, 3);
190        assert_eq!(stats.idle, 2);
191        assert_eq!(stats.total_created, 10);
192        assert_eq!(stats.total_destroyed, 5);
193    }
194
195    #[test]
196    fn pool_stats_zero() {
197        let stats = PoolStats {
198            active: 0,
199            idle: 0,
200            total_created: 0,
201            total_destroyed: 0,
202        };
203        assert_eq!(stats.active + stats.idle, 0);
204    }
205
206    #[test]
207    fn pool_stats_invariant() {
208        // total_created >= total_destroyed (can't destroy more than created)
209        let stats = PoolStats {
210            active: 5,
211            idle: 3,
212            total_created: 20,
213            total_destroyed: 12,
214        };
215        assert!(stats.total_created >= stats.total_destroyed);
216        assert_eq!(
217            stats.active + stats.idle,
218            stats.total_created - stats.total_destroyed
219        );
220    }
221
222    #[test]
223    fn pool_stats_all_active() {
224        let stats = PoolStats {
225            active: 8,
226            idle: 0,
227            total_created: 8,
228            total_destroyed: 0,
229        };
230        assert_eq!(stats.idle, 0);
231        assert_eq!(stats.active, stats.total_created);
232    }
233
234    #[test]
235    fn pool_stats_all_idle() {
236        let stats = PoolStats {
237            active: 0,
238            idle: 4,
239            total_created: 4,
240            total_destroyed: 0,
241        };
242        assert_eq!(stats.active, 0);
243        assert_eq!(stats.idle, stats.total_created);
244    }
245}