we (web engine): Experimental web browser project to understand the limits of Claude
1//! Tri-color mark-and-sweep garbage collector.
2//!
3//! Manages heap-allocated JS objects (plain objects, arrays, functions) using
4//! the tri-color invariant: white (unreached), gray (reached, children not yet
5//! scanned), black (fully scanned). The collector is stop-the-world and
6//! triggered when live object count exceeds a configurable threshold.
7
8/// Mark color for tri-color marking.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10enum Color {
11 /// Not yet visited — candidate for collection.
12 White,
13 /// Visited but children not yet scanned.
14 Gray,
15 /// Fully scanned — reachable and all children traced.
16 Black,
17}
18
19/// A reference (handle) to a GC-managed object.
20///
21/// This is a lightweight index into the GC heap. It is `Copy` so it can be
22/// freely duplicated — the GC is responsible for tracking liveness.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct GcRef(u32);
25
26/// Trait for types that can be traced by the garbage collector.
27///
28/// Implementors must report all `GcRef` values they hold so the GC can
29/// traverse the object graph.
30pub trait Traceable {
31 /// Call `visitor` once for each `GcRef` this object holds.
32 fn trace(&self, visitor: &mut dyn FnMut(GcRef));
33}
34
35/// A heap slot containing GC metadata and the object data.
36struct HeapSlot<T> {
37 color: Color,
38 data: T,
39}
40
41/// Statistics about the GC heap.
42#[derive(Debug, Clone)]
43pub struct GcStats {
44 /// Number of live (allocated) objects.
45 pub live_count: usize,
46 /// Total heap slots (including free slots).
47 pub total_slots: usize,
48 /// Number of collections performed so far.
49 pub collections: usize,
50}
51
52/// The garbage collector.
53///
54/// Manages a heap of `T` objects. Objects are allocated via [`alloc`] and
55/// accessed via [`get`]/[`get_mut`]. Collection is triggered manually or
56/// when [`should_collect`] returns true.
57pub struct Gc<T: Traceable> {
58 /// The heap: a vector of optional slots (None = free).
59 heap: Vec<Option<HeapSlot<T>>>,
60 /// Free slot indices for O(1) allocation.
61 free_list: Vec<u32>,
62 /// Number of currently live objects.
63 live_count: usize,
64 /// Collection is suggested when live_count exceeds this.
65 threshold: usize,
66 /// Total collections performed.
67 collections: usize,
68}
69
70/// Initial collection threshold.
71const INITIAL_THRESHOLD: usize = 256;
72
73impl<T: Traceable> Gc<T> {
74 /// Create a new, empty GC heap.
75 pub fn new() -> Self {
76 Self {
77 heap: Vec::new(),
78 free_list: Vec::new(),
79 live_count: 0,
80 threshold: INITIAL_THRESHOLD,
81 collections: 0,
82 }
83 }
84
85 /// Allocate a new object on the heap, returning a `GcRef` handle.
86 pub fn alloc(&mut self, data: T) -> GcRef {
87 let slot = HeapSlot {
88 color: Color::White,
89 data,
90 };
91 let idx = if let Some(free_idx) = self.free_list.pop() {
92 let i = free_idx as usize;
93 self.heap[i] = Some(slot);
94 free_idx
95 } else {
96 let i = self.heap.len();
97 self.heap.push(Some(slot));
98 i as u32
99 };
100 self.live_count += 1;
101 GcRef(idx)
102 }
103
104 /// Get an immutable reference to the object at `r`.
105 ///
106 /// Returns `None` if the slot has been freed (stale reference).
107 pub fn get(&self, r: GcRef) -> Option<&T> {
108 let idx = r.0 as usize;
109 self.heap
110 .get(idx)
111 .and_then(|slot| slot.as_ref().map(|s| &s.data))
112 }
113
114 /// Get a mutable reference to the object at `r`.
115 ///
116 /// Returns `None` if the slot has been freed (stale reference).
117 pub fn get_mut(&mut self, r: GcRef) -> Option<&mut T> {
118 let idx = r.0 as usize;
119 self.heap
120 .get_mut(idx)
121 .and_then(|slot| slot.as_mut().map(|s| &mut s.data))
122 }
123
124 /// Returns `true` if a collection should be triggered.
125 pub fn should_collect(&self) -> bool {
126 self.live_count >= self.threshold
127 }
128
129 /// Perform a garbage collection pass.
130 ///
131 /// `roots` must contain every `GcRef` reachable from the mutator (VM
132 /// registers, globals, call stack). Any object not transitively reachable
133 /// from a root is freed.
134 pub fn collect(&mut self, roots: &[GcRef]) {
135 self.mark(roots);
136 self.sweep();
137 self.collections += 1;
138
139 // Grow threshold so we don't collect too often.
140 if self.live_count >= self.threshold {
141 self.threshold = self.live_count * 2;
142 }
143 }
144
145 /// Return heap statistics.
146 pub fn stats(&self) -> GcStats {
147 GcStats {
148 live_count: self.live_count,
149 total_slots: self.heap.len(),
150 collections: self.collections,
151 }
152 }
153
154 /// Mark phase: starting from roots, color all reachable objects black.
155 fn mark(&mut self, roots: &[GcRef]) {
156 let mut gray_stack: Vec<GcRef> = Vec::new();
157
158 // Color roots gray.
159 for &root in roots {
160 let idx = root.0 as usize;
161 if idx < self.heap.len() {
162 if let Some(entry) = &mut self.heap[idx] {
163 if entry.color == Color::White {
164 entry.color = Color::Gray;
165 gray_stack.push(root);
166 }
167 }
168 }
169 }
170
171 // Process the gray stack.
172 while let Some(gc_ref) = gray_stack.pop() {
173 let idx = gc_ref.0 as usize;
174
175 // Collect child references (scoped borrow).
176 let children = {
177 let mut c = Vec::new();
178 if let Some(entry) = &self.heap[idx] {
179 entry.data.trace(&mut |child| c.push(child));
180 }
181 c
182 };
183
184 // Mark current object black.
185 if let Some(entry) = &mut self.heap[idx] {
186 entry.color = Color::Black;
187 }
188
189 // Mark white children gray.
190 for child in children {
191 let cidx = child.0 as usize;
192 if cidx < self.heap.len() {
193 if let Some(entry) = &mut self.heap[cidx] {
194 if entry.color == Color::White {
195 entry.color = Color::Gray;
196 gray_stack.push(child);
197 }
198 }
199 }
200 }
201 }
202 }
203
204 /// Sweep phase: free all white objects, reset black objects to white.
205 fn sweep(&mut self) {
206 for i in 0..self.heap.len() {
207 let should_free = match &self.heap[i] {
208 Some(entry) => entry.color == Color::White,
209 None => false,
210 };
211 if should_free {
212 self.heap[i] = None;
213 self.free_list.push(i as u32);
214 self.live_count -= 1;
215 } else if let Some(entry) = &mut self.heap[i] {
216 // Reset black → white for next cycle.
217 entry.color = Color::White;
218 }
219 }
220 }
221}
222
223impl<T: Traceable> Default for Gc<T> {
224 fn default() -> Self {
225 Self::new()
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 /// A simple test object that holds GcRef children.
234 struct TestObj {
235 children: Vec<GcRef>,
236 }
237
238 impl Traceable for TestObj {
239 fn trace(&self, visitor: &mut dyn FnMut(GcRef)) {
240 for &child in &self.children {
241 visitor(child);
242 }
243 }
244 }
245
246 #[test]
247 fn test_alloc_and_get() {
248 let mut gc = Gc::new();
249 let r = gc.alloc(TestObj {
250 children: Vec::new(),
251 });
252 assert!(gc.get(r).is_some());
253 assert_eq!(gc.stats().live_count, 1);
254 }
255
256 #[test]
257 fn test_collect_unreachable() {
258 let mut gc = Gc::new();
259 let r1 = gc.alloc(TestObj {
260 children: Vec::new(),
261 });
262 let r2 = gc.alloc(TestObj {
263 children: Vec::new(),
264 });
265 assert_eq!(gc.stats().live_count, 2);
266
267 // Collect with no roots — everything is freed.
268 gc.collect(&[]);
269 assert_eq!(gc.stats().live_count, 0);
270 assert!(gc.get(r1).is_none());
271 assert!(gc.get(r2).is_none());
272 }
273
274 #[test]
275 fn test_collect_reachable() {
276 let mut gc = Gc::new();
277 let r1 = gc.alloc(TestObj {
278 children: Vec::new(),
279 });
280 let r2 = gc.alloc(TestObj {
281 children: Vec::new(),
282 });
283 assert_eq!(gc.stats().live_count, 2);
284
285 // Only r1 is a root.
286 gc.collect(&[r1]);
287 assert_eq!(gc.stats().live_count, 1);
288 assert!(gc.get(r1).is_some());
289 assert!(gc.get(r2).is_none());
290 }
291
292 #[test]
293 fn test_collect_transitive() {
294 let mut gc = Gc::new();
295 let r1 = gc.alloc(TestObj {
296 children: Vec::new(),
297 });
298 let r2 = gc.alloc(TestObj {
299 children: Vec::new(),
300 });
301 let r3 = gc.alloc(TestObj {
302 children: Vec::new(),
303 });
304
305 // r1 -> r2 -> r3
306 gc.get_mut(r1).unwrap().children.push(r2);
307 gc.get_mut(r2).unwrap().children.push(r3);
308
309 gc.collect(&[r1]);
310 assert_eq!(gc.stats().live_count, 3);
311 assert!(gc.get(r1).is_some());
312 assert!(gc.get(r2).is_some());
313 assert!(gc.get(r3).is_some());
314 }
315
316 #[test]
317 fn test_cycle_collection() {
318 let mut gc = Gc::new();
319 let r1 = gc.alloc(TestObj {
320 children: Vec::new(),
321 });
322 let r2 = gc.alloc(TestObj {
323 children: Vec::new(),
324 });
325
326 // Create a cycle: r1 -> r2 -> r1.
327 gc.get_mut(r1).unwrap().children.push(r2);
328 gc.get_mut(r2).unwrap().children.push(r1);
329
330 // With r1 as root, both survive.
331 gc.collect(&[r1]);
332 assert_eq!(gc.stats().live_count, 2);
333
334 // With no roots, both are freed (cycle is collected).
335 gc.collect(&[]);
336 assert_eq!(gc.stats().live_count, 0);
337 }
338
339 #[test]
340 fn test_free_list_reuse() {
341 let mut gc = Gc::new();
342 let _r1 = gc.alloc(TestObj {
343 children: Vec::new(),
344 });
345 let _r2 = gc.alloc(TestObj {
346 children: Vec::new(),
347 });
348
349 // Free both.
350 gc.collect(&[]);
351 assert_eq!(gc.stats().live_count, 0);
352 assert_eq!(gc.stats().total_slots, 2);
353
354 // Allocate again — should reuse freed slots.
355 let _r3 = gc.alloc(TestObj {
356 children: Vec::new(),
357 });
358 let _r4 = gc.alloc(TestObj {
359 children: Vec::new(),
360 });
361 assert_eq!(gc.stats().live_count, 2);
362 assert_eq!(gc.stats().total_slots, 2); // No growth.
363 }
364
365 #[test]
366 fn test_should_collect_threshold() {
367 let mut gc: Gc<TestObj> = Gc::new();
368 assert!(!gc.should_collect());
369
370 // Allocate up to threshold.
371 for _ in 0..INITIAL_THRESHOLD {
372 gc.alloc(TestObj {
373 children: Vec::new(),
374 });
375 }
376 assert!(gc.should_collect());
377 }
378
379 #[test]
380 fn test_stress() {
381 let mut gc = Gc::new();
382 let mut live_refs = Vec::new();
383
384 for i in 0..1000 {
385 let r = gc.alloc(TestObj {
386 children: Vec::new(),
387 });
388 if i % 3 == 0 {
389 live_refs.push(r);
390 }
391 }
392
393 gc.collect(&live_refs);
394 // Only refs kept in live_refs should survive.
395 assert_eq!(gc.stats().live_count, live_refs.len());
396
397 for r in &live_refs {
398 assert!(gc.get(*r).is_some());
399 }
400 }
401}