fix(cos): count strings at every depth against the object caches

The object caches weigh what each cached object owns, but the walk
stopped four levels down and counted anything deeper as nothing. Many
objects holding a large string inside a few nested arrays stayed cached
far past the byte budget (34 MiB against 32 MiB in the test, and without
limit as objects grow). The walk now goes past the parser's nesting
limit; anything deeper counts as too large to keep.
This commit is contained in:
aiongg
2026-10-22 17:43:04 +00:00
parent 92e713feb1
commit 73d15cd8b2
+31 -4
View File
@@ -494,12 +494,16 @@ impl ObjectReader {
}
}
/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object
/// is measured in full.
const OWNED_BYTES_DEPTH: usize = 256;
/// Bytes `object` holds of its own: string and stream data that is not a view into `data`
/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the
/// reader's object count).
/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured
/// and counts as too large to keep.
fn owned_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
if depth > 4 {
return 0;
if depth > OWNED_BYTES_DEPTH {
return usize::MAX;
}
match object {
Object::String(s) => s.bytes.len(),
@@ -2993,6 +2997,29 @@ endobj
}
}
#[test]
fn editor_cache_counts_deeply_nested_strings() {
let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20]));
for _ in 0..8 {
nested = Object::Array(vec![nested]);
}
let mut source = Document::new_empty();
let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect();
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
let doc = Document::open(bytes).unwrap();
assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20);
let root = doc.get(doc.root().unwrap());
for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() {
let Object::Ref(reference) = reference else { panic!("indirect object") };
drop(doc.get(*reference));
let cache = doc.cache.lock().unwrap();
let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0)));
let strings = cache.values().filter(|o| o.as_array().is_some()).count();
assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted");
}
}
#[test]
fn editor_cache_stays_bounded() {
let mut source = Document::new_empty();