Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/eigenscript.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 25 additions & 0 deletions tests/test_dict.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading