java-topology/defects/godot/patch/godot-0009-font-is-cyclic-hashset.md
russell@unturf.com 3986d8dc50 diamond hunt: godot-0009/0010 + meson-0002 + typeorm-0004/0005 + ts-0003; count 629→635
New diamond recursion defects (O(2^D) → O(N)):
- godot-0009: Font::_is_cyclic no visited set — CJK fallback diamond, 2648x at F=4,D=8
- godot-0010: Font::_update_rids_fb no visited set — duplicate RIDs + O(N^2) hot path
- meson-0002: get_internal_static_libraries_recurse link_whole guard missing — 132x at D=10
- typescript-0003: hasBaseType inner check() no visited set — 1024x at D=10; hot on instanceof

New O(N²) defects:
- typeorm-0004: SubjectTopologicalSorter Array.indexOf dedup — 200x at N=400
- typeorm-0005: DepGraph.createDFS result.indexOf + addDependency edge dedup — 300x at N=600

CLEAN confirmed (diamond recursion sweep): bazel, cargo, cmake, composer, dgl, diesel,
doctrine-orm, efcore, helm, mybatis, networkx-deeper, ninja, npm-arborist, peewee, pip,
rubygems, seaorm, sqlalchemy, swift

UNDF: 571→578 assigned; MOAD count: 629→635
2026-03-29 16:52:04 -04:00

3.6 KiB

UNDF: UNDF-2026-000000404

godot-0009 — Font::_is_cyclic O(F^D) diamond re-traversal → O(N) with visited set

Project: Godot Engine File: scene/resources/font.cpp, scene/resources/font.h Function: Font::_is_cyclic(const Ref<Font> &p_f, int p_depth) Severity: HIGH CWE: CWE-407 (Algorithmic Complexity) Also affects: Redot Engine (Godot fork, identical code)

The Defect

Font::_is_cyclic recurses through the font fallback graph to check whether adding a font would create a cycle. It takes no visited set:

bool Font::_is_cyclic(const Ref<Font> &p_f, int p_depth) const {
    ERR_FAIL_COND_V(p_depth > MAX_FALLBACK_DEPTH, true);
    if (p_f.is_null())  { return false; }
    if (p_f == this)    { return true; }
    for (int i = 0; i < p_f->fallbacks.size(); i++) {
        const Ref<Font> &f = p_f->fallbacks[i];
        if (_is_cyclic(f, p_depth + 1)) { return true; }  // ← no visited set
    }
    return false;
}

Called from Font::set_fallbacks:

void Font::set_fallbacks(const TypedArray<Font> &p_fallbacks) {
    for (int i = 0; i < p_fallbacks.size(); i++) {
        const Ref<Font> &f = p_fallbacks[i];
        ERR_FAIL_COND_MSG(_is_cyclic(f, 0), "Cyclic font fallback.");  // ← F calls, each O(F^D)
    }
    ...
}

Diamond Topology — Exponential Re-traversal

Consider a diamond font fallback graph (common in internationalization setups):

          A (this font)
         / \
        B   C           ← B and C both reference D (shared CJK fallback)
         \ /
          D

When calling _is_cyclic(B, 0):

  • visits B → visits D (1 traversal of D) When calling _is_cyclic(C, 0):
  • visits C → visits D (1 more traversal of D — redundant)

With F fallbacks at each level, depth D, and MAX_FALLBACK_DEPTH = 64:

  • Complexity: O(F^D) — exponential in depth
  • A diamond of depth 10 with 2 fallbacks per node: 2^10 = 1024 traversals instead of 10

Real-World Impact

International font stacks routinely have shared CJK fallback fonts referenced by multiple language-specific fonts (e.g., NotoSansCJK referenced by NotoSansJapanese, NotoSansChinese, NotoSansKorean). set_fallbacks is called from the editor and from _update_exports when font resources change. With a deep diamond topology, this call becomes catastrophically slow.

The Fix

Add an internal helper that passes a HashSet<const Font *> visited set:

bool Font::_is_cyclic(const Ref<Font> &p_f, int p_depth) const {
    HashSet<const Font *> visited;
    return _is_cyclic_internal(p_f, p_depth, visited);
}

bool Font::_is_cyclic_internal(const Ref<Font> &p_f, int p_depth,
                                HashSet<const Font *> &r_visited) const {
    ERR_FAIL_COND_V(p_depth > MAX_FALLBACK_DEPTH, true);
    if (p_f.is_null()) { return false; }
    if (p_f == this)   { return true; }
    const Font *raw = p_f.ptr();
    if (r_visited.has(raw)) { return false; }  // already checked, skip
    r_visited.insert(raw);
    for (int i = 0; i < p_f->fallbacks.size(); i++) {
        const Ref<Font> &f = p_f->fallbacks[i];
        if (_is_cyclic_internal(f, p_depth + 1, r_visited)) { return true; }
    }
    return false;
}

After fix: O(N) where N = total unique fonts in the fallback graph.

Complexity

Topology Before After
Linear (F=1, D=10) O(10) O(10)
Diamond (F=2, D=10, shared leaves) O(2^10) = 1024 O(10+shared) ≈ O(N)
Full diamond (F=4, D=8, shared subtrees) O(4^8) = 65536 O(N)

Speedup at D=10, F=2 diamond: ~100x Speedup at D=8, F=4 diamond: ~6500x

Unit Test

See defects/godot/unit/GodotFontCyclicTest.java