A memory leak in a long-running process — a CLI batch job, a queue worker, a daemon — is usually code written for a short life. "Register this callback, it gets cleaned up when the request ends" is fine for one request, but the same code inside a process that runs for hours never gets that cleanup. And the way to find one isn't "watch memory go up". It's to pick a specific number to watch and prove what's driving it, rather than guess.
The method: reproduce it fast (make it fail in minutes, not hours), add logging at a checkpoint the process already hits over and over, narrow from a general signal down to one named cause, then confirm with a differential test — change one thing, rerun the same input, compare.
Two leaks found this way in one debugging session, both with the same root cause: a callback registered once per loop iteration instead of once, and never removed.
// leaks: registers a NEW closure every call, all of it captured in the closure's `use`
function record( $data ) {
add_filter( 'some_filter', function( $x ) use ( $data ) { ... } );
}
// fixed: accumulates into a shared store, registers the filter exactly once
private static $store = [];
private static $filter_added = false;
function record( $data ) {
self::$store[] = $data;
if ( ! self::$filter_added ) {
add_filter( 'some_filter', function( $x ) { /* reads self::$store */ } );
self::$filter_added = true;
}
}
To catch this instead of assuming it, hook into a checkpoint the process already runs on a schedule. In WordPress, ElasticPress runs a stop_the_insanity()-style cleanup after every batch — a good place to log memory_get_usage() next to a breakdown of $wp_filter by hook name, sorted by callback count. When one hook's count climbs steadily while the rest stay flat, that's your leak, named.
debug_zval_refcount and reference countingWP_Hook and how $wp_filter is structuredphp -d memory_limit=64M script.php).
Done when: it fails with an out-of-memory error, and you can point to the exact line causing growth.