Context
Follow-up to the memory-lifetime overhaul (#62, merged in #63). One bounded residual was deliberately left in place and documented in docs/long-running.md: FunctionLikeTrait::redefine() on a user-defined function/method leaks the previous function body.
Current behaviour
redefine() (src/Reflection/FunctionLikeTrait.php) copies the closure's zend_function wholesale over the original one. The original op_array's exclusive allocations — opcodes, literals, vars, live ranges — become unreachable and stay allocated:
Zend/zend_opcode.c(1111): Freeing ... (opcode array)
Zend/zend_compile.c(7573): Freeing ... (literals)
Zend/zend_string.h(176/252): Freeing ... (strings)
The leak is bounded: one previous body per redefined function, not per call in a loop over the same function... actually it is per redefine() call, so a worker that repeatedly redefines the same function grows without bound. Realistic usage (redefine once at boot) leaks a few hundred bytes per function. ReflectionFunctionTest::testRedefine and ReflectionMethodTest::testRedefine currently carry a commented ini_set('report_memleaks', '0') exemption for exactly this.
Why it was not fixed in #63
A wholesale destroy_op_array() on the original before the overwrite is incorrect, because redefine() first copies the original's common struct into the closure's function to preserve identity — after that copy the two functions share raw pointers:
function_name (zend_string*, refcounted — could be addref'd before destroy)
arg_info (plain allocated array, not refcounted — destroying the original frees it while the redefined entry still points at it → use-after-free on any reflection of parameters)
doc_comment and friends
And the two candidate primitives have export problems:
destroy_op_array is a real ZEND_API export and could be added to tools/generator/symbols.php, but is too coarse while pointers are shared;
_efree (for freeing just the exclusive arrays) cannot be declared safely with one signature: debug builds add ZEND_FILE_LINE_DC parameters that are actually used, so a hand-authored release-shape declaration would pass garbage file/line pointers on the debug CI build.
Possible directions
- Unshare then destroy — before the overwrite: addref
function_name/doc_comment, duplicate arg_info (size is computable from num_args + variadic + return-type slot), then export destroy_op_array and destroy the original. Most complete, most surgery.
- Bucket-pointer replacement — instead of memcpy-ing over the original struct, point the function/method-table bucket at the closure's
zend_function (like addRawMethod() does), keep the closure alive in a Core-level registry, and release the original entry through the table's own zend_function_dtor. Avoids manual freeing entirely, but changes redefine() mechanics and still needs the name/scope fixups plus the shared-pointer audit.
- Leave as documented residual — acceptable for redefine-once-at-boot usage; the docs and test exemptions already state it.
Whichever direction wins, the acceptance test is simple: remove the ini_set('report_memleaks', '0') exemptions from the two testRedefine tests and drop the corresponding row from the immortal-by-design table in docs/long-running.md; the debug leak gate then enforces it.
Related (separate, probably fine as-is): addMethod() intentionally immortalizes the closure body because the class method table stores a pointer into the closure object — releasing it at any userland-reachable moment would dangle the method entry during class destruction.
Not urgent — filed to keep the option on the table.
Context
Follow-up to the memory-lifetime overhaul (#62, merged in #63). One bounded residual was deliberately left in place and documented in
docs/long-running.md:FunctionLikeTrait::redefine()on a user-defined function/method leaks the previous function body.Current behaviour
redefine()(src/Reflection/FunctionLikeTrait.php) copies the closure'szend_functionwholesale over the original one. The original op_array's exclusive allocations — opcodes, literals, vars, live ranges — become unreachable and stay allocated:The leak is bounded: one previous body per redefined function, not per call in a loop over the same function... actually it is per
redefine()call, so a worker that repeatedly redefines the same function grows without bound. Realistic usage (redefine once at boot) leaks a few hundred bytes per function.ReflectionFunctionTest::testRedefineandReflectionMethodTest::testRedefinecurrently carry a commentedini_set('report_memleaks', '0')exemption for exactly this.Why it was not fixed in #63
A wholesale
destroy_op_array()on the original before the overwrite is incorrect, becauseredefine()first copies the original'scommonstruct into the closure's function to preserve identity — after that copy the two functions share raw pointers:function_name(zend_string*, refcounted — could be addref'd before destroy)arg_info(plain allocated array, not refcounted — destroying the original frees it while the redefined entry still points at it → use-after-free on any reflection of parameters)doc_commentand friendsAnd the two candidate primitives have export problems:
destroy_op_arrayis a realZEND_APIexport and could be added totools/generator/symbols.php, but is too coarse while pointers are shared;_efree(for freeing just the exclusive arrays) cannot be declared safely with one signature: debug builds addZEND_FILE_LINE_DCparameters that are actually used, so a hand-authored release-shape declaration would pass garbage file/line pointers on the debug CI build.Possible directions
function_name/doc_comment, duplicatearg_info(size is computable fromnum_args+ variadic + return-type slot), then exportdestroy_op_arrayand destroy the original. Most complete, most surgery.zend_function(likeaddRawMethod()does), keep the closure alive in a Core-level registry, and release the original entry through the table's ownzend_function_dtor. Avoids manual freeing entirely, but changesredefine()mechanics and still needs the name/scope fixups plus the shared-pointer audit.Whichever direction wins, the acceptance test is simple: remove the
ini_set('report_memleaks', '0')exemptions from the twotestRedefinetests and drop the corresponding row from the immortal-by-design table indocs/long-running.md; the debug leak gate then enforces it.Related (separate, probably fine as-is):
addMethod()intentionally immortalizes the closure body because the class method table stores a pointer into the closure object — releasing it at any userland-reachable moment would dangle the method entry during class destruction.Not urgent — filed to keep the option on the table.