FU.4: craft-proof packs — remembered hyphenation patterns keep to a 256 MB budget of their own; a pack's spelling files are never candidates through a symbolic link

Follow-ups from craft-libs#485: up to 256 languages' pack patterns (64 MB files each) were kept
by count only; `hunspell::ordered` checked pack files with `is_file`, which follows links,
while the pack scan refuses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
This commit is contained in:
Martin BallandClaude Opus 5.5 committed 2026-11-02 01:18:42 +00:00
1 parent 87defe169f
commit 86345ebcf5
2 files changed
+93 -13

No files matched your search

+38 -7
View File
@@ -196,19 +196,22 @@ pub(crate) fn ordered(
let first = names.first().map(|n| norm(n));
let (region, rest): (Vec<&str>, Vec<&str>) = names.iter().copied().partition(|n| Some(norm(n)) == first);
let pack: &[&str] = &[crate::packs::SPELLING];
// (folder, names, a Language pack's: never read through a symbolic link, as the pack scan
// checks; a system folder's dictionaries often are links, `en_GB.dic` → `en_GB-ise.dic`).
let tiers = dirs
.iter()
.map(|d| (d, names))
.chain(region_packs.iter().map(|d| (d, pack)))
.chain(system.iter().map(|d| (d, &region[..])))
.chain(other_packs.iter().map(|d| (d, pack)))
.chain(system.iter().map(|d| (d, &rest[..])));
.map(|d| (d, names, false))
.chain(region_packs.iter().map(|d| (d, pack, true)))
.chain(system.iter().map(|d| (d, &region[..], false)))
.chain(other_packs.iter().map(|d| (d, pack, true)))
.chain(system.iter().map(|d| (d, &rest[..], false)));
let file = |p: &Path, pack: bool| if pack { std::fs::symlink_metadata(p).is_ok_and(|m| m.is_file()) } else { p.is_file() };
let mut out: Vec<(PathBuf, PathBuf)> = Vec::new();
for (dir, names) in tiers {
for (dir, names, in_pack) in tiers {
for n in names {
let aff = dir.join(format!("{n}.aff"));
let dic = dir.join(format!("{n}.dic"));
if aff.is_file() && dic.is_file() && !out.iter().any(|(_, d)| *d == dic) {
if file(&aff, in_pack) && file(&dic, in_pack) && !out.iter().any(|(_, d)| *d == dic) {
out.push((aff, dic));
}
}
@@ -696,6 +699,34 @@ mod tests {
}
}
/// Review follow-up (craft-libs#485): a pack's `spelling.aff`/`.dic` that is a symbolic link
/// (swapped in after the pack scan) is never a candidate; one in a system folder still is.
#[cfg(unix)]
#[test]
fn pack_files_are_never_read_through_a_link() {
let root = std::env::temp_dir().join(format!("wc-proof-pack-links-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (pack, system, elsewhere) = (root.join("packs").join("nl"), root.join("system"), root.join("elsewhere"));
for d in [&pack, &system, &elsewhere] {
std::fs::create_dir_all(d).unwrap();
}
std::fs::write(elsewhere.join("x.aff"), "SET UTF-8\n").unwrap();
std::fs::write(elsewhere.join("x.dic"), "1\nx\n").unwrap();
std::fs::write(pack.join("spelling.aff"), "SET UTF-8\n").unwrap();
std::os::unix::fs::symlink(elsewhere.join("x.dic"), pack.join("spelling.dic")).unwrap();
std::os::unix::fs::symlink(elsewhere.join("x.aff"), system.join("nl.aff")).unwrap();
std::os::unix::fs::symlink(elsewhere.join("x.dic"), system.join("nl.dic")).unwrap();
let got: Vec<PathBuf> =
ordered(&["nl"], &[], std::slice::from_ref(&pack), std::slice::from_ref(&system), &[]).into_iter().map(|(_, d)| d).collect();
assert_eq!(got, [system.join("nl.dic")], "the system's link is followed, the pack's never");
std::fs::remove_file(pack.join("spelling.dic")).unwrap();
std::fs::write(pack.join("spelling.dic"), "1\nx\n").unwrap();
let got: Vec<PathBuf> =
ordered(&["nl"], &[], std::slice::from_ref(&pack), std::slice::from_ref(&system), &[]).into_iter().map(|(_, d)| d).collect();
assert_eq!(got, [pack.join("spelling.dic"), system.join("nl.dic")]);
let _ = std::fs::remove_dir_all(&root);
}
/// WordCraft's own dictionaries folder under the user's application data is searched on every
/// desktop system, after the setting and before the shared system locations (#1063).
#[test]
+55 -6
View File
@@ -34,6 +34,13 @@ pub(crate) const SPELLING: &str = "spelling";
const HYPHENATION: &str = "hyphenation.pat";
/// Most languages whose pack patterns are remembered (a document may tag thousands).
const MAX_HYPHENATION_LOOKUPS: usize = 256;
/// The memory budget of the remembered pack patterns, in estimated bytes ([`pattern_bytes`]):
/// a pack's patterns may be as large as a dictionary (64 MB of file), and up to
/// [`MAX_HYPHENATION_LOOKUPS`] of them are kept, so they get a budget of their own, as the
/// dictionaries do (`pack_lang::MAX_LOADED_BYTES`).
const MAX_HYPHENATION_BYTES: u64 = 256 << 20;
/// Estimated memory per pattern (its packed key, its values and the map's own room).
const BYTES_PER_PATTERN: u64 = 64;
/// A pack's description.
const PACK_TOML: &str = "pack.toml";
/// A pack's licence texts.
@@ -80,15 +87,18 @@ struct State {
dir: Option<PathBuf>,
scan: Option<Arc<Scan>>,
/// Each language's pack patterns once looked up (`None`: no pack has them), at most
/// [`MAX_HYPHENATION_LOOKUPS`] languages (cleared when full).
/// [`MAX_HYPHENATION_LOOKUPS`] languages and [`MAX_HYPHENATION_BYTES`] (cleared when full).
hyphenation: BTreeMap<ProofLang, Option<Arc<Patterns>>>,
/// The estimated memory of the patterns in [`State::hyphenation`].
hyphenation_bytes: u64,
/// (locale, reason) of pack hyphenation files that failed to load.
hyphenation_errors: Vec<(String, String)>,
/// Counts changes, so a scan or load that started before one is not kept.
changes: u64,
}
static STATE: RwLock<State> = RwLock::new(State { dir: None, scan: None, hyphenation: BTreeMap::new(), hyphenation_errors: Vec::new(), changes: 0 });
static STATE: RwLock<State> =
RwLock::new(State { dir: None, scan: None, hyphenation: BTreeMap::new(), hyphenation_bytes: 0, hyphenation_errors: Vec::new(), changes: 0 });
fn read() -> RwLockReadGuard<'static, State> {
STATE.read().unwrap_or_else(PoisonError::into_inner)
@@ -121,6 +131,7 @@ pub fn reload() {
fn forget(s: &mut State) {
s.scan = None;
s.hyphenation.clear();
s.hyphenation_bytes = 0;
s.hyphenation_errors.clear();
s.changes = s.changes.wrapping_add(1);
}
@@ -566,10 +577,8 @@ pub(crate) fn hyphenation(lang: ProofLang) -> Option<Arc<Patterns>> {
let mut s = write();
if s.changes == changes && !s.hyphenation.contains_key(&lang) {
// ponytail: cleared when full, an LRU if many languages each need their patterns.
if s.hyphenation.len() >= MAX_HYPHENATION_LOOKUPS {
s.hyphenation.clear();
}
s.hyphenation.insert(lang, found.clone());
let bytes = found.as_deref().map_or(0, pattern_bytes);
remember_patterns(&mut s, lang, found.clone(), bytes, MAX_HYPHENATION_BYTES);
for e in errors {
if !s.hyphenation_errors.contains(&e) {
s.hyphenation_errors.push(e);
@@ -579,6 +588,23 @@ pub(crate) fn hyphenation(lang: ProofLang) -> Option<Arc<Patterns>> {
found
}
/// Estimated memory of `p`.
fn pattern_bytes(p: &Patterns) -> u64 {
(p.len() as u64).saturating_add(p.exception_count() as u64).saturating_mul(BYTES_PER_PATTERN)
}
/// Remember `lang`'s pack patterns (`bytes` of them): over [`MAX_HYPHENATION_LOOKUPS`] languages
/// or `budget` bytes, the ones remembered so far are forgotten first. Patterns larger than the
/// whole budget are still kept alone, or they would be read from disk for every word.
fn remember_patterns(s: &mut State, lang: ProofLang, found: Option<Arc<Patterns>>, bytes: u64, budget: u64) {
if s.hyphenation.len() >= MAX_HYPHENATION_LOOKUPS || s.hyphenation_bytes.saturating_add(bytes) > budget {
s.hyphenation.clear();
s.hyphenation_bytes = 0;
}
s.hyphenation.insert(lang, found);
s.hyphenation_bytes = s.hyphenation_bytes.saturating_add(bytes);
}
/// How many languages' pack patterns are remembered now.
#[cfg(test)]
pub(crate) fn hyphenation_lookups() -> usize {
@@ -600,6 +626,29 @@ fn load_patterns(path: &Path) -> Result<Patterns, String> {
mod tests {
use super::*;
/// Review follow-up (craft-libs#485): the remembered pack patterns have a memory budget, not
/// only a count, so 256 languages of large patterns can't each stay in memory.
#[test]
fn remembered_patterns_keep_to_a_memory_budget() {
let mut s = State { dir: None, scan: None, hyphenation: BTreeMap::new(), hyphenation_bytes: 0, hyphenation_errors: Vec::new(), changes: 0 };
let p = Arc::new(Patterns::parse("hy3ph he2n hena4 hen5at 1na n2at 1tio 2io o2n"));
let bytes = pattern_bytes(&p);
assert_eq!(bytes, 9 * BYTES_PER_PATTERN);
let lang = |t: &str| ProofLang::from_tag(Some(t));
let budget = bytes * 2;
remember_patterns(&mut s, lang("nl"), Some(p.clone()), bytes, budget);
remember_patterns(&mut s, lang("fr"), Some(p.clone()), bytes, budget);
remember_patterns(&mut s, lang("sv"), None, 0, budget);
assert_eq!((s.hyphenation.len(), s.hyphenation_bytes), (3, budget), "a language with no patterns takes no room");
remember_patterns(&mut s, lang("da"), Some(p.clone()), bytes, budget);
assert_eq!((s.hyphenation.len(), s.hyphenation_bytes), (1, bytes), "over the budget: the others are forgotten");
assert!(s.hyphenation.contains_key(&lang("da")));
remember_patterns(&mut s, lang("fi"), Some(p.clone()), budget * 4, budget);
assert_eq!((s.hyphenation.len(), s.hyphenation_bytes), (1, budget * 4), "patterns over the whole budget are kept alone");
forget(&mut s);
assert_eq!((s.hyphenation.len(), s.hyphenation_bytes), (0, 0));
}
/// Review of #485: a pack's name loses bidirectional and other format characters.
#[test]
fn pack_names_are_plain_text() {