From 73d15cd8b25c38483bd6be7e9d6c0aa59bb92bf7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:38:04 +0000 Subject: [PATCH] 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. --- crates/cos/src/document.rs | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index c3f6c6b74..96975eb36 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -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>, 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();