1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
// @trace REQ-LIB-001 REQ-LIB-004 [entity:PagePool]
// @trace REQ-BRW-003: Multi-page pool with idle eviction
// @trace REQ-LIB-001: Headless multi-page management API
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::time::{Duration, Instant};
use dpi::PhysicalSize;
use servo::Servo;
use crate::config::{BaoConfig, PageConfig};
use crate::delegate::BaoServoDelegate;
use crate::error::BrowserError;
use crate::page::PageHandle;
pub struct PoolStats {
pub active: usize,
pub idle: usize,
pub total_created: usize,
pub total_destroyed: usize,
}
struct IdleEntry {
page: PageHandle,
idle_since: Instant,
}
pub struct PagePool {
servo: Rc<Servo>,
servo_delegate: Rc<BaoServoDelegate>,
active_pages: RefCell<HashMap<usize, PageHandle>>,
idle_pages: RefCell<HashMap<usize, IdleEntry>>,
max_total: usize,
idle_ttl: Duration,
default_viewport: PhysicalSize<u32>,
next_id: RefCell<usize>,
total_created: RefCell<usize>,
total_destroyed: RefCell<usize>,
}
impl PagePool {
pub fn new(servo: Rc<Servo>, servo_delegate: Rc<BaoServoDelegate>, config: &BaoConfig) -> Self {
PagePool {
servo,
servo_delegate,
active_pages: RefCell::new(HashMap::new()),
idle_pages: RefCell::new(HashMap::new()),
max_total: config.max_pages,
idle_ttl: config.idle_ttl,
default_viewport: PhysicalSize::new(
config.default_viewport_width,
config.default_viewport_height,
),
next_id: RefCell::new(1),
total_created: RefCell::new(0),
total_destroyed: RefCell::new(0),
}
}
pub fn create_page(&self, config: &PageConfig) -> Result<PageHandle, BrowserError> {
let total = self.active_pages.borrow().len() + self.idle_pages.borrow().len();
if total >= self.max_total {
return Err(BrowserError::Init(format!(
"page limit exceeded: {total}/{}",
self.max_total
)));
}
// SM-EVOLUTION #28 (verdict consumed 2026-09-10, REQ-STL identity
// consistency): arm the CREATION-TIME engine timezone policy before
// ANY realm of this page exists — `forceUTC_` is a per-realm
// creation option with NO post-creation setter, and this page's
// Window realm is created during the pipeline setup inside
// `PageHandle::new` + `wait_for_pipeline_ready` below, so arming any
// later would silently miss it. Both sinks (servo DOM realms via
// `set_force_utc_realms` + bao Node-semantics realms via
// `set_node_force_utc`) are fed from the same
// `StealthProfile::timezone` field. Stealth-free pages reset the
// flags explicitly — a stealthed page earlier in the process must
// not leak its policy onto a stealth-free page's realms
// (process-global creation-time flags, last write wins; engine-level
// sink granularity, same class as the canvas noise seed global).
let force_utc = config
.stealth_profile
.as_ref()
.map_or(false, |p| p.timezone.force_utc);
servo::set_force_utc_realms(force_utc);
bao_engine::set_node_force_utc(force_utc);
let id = {
let mut next = self.next_id.borrow_mut();
let id = *next;
*next += 1;
id
};
let page = {
// #40 phase breadcrumb: the webview build itself is async, but
// keep the span named — a wedge here pins it precisely.
crate::phase_watch::enter_phase(
crate::phase_watch::phase::CREATE_WEBVIEW_NEW,
id as u64,
);
let p = PageHandle::new(
Rc::clone(&self.servo),
Rc::clone(&self.servo_delegate),
config,
self.default_viewport,
id,
);
crate::phase_watch::enter_phase(
crate::phase_watch::phase::CREATE_WAIT_READY,
id as u64,
);
p?
};
// Eager Node Realm init — REQ-SEC-002: eliminate lazy init path
page.wait_for_pipeline_ready(Duration::from_secs(10))?;
// SINGLE injection point for the whole crate (e36 BCE): engine/Web
// APIs + stealth props + the servo-native Worker-scope callback
// (page-script `new Worker()` stealth inheritance), exactly ONCE per
// page. BrowserRuntime::create_page used to run a SECOND
// inject_all_with_profile on the already-injected page; the second
// install_webgl_override stored the first pass's JS hook into
// __originalGetParameter__, so every un-intercepted getParameter
// looped JS hook ↔ native override forever and surfaced as literal
// `undefined` (e36 evidence:
// .claude/prompts/brw004-getparameter-evidence.md).
crate::phase_watch::enter_phase(crate::phase_watch::phase::CREATE_INJECT, id as u64);
crate::runtime_bridge::inject_all_with_profile(&page, &config.stealth_profile)?;
// ISSUE #20: config → runtime enforcement wiring. The runtime-side
// enforcement points (fs read/write, net, run in bao_runtime) consult
// the permission_bridge thread-local guard; without this install the
// configured permission never reached them (audit finding: the guard
// was always None — every check_* site silently allowed). The
// script-thread callback drain (handle_evaluate_javascript, ahead of
// any user evaluate) installs it on the owning thread.
//
// Scope note (DoD-D): the guard is thread-local per ScriptThread and
// install is last-page-wins — per-page scoping on a shared thread is
// recorded as a known limitation, not silently claimed.
if let Some(permission) = &config.permission {
let check = bun_runtime::permission_bridge::PermissionCheck {
read_paths: permission.read.clone(),
write_paths: permission.write.clone(),
net_hosts: permission.net.clone(),
env_allowed: permission.env.unwrap_or(true),
run_allowed: permission.run.unwrap_or(true),
};
let webview_id = page
.webview_id()
.expect("created page must have a webview id");
servo::register_script_thread_callback(
webview_id,
Box::new(move |_, _| {
bun_runtime::permission_bridge::set_permission(Some(check));
}),
);
}
// PER-WORKER delivery tier (REQ-BRW-004, user ruling 2026-09-09
// vendor patch — e43 multi-worker gap): the worker-scope callback
// registered just above (inside inject_all_with_profile) is
// consume-once, so the FIRST Worker of this page drained it and every
// 2nd+ page-JS `new Worker()` ran with ZERO stealth injection (engine
// getters + JS hooks all absent — a bare fingerprintable Worker).
// These injectors are NON-consuming: EVERY Dedicated Worker this page
// creates receives both phases (scope init at the first drain point +
// post-interfaces JS-hook install at the second).
//
// Second-drain note (C15 history): the OLD page-init one-shot
// registration for the second drain point (interfaces-ready callback)
// is RETIRED by this tier — with both registered, Worker #1 ran the
// JS hooks blob TWICE at the second point (one-shot + injector), and
// the audio getChannelData wrapper has no property-slot idempotency
// guard (unlike getParameter's e36 __originalGetParameter__ gate), so
// the double wrap applied the deterministic noise twice and broke
// cross-realm digest equality (c15_worker_window_cross_realm_noise_
// consistency). The injector alone gives EVERY worker exactly one
// blob run; SW never drains the interfaces-ready queue (its own path
// only drains the scope registry), so nothing else consumed the
// retired one-shot.
//
// The FIRST-point double run (scope one-shot + scope injector, both
// before interfaces exist) stays safe: the JS blob's typeof guards
// skip everything pre-interfaces and the engine layer is idempotent
// (define_permanent_getter "prior install" arm / e36 gate) — verified
// by worker_multi_injection_tests (ua exact-match + permgetter=1 +
// orignative=1). The scope one-shot itself is kept: a page's
// ServiceWorker consumes it (S1 f77faf8b), and that drain is
// S-family domain — untouched here.
//
// @trace REQ-BRW-004 [criterion:12..17] CRIT-STL-WK per-Worker
// @trace REQ-BRW-004 [criterion:15] worker JS-hook per-Worker delivery
if let Some(webview_id) = page.webview_id() {
crate::runtime_bridge::register_worker_scope_injector_native(
webview_id,
config.stealth_profile.clone(),
);
crate::register_worker_interfaces_ready_injector_native(
webview_id,
config.stealth_profile.clone(),
);
}
self.active_pages.borrow_mut().insert(id, page.clone());
*self.total_created.borrow_mut() += 1;
// create_page fully returned — park the watchdog until the next phase.
crate::phase_watch::enter_phase(crate::phase_watch::phase::IDLE, 0);
Ok(page)
}
pub fn get_page(&self, id: usize) -> Option<PageHandle> {
if let Some(page) = self.active_pages.borrow().get(&id) {
return Some(page.clone());
}
if let Some(entry) = self.idle_pages.borrow_mut().remove(&id) {
// G1 (W16 T6): leaving the idle map is the SPEC
// `Idle --handle_reacquired--> Interactive` transition. Same
// thread, synchronous with the map move — no observation window.
// A mid-load-released page (stored Navigating) never entered
// Idle and is left alone.
entry.page.lifecycle_handle_reacquired();
self.active_pages
.borrow_mut()
.insert(id, entry.page.clone());
return Some(entry.page);
}
None
}
pub fn close_page(&self, id: usize) -> Result<(), BrowserError> {
crate::phase_watch::enter_phase(crate::phase_watch::phase::CLOSE, id as u64);
let result = self.close_page_inner(id);
crate::phase_watch::enter_phase(crate::phase_watch::phase::IDLE, 0);
result
}
fn close_page_inner(&self, id: usize) -> Result<(), BrowserError> {
if let Some(page) = self.active_pages.borrow_mut().remove(&id) {
page.close()?;
*self.total_destroyed.borrow_mut() += 1;
return Ok(());
}
if let Some(entry) = self.idle_pages.borrow_mut().remove(&id) {
entry.page.close()?;
*self.total_destroyed.borrow_mut() += 1;
return Ok(());
}
Err(BrowserError::Init(format!("page {id} not found")))
}
pub fn release_page(&self, id: usize) {
if let Some(page) = self.active_pages.borrow_mut().remove(&id) {
// G1 (W16 T5): entering the idle map materializes the SPEC
// `Interactive --handle_dropped--> Idle` transition for pages
// observably Interactive (the pool-level idle model and the
// page-level PageState stay in lockstep). Mid-load pages keep
// Navigating (no pseudo-Idle); their TTL reclaim later enters
// Closing via close_during_load.
page.lifecycle_handle_dropped();
self.idle_pages.borrow_mut().insert(
id,
IdleEntry {
page,
idle_since: Instant::now(),
},
);
}
}
pub fn check_idle_pages(&self) -> usize {
let mut reclaimed = 0;
let expired: Vec<usize> = self
.idle_pages
.borrow()
.iter()
.filter(|(_, entry)| entry.idle_since.elapsed() > self.idle_ttl)
.map(|(id, _)| *id)
.collect();
for id in expired {
if let Some(entry) = self.idle_pages.borrow_mut().remove(&id) {
let _ = entry.page.close();
*self.total_destroyed.borrow_mut() += 1;
reclaimed += 1;
}
}
reclaimed
}
pub fn stats(&self) -> PoolStats {
PoolStats {
active: self.active_pages.borrow().len(),
idle: self.idle_pages.borrow().len(),
total_created: *self.total_created.borrow(),
total_destroyed: *self.total_destroyed.borrow(),
}
}
pub fn close_all(&self) {
for (_, page) in self.active_pages.borrow_mut().drain() {
let _ = page.close();
*self.total_destroyed.borrow_mut() += 1;
}
for (_, entry) in self.idle_pages.borrow_mut().drain() {
let _ = entry.page.close();
*self.total_destroyed.borrow_mut() += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pool_stats_construction() {
let stats = PoolStats {
active: 3,
idle: 2,
total_created: 10,
total_destroyed: 5,
};
assert_eq!(stats.active, 3);
assert_eq!(stats.idle, 2);
assert_eq!(stats.total_created, 10);
assert_eq!(stats.total_destroyed, 5);
}
#[test]
fn pool_stats_zero() {
let stats = PoolStats {
active: 0,
idle: 0,
total_created: 0,
total_destroyed: 0,
};
assert_eq!(stats.active + stats.idle, 0);
}
#[test]
fn pool_stats_invariant() {
// total_created >= total_destroyed (can't destroy more than created)
let stats = PoolStats {
active: 5,
idle: 3,
total_created: 20,
total_destroyed: 12,
};
assert!(stats.total_created >= stats.total_destroyed);
assert_eq!(
stats.active + stats.idle,
stats.total_created - stats.total_destroyed
);
}
#[test]
fn pool_stats_all_active() {
let stats = PoolStats {
active: 8,
idle: 0,
total_created: 8,
total_destroyed: 0,
};
assert_eq!(stats.idle, 0);
assert_eq!(stats.active, stats.total_created);
}
#[test]
fn pool_stats_all_idle() {
let stats = PoolStats {
active: 0,
idle: 4,
total_created: 4,
total_destroyed: 0,
};
assert_eq!(stats.active, 0);
assert_eq!(stats.idle, stats.total_created);
}
}