The substrate half of lisachenko/native-php-coroutines#34 (instance-graph reclamation). Identified during the PR #23 design pass and deferred; the motivating workload has since been measured: the coroutines slot-recycling session reproduced sustained panics exhausting the default 256-entry entries table on unmodified main — one SharedError instance graph per panic, ArenaException::registryTableFull as the wall.
Today's behaviour
Registry::assertRegistryRoom() refuses any non-upsert insert into an arena-resident registry table once nNumUsed == nTableSize. That conservatism is correct as far as it goes — an engine growth would perealloc() shared storage into one process's private heap and silently unshare the registry — but it is tombstone-blind: zend_hash deletion marks buckets IS_UNDEF without decrementing nNumUsed, so a table that has seen drop() / dropInstance() traffic reports full while holding reclaimable slots. Net effect: dropping entries buys registry hygiene but no capacity, and a long-running family still hits the wall at (table size) total persists, not live ones.
The provable branch
The engine's own resize decision, zend_hash_do_resize():
if (ht->nNumUsed > ht->nNumOfElements + (ht->nNumOfElements >> 5)) {
zend_hash_rehash(ht); /* compacts IN PLACE — no reallocation */
} else {
/* doubles the table — the forbidden path */
}
Both operands are readable from the table header at insert time, so the guard can mirror the arithmetic exactly: allow the insert when nNumUsed - nNumOfElements > nNumOfElements >> 5 (the in-place rehash is guaranteed, storage never moves), refuse otherwise as today. zend_hash_rehash memsets the hash area and compacts buckets within the existing block — arena-safe by construction. The logic is algorithmic, not offset-based, and stable across both supported minors; assertArenaResident() remains the after-the-fact tripwire if the mirror ever drifts from the engine.
Scope notes
Acceptance criteria
The substrate half of lisachenko/native-php-coroutines#34 (instance-graph reclamation). Identified during the PR #23 design pass and deferred; the motivating workload has since been measured: the coroutines slot-recycling session reproduced sustained panics exhausting the default 256-entry
entriestable on unmodified main — oneSharedErrorinstance graph per panic,ArenaException::registryTableFullas the wall.Today's behaviour
Registry::assertRegistryRoom()refuses any non-upsert insert into an arena-resident registry table oncenNumUsed == nTableSize. That conservatism is correct as far as it goes — an engine growth wouldperealloc()shared storage into one process's private heap and silently unshare the registry — but it is tombstone-blind:zend_hashdeletion marks bucketsIS_UNDEFwithout decrementingnNumUsed, so a table that has seendrop()/dropInstance()traffic reports full while holding reclaimable slots. Net effect: dropping entries buys registry hygiene but no capacity, and a long-running family still hits the wall at (table size) total persists, not live ones.The provable branch
The engine's own resize decision,
zend_hash_do_resize():Both operands are readable from the table header at insert time, so the guard can mirror the arithmetic exactly: allow the insert when
nNumUsed - nNumOfElements > nNumOfElements >> 5(the in-place rehash is guaranteed, storage never moves), refuse otherwise as today.zend_hash_rehashmemsets the hash area and compacts buckets within the existing block — arena-safe by construction. The logic is algorithmic, not offset-based, and stable across both supported minors;assertArenaResident()remains the after-the-fact tripwire if the mirror ever drifts from the engine.Scope notes
assertRegistryRoom()already points at the eventual clean shape — a z-engine re-attachment API (Allocator seam for persistent graph cloning + external arData install API (PR-Z1) z-engine#223) letting a borrowed view adopt its external block, at which point z-engine's own external-storage guard covers recovered tables. The compact-aware mirror is the useful intermediate that unblocks reclamation now, and z-engine gets the ordinary care (a named API upstream, no reach-through), so the two are not in conflict.dropInstance()and when) is Instance graphs are never reclaimed: one task graph per unpublished spawn, one SharedError per panic — sustained panics exhaust the registry's entries table native-php-coroutines#34; this issue is only about making a returned entry insertable again.Acceptance criteria