Skip to content

dom: report the DOMXPath callback node list to the cycle collector - #23596

Open
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:promote/dom-xpath-node-list-gc
Open

dom: report the DOMXPath callback node list to the cycle collector#23596
iliaal wants to merge 1 commit into
php:masterfrom
iliaal:promote/dom-xpath-node-list-gc

Conversation

@iliaal

@iliaal iliaal commented Sep 6, 2026

Copy link
Copy Markdown
Member

php_dom_xpath_callbacks_get_gc() traced php_ns and namespaces but not node_list, which holds the nodes passed to and returned from php:function callbacks. A cycle from a DOMXPath through node_list to a node and back to the same DOMXPath was invisible to the collector, so it was never freed.

node_list is still cleared at object destruction rather than at the end of each evaluation. Clearing it eagerly is not safe, because a callback can run a nested query()/evaluate() on the same object and the inner call would free wrappers the outer evaluation is still using.

@iliaal
iliaal requested a review from devnexen as a code owner September 6, 2026 15:07
iliaal added a commit to iliaal/php-src that referenced this pull request Sep 6, 2026
php_dom_xpath_callbacks_get_gc() traced php_ns and namespaces but not
node_list, which holds the nodes handed to and returned from php:function
callbacks. A cycle running DOMXPath to node_list to node and back to the
same DOMXPath was therefore invisible to the collector and never freed.

Closes phpGH-23596
@iliaal
iliaal force-pushed the promote/dom-xpath-node-list-gc branch from 472bca6 to 95b6eb9 Compare September 6, 2026 15:08
@devnexen

devnexen commented Sep 8, 2026

Copy link
Copy Markdown
Member

can the following be tested ?

--TEST--
XSLTProcessor: cycle collection triggered while the php:function node list is torn down
--EXTENSIONS--
dom
xsl
--FILE--
<?php
class GcElement extends DOMElement
{
    public function __destruct()
    {
        gc_collect_cycles();
    }
}

$xml = new DOMDocument();
$xml->loadXML('<root><a/><b/><c/><d/><e/></root>');
$xml->registerNodeClass(DOMElement::class, GcElement::class);

$xsl = new DOMDocument();
$xsl->loadXML(<<<XSL
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:php="http://php.net/xsl">
<xsl:template match="/"><out><xsl:for-each select="/root/*"><xsl:value-of select="php:function('cb', .)"/></xsl:for-each></out></xsl:template>
</xsl:stylesheet>
XSL);

function cb(array $nodes): string
{
    return $nodes[0]->nodeName;
}

$proc = new XSLTProcessor();
$proc->registerPHPFunctions();
$proc->importStylesheet($xsl);

$root_buffer = $proc;
unset($root_buffer);

echo $proc->transformToXml($xml);
echo 'done', PHP_EOL;
?>
--EXPECT--
<?xml version="1.0"?>
<out xmlns:php="http://php.net/xsl">abcde</out>
done

iliaal added a commit to iliaal/php-src that referenced this pull request Sep 8, 2026
php_dom_xpath_callbacks_get_gc() traced php_ns and namespaces but not
node_list, which holds the nodes handed to and returned from php:function
callbacks. A cycle running DOMXPath to node_list to node and back to the
same DOMXPath was therefore invisible to the collector and never freed.

XSLTProcessor shares the registry and so the same handler, and is affected
whenever a transform does not reach its own node list cleanup, which a
callback suspending a Fiber achieves. Both surfaces are covered.

Closes phpGH-23596
@iliaal
iliaal force-pushed the promote/dom-xpath-node-list-gc branch from 95b6eb9 to d50dcd5 Compare September 8, 2026 16:46
@iliaal

iliaal commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

That exact test passes with and without the patch, so it wouldn't pin anything: transformToXml() clears the node list at the end of every transform (xsltprocessor.c:419), so a completed transform leaves nothing to close a cycle through.

It is reachable though, just not via a completed transform. A callback that suspends a Fiber never reaches that cleanup, and then the processor leaks on unpatched master. Added ext/xsl/tests/xsltprocessor_callback_node_list_gc.phpt doing exactly that, red before the patch and green after.

@devnexen

devnexen commented Sep 8, 2026

Copy link
Copy Markdown
Member

Ok let s try this one out (debug build)

--TEST--
XSLTProcessor: cycle collection triggered while the php:function node list is torn down
--EXTENSIONS--
dom
xsl
--FILE--
<?php
class GcElement extends DOMElement
{
    private static int $destroyed = 0;

    public function __destruct()
    {
        /* Collect once the node list teardown has already freed some entries. */
        if (++self::$destroyed === 3) {
            gc_collect_cycles();
        }
    }
}

$xml = new DOMDocument();
$xml->loadXML('<root><a/><b/><c/><d/><e/><f/><g/><h/></root>');
$xml->registerNodeClass(DOMElement::class, GcElement::class);

$xsl = new DOMDocument();
$xsl->loadXML(<<<XSL
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:php="http://php.net/xsl">
<xsl:template match="/"><out><xsl:for-each select="/root/*"><xsl:value-of select="php:function('cb', .)"/></xsl:for-each></out></xsl:template>
</xsl:stylesheet>
XSL);

function cb(array $nodes): string
{
    return $nodes[0]->nodeName;
}

$proc = new XSLTProcessor();
$proc->registerPHPFunctions();
$proc->importStylesheet($xsl);

$root_buffer = $proc;
unset($root_buffer);

echo $proc->transformToXml($xml);
echo 'done', PHP_EOL;
?>
--EXPECT--
<?xml version="1.0"?>
<out xmlns:php="http://php.net/xsl">abcdefgh</out>
done

php_dom_xpath_callbacks_get_gc() traced php_ns and namespaces but not
node_list, which holds the nodes handed to and returned from php:function
callbacks, so a cycle running DOMXPath to node_list to node and back was
invisible to the collector. XSLTProcessor shares the registry and is
affected whenever a transform does not reach its own node list cleanup,
which a callback suspending a Fiber achieves. Reporting the list also makes
it reachable while being torn down, so clean_node_list() detaches the table
before destroying it; otherwise a node destructor calling
gc_collect_cycles() has the collector read and write entries that
zend_hash_destroy() already freed.

Closes phpGH-23596
@iliaal
iliaal force-pushed the promote/dom-xpath-node-list-gc branch from d50dcd5 to c4d1821 Compare September 8, 2026 17:31
@iliaal

iliaal commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

That one bites. Valgrind shows gc_mark_grey() reading and writing entries that zend_hash_destroy() had already freed, reached through the new get_gc while clean_node_list() was still mid-teardown. Detached the table before destroying it, and added your test as ext/xsl/tests/xsltprocessor_callback_node_list_gc_teardown.phpt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants