Skip to content

26.7.53: typechecker never terminates in list_alias_members_for_path — unbounded index_depth means a web server silently never starts (26.7.47 is fine) #654

Description

@logbie

Summary

Starting with 26.7.53, the typechecker fails to terminate on a large real-world program. The process pins one core at ~100% CPU, grows memory steadily, and never reaches the listen statement — so a web-server program simply never starts, and emits no error at all.

A perf profile puts the hot loop squarely in TypeChecker::list_alias_members_for_path, and reading the code the cause looks structural: ListAliasPath::index_depth is an unbounded usize that add_structural_list_alias keeps re-translating upward, so the alias set can never reach a fixpoint.

This is worse than a hard error, because there is no diagnostic — systemd sees a live process that never binds its port.

Impact

This blocks https://wfl.fyi (Scriptorium CMS, WFL-native TLS) from moving off 26.7.47, which is where it is pinned in production today.

Reproduction

lib/scribe.wfl from the public WebFirstLanguage/wfl-web repo reproduces it on its own — it is self-contained, with no include from:

git clone https://github.com/WebFirstLanguage/wfl-web
cd wfl-web
wfl lib/scribe.wfl
Runtime Result
26.7.47 exits 0 in well under a second
26.7.53 hangs indefinitely (killed at 60s+), 99.9% CPU, RSS climbing

Same binary, same file, same working directory — the only variable is the runtime.

Running the full site (wfl main.wfl) shows the practical failure: the process stays alive, logs only its usual ANALYZE-* warnings, prints no listening on port line, and never accepts a connection.

Regression range

  • 26.7.47 — fine. This is what production runs.
  • 26.7.52not this bug. It failed differently, with two hard typechecker errors (an outer-scope redeclaration in a try:/when error: pair, and a for each over an Any-typed collection). Both of those are fixed in 26.7.53 — I re-tested both constructs and they now pass. Recorded from a prior run rather than re-verified here, since I no longer have a 26.7.52 binary.
  • 26.7.53 — this hang.
  • 26.7.48–26.7.51 — untested.

Given the timing, PR #652 ("Codex/typechecker contract audit") is the most likely origin, though I have not bisected commits to confirm.

Evidence — perf profile of the spinning process

perf record -F 199 -g over 6s (1204 samples), release build with debuginfo:

26.32%  hashbrown::map::HashMap<K,V,S,A>::insert
14.27%  core::hash::BuildHasher::hash_one
11.04%  [unresolved] -> wfl::typechecker::TypeChecker::list_alias_members_for_path
 6.09%  wfl::typechecker::TypeChecker::list_alias_members_for_path
 5.19%  core::hash::sip::Hasher::write
 3.61%  malloc

Consistent with an unbounded insert loop into list_alias_groups. Observed RSS went 59 MB → 80 MB over ~35s of spinning, with the main thread parked in futex_wait while a worker thread span.

Where I think it comes from

src/typechecker/mod.rs:145:

struct ListAliasPath {
    binding: SymbolBindingKey,
    index_depth: usize,   // unbounded
}

src/typechecker/mod.rs:1407 synthesizes new paths at a translated depth, with no cap and no visited set:

fn list_alias_members_for_path(&self, path: &ListAliasPath) -> HashSet<ListAliasPath> {
    ...
    for (ancestor, aliases) in &self.list_alias_groups {
        if ancestor.binding == path.binding && ancestor.index_depth <= path.index_depth {
            let offset = path.index_depth - ancestor.index_depth;
            for alias in aliases {
                members.insert(ListAliasPath {
                    binding: alias.binding.clone(),
                    index_depth: alias.index_depth + offset,   // grows without bound
                });
            }
        }
    }
    members
}

and add_structural_list_alias (~line 499) feeds newly-materialized deeper descendants straight back into the same map:

let translated = ListAliasPath {
    binding: target.binding.clone(),
    index_depth: target.index_depth + descendant.index_depth - source.index_depth,
};

If two bindings alias each other at different depths, each pass produces a strictly deeper ListAliasPath, which is a brand-new HashSet key, which then qualifies as a "descendant" on the next pass. Nothing bounds the recursion, so the worklist never converges.

Which construct triggers it

Bisecting lib/scribe.wfl (100 actions) by truncating at end action boundaries, the first hanging prefix ends at line 2322scribe_hoist_defs:

define action called scribe_hoist_defs with parameters sc_nodes and sc_ctx and sc_scope:
    for each nd in sc_nodes:
        store k as nd["kind"]
        check if k is "macro":
            store mkey as nd["name"]
            check if scribe_negate of (scribe_scope_has of mkey and sc_scope):
                push with sc_scope and (scribe_binding of mkey and (scribe_macro_value of nd))
            end check
        otherwise:
            check if k is "import":
                store ig as scribe_do_import of nd and sc_ctx and sc_scope
            ...

The self-nesting shape looks like the aggravating factor. scribe_do_import pushes into sc_scope a binding whose value is a map holding binds — and binds is itself a list of bindings of the same shape as sc_scope:

store binds as scribe_macro_bindings of nds
create map nsval:
    "__scribe_ns__" is yes
    "binds" is binds
end map
push with sc_scope and (scribe_binding of alias and nsval)

So sc_scope transitively contains lists of the same shape as sc_scope — exactly the cyclic alias relation that would let index_depth climb forever.

I could not reduce this to a short snippet. Several hand-written candidates (pushing into a parameter list inside a for each, pushing values derived from the loop variable via nested calls, and the self-nesting binds-inside-scope shape above) all typecheck fine in isolation on 26.7.53. That suggests the blow-up needs a critical mass of interacting alias edges accumulated across the whole ~2374-line file rather than one construct — which may itself be a useful clue about where the fixpoint is missing.

Suggested fix

Whatever the specific trigger, the unbounded index_depth looks like the thing to fix:

  1. Carry a visited set through add_structural_list_alias / list_alias_members_for_path so a cyclic alias relation cannot be re-expanded forever.
  2. And/or cap index_depth at a saturating maximum — beyond a small depth the extra precision is unlikely to change a diagnostic.
  3. A hard iteration ceiling on the alias fixpoint, degrading to a conservative "may alias" answer, would turn any remaining non-convergence into a bounded slowdown instead of a hang.

Regardless of the root cause, it would help a great deal if a typechecker that fails to converge timed out with a diagnostic rather than hanging silently. A web server that never binds and never explains why is very hard to diagnose in production.

Environment

  • Ubuntu 24.04, x86_64, 2 vCPU / 8 GB
  • wfl built from source, cargo build --release (optimized + debuginfo)
  • Compared against 26.7.47 built the same way on the same machine

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions