From fc55c41aac3e10cf5eee2c17293b2a0ffc94dc05 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Thu, 9 Jul 2026 20:57:51 -0500 Subject: [PATCH] fix(dict): dict_remove no longer inflates the hash table exponentially MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit env_hash_rebuild blindly doubled the old capacity — right for the grow-on-insert callers (all rebuild at >70% load), catastrophic for dict_remove's re-index rebuild: N removes on one dict grew its table by 2^N, so ~25 insert/remove cycles of a single key allocated gigabytes and aborted OOM. Size the new table from the live entry count instead — identical doubling behavior at the grow sites, no inflation on remove. Surfaced by liferaft's per-message pending-registry churn during the #523 task-layer migration; regression-pinned with a 200-cycle churn test (impossible pre-fix, instant post-fix) plus a survivors-resolve check in tests/test_dict.eigs. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++++++++ src/eigenscript.c | 11 +++++++++-- tests/test_dict.eigs | 25 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be3319..54818ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,6 +191,14 @@ All notable changes to EigenScript are documented here. (the one legitimate empty-expression position) still yields `null` (#494). ### Fixed +- **`dict_remove` no longer inflates the dict's hash table exponentially.** + The re-index rebuild after a removal reused the grow path's blind + capacity-doubling, so N removes on one dict grew its table by 2^N — + ~25 insert/remove cycles of a single key allocated gigabytes and OOMed + the process. The rebuild now sizes the table from the live entry count + (identical doubling behavior on the >70%-load grow path). Surfaced by + liferaft's per-message pending-registry churn during the #523 task-layer + migration; regression-pinned in `tests/test_dict.eigs` (200-cycle churn). - **`args` now rides the trace tape (#471).** The `args` builtin returned `argv` directly, unwrapped — the last un-taped nondeterminism source reachable by a pure script, and a hole in the closed-world invariant behind diff --git a/src/eigenscript.c b/src/eigenscript.c index 3dc1820..1fc7b37 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -1291,8 +1291,15 @@ void env_hash_insert(EnvHash *ht, uint32_t h, int idx) { } static void env_hash_rebuild(EnvHash *ht, char **names, int count) { - int new_cap = (ht->mask + 1) * 2; - if (new_cap < ENV_HASH_INIT_CAP) new_cap = ENV_HASH_INIT_CAP; + /* Size from the live entry count, never by blindly doubling the old + * capacity. Every grow-path caller rebuilds at >70% load, so 2x-count + * lands on the same doubling as before — but dict_remove rebuilds to + * RE-INDEX after a removal, and blind doubling there inflated a + * shrinking dict's table by 2^N over N removes: ~25 removes of a single + * key OOMed the process (surfaced by liferaft's per-message registry + * churn during the #523 task-layer migration). */ + int new_cap = ENV_HASH_INIT_CAP; + while (new_cap < count * 2) new_cap *= 2; free(ht->hashes); free(ht->indices); free(ht->generations); diff --git a/tests/test_dict.eigs b/tests/test_dict.eigs index ec576d5..e821b24 100644 --- a/tests/test_dict.eigs +++ b/tests/test_dict.eigs @@ -59,4 +59,29 @@ assert_eq of [app.db.port, 5432, "nested dot number"] msg is f"name={d.name}" assert_eq of [msg, "name=eigen", "dict in f-string"] +# Repeated insert/remove on one dict must not inflate its hash table: the +# re-index rebuild after a remove used to blindly DOUBLE capacity, going +# exponential (2^N after N removes -- OOM at ~25; caught by liferaft's +# per-message registry churn, #523 migration). 200 cycles is impossible +# pre-fix and instant post-fix. +churn is {} +ci is 0 +loop while ci < 200: + churn["k"] is ci + dict_remove of [churn, "k"] + ci is ci + 1 +assert_eq of [ci, 200, "200 insert/remove cycles complete (no exponential rebuild)"] +assert_eq of [len of churn, 0, "churned dict ends empty"] + +# Same churn against a dict that keeps OTHER live keys: removes re-index the +# survivors, lookups must stay correct throughout. +churn2 is {"keep1": 1, "keep2": 2} +ci is 0 +loop while ci < 50: + churn2["tmp"] is ci + dict_remove of [churn2, "tmp"] + ci is ci + 1 +assert_eq of [churn2.keep1 + churn2.keep2, 3, "surviving keys resolve after churn"] +assert_eq of [len of churn2, 2, "only survivors remain"] + test_summary of null