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.52 — not 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 2322 — scribe_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:
- Carry a visited set through
add_structural_list_alias / list_alias_members_for_path so a cyclic alias relation cannot be re-expanded forever.
- And/or cap
index_depth at a saturating maximum — beyond a small depth the extra precision is unlikely to change a diagnostic.
- 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
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
listenstatement — so a web-server program simply never starts, and emits no error at all.A
perfprofile puts the hot loop squarely inTypeChecker::list_alias_members_for_path, and reading the code the cause looks structural:ListAliasPath::index_depthis an unboundedusizethatadd_structural_list_aliaskeeps re-translating upward, so the alias set can never reach a fixpoint.This is worse than a hard error, because there is no diagnostic —
systemdsees 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.wflfrom the public WebFirstLanguage/wfl-web repo reproduces it on its own — it is self-contained, with noinclude from:git clone https://github.com/WebFirstLanguage/wfl-web cd wfl-web wfl lib/scribe.wflSame 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 usualANALYZE-*warnings, prints nolistening on portline, and never accepts a connection.Regression range
try:/when error:pair, and afor eachover anAny-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.Given the timing, PR #652 ("Codex/typechecker contract audit") is the most likely origin, though I have not bisected commits to confirm.
Evidence —
perfprofile of the spinning processperf record -F 199 -gover 6s (1204 samples), release build with debuginfo: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 infutex_waitwhile a worker thread span.Where I think it comes from
src/typechecker/mod.rs:145:src/typechecker/mod.rs:1407synthesizes new paths at a translated depth, with no cap and no visited set:and
add_structural_list_alias(~line 499) feeds newly-materialized deeper descendants straight back into the same map:If two bindings alias each other at different depths, each pass produces a strictly deeper
ListAliasPath, which is a brand-newHashSetkey, 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 atend actionboundaries, the first hanging prefix ends at line 2322 —scribe_hoist_defs:The self-nesting shape looks like the aggravating factor.
scribe_do_importpushes intosc_scopea binding whosevalueis a map holdingbinds— andbindsis itself a list of bindings of the same shape assc_scope:So
sc_scopetransitively contains lists of the same shape assc_scope— exactly the cyclic alias relation that would letindex_depthclimb 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-nestingbinds-inside-scopeshape 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_depthlooks like the thing to fix:add_structural_list_alias/list_alias_members_for_pathso a cyclic alias relation cannot be re-expanded forever.index_depthat a saturating maximum — beyond a small depth the extra precision is unlikely to change a diagnostic.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
wflbuilt from source,cargo build --release(optimized + debuginfo)