Hi,

While reviewing contrib/xml2 I found two successful-path libxml leaks in
XPath functions.

The attached series is:

  0001 Fix libxml string leak in contrib/xml2 xpath_list
  0002 Fix libxml leaks in contrib/xml2 xpath_table

In xpath_list(), the plain separator path passes the result of
xmlXPathCastNodeToString() directly to xmlBufferWriteCHAR().
xmlXPathCastNodeToString() returns a libxml-allocated xmlChar * that
has to be released with xmlFree(), while xmlBufferWriteCHAR() copies
the string rather than taking ownership.

In xpath_table(), xmlXPathCompiledEval() returns an xmlXPathObjectPtr
that has to be released with xmlXPathFreeObject().  The function also
stores libxml-allocated xmlChar strings in the values array before
calling BuildTupleFromCStrings().  BuildTupleFromCStrings() consumes
those strings by converting/copying them into the result tuple, so the
temporary libxml strings can be released after the tuple is built.

The second patch frees the XPath result object after each evaluation,
frees per-column string values after BuildTupleFromCStrings() has
consumed them, and tracks the current libxml allocations across the
existing PG_TRY block so they are also released on error.

The attached manual repro scripts exercise the two paths separately.
They are Linux-specific because they sample VmRSS from
/proc/<backend-pid>/status using pg_read_file(), so they should be run
as a superuser or a role allowed to read server files.

On unpatched origin/master, selected NOTICE lines from the repro scripts
show steady backend RSS growth:

postgres=# \i ./xml2-xpath-list-leak-repro.sql
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=1,
total_kb=38616, diff_kb=17220 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=2, total_kb=40732, diff_kb=2104
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=3,
total_kb=42720, diff_kb=1988 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=4, total_kb=44644, diff_kb=1924
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=5,
total_kb=46440, diff_kb=1796 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=6, total_kb=47968, diff_kb=1528
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=7,
total_kb=50892, diff_kb=2924 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=8, total_kb=52712, diff_kb=1820
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=9,
total_kb=54276, diff_kb=1564 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=10, total_kb=55328, diff_kb=1052

postgres=# \i ./xml2-xpath-table-leak-repro.sql
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=1,
total_kb=26452, diff_kb=5136 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=2, total_kb=27772, diff_kb=1312
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=3,
total_kb=29068, diff_kb=1296 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=4, total_kb=30368, diff_kb=1300
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=5,
total_kb=31668, diff_kb=1300 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=6, total_kb=32968, diff_kb=1300
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=7,
total_kb=34260, diff_kb=1292 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=8, total_kb=35568, diff_kb=1308
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=9,
total_kb=36876, diff_kb=1308 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=10, total_kb=38168, diff_kb=1292

With both patches applied, the same scripts plateau after the initial
allocations:

postgres=# \i ./xml2-xpath-list-leak-repro.sql
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=1,
total_kb=24480, diff_kb=3020 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=2, total_kb=24532, diff_kb=44
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=3,
total_kb=24580, diff_kb=48 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=4, total_kb=24580, diff_kb=0
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=5,
total_kb=24624, diff_kb=44 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=6, total_kb=24580, diff_kb=-44
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=7,
total_kb=24580, diff_kb=0 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=8, total_kb=24580, diff_kb=0
psql:xml2-xpath-list-leak-repro.sql:38: NOTICE:  xpath_list i=9,
total_kb=24580, diff_kb=0 psql:xml2-xpath-list-leak-repro.sql:38:
NOTICE:  xpath_list i=10, total_kb=24580, diff_kb=0

postgres=# \i ./xml2-xpath-table-leak-repro.sql
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=1,
total_kb=26572, diff_kb=188 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=2, total_kb=26600, diff_kb=28
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=3,
total_kb=26600, diff_kb=0 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=4, total_kb=26600, diff_kb=0
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=5,
total_kb=26600, diff_kb=0 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=6, total_kb=26600, diff_kb=0
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=7,
total_kb=26600, diff_kb=0 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=8, total_kb=26600, diff_kb=0
psql:xml2-xpath-table-leak-repro.sql:40: NOTICE:  xpath_table i=9,
total_kb=26600, diff_kb=0 psql:xml2-xpath-table-leak-repro.sql:40:
NOTICE:  xpath_table i=10, total_kb=26600, diff_kb=0

contrib/xml2 regression tests still pass:

    make -C contrib/xml2 check
    # All 1 tests passed.

I also checked that the two patches apply cleanly to current
origin/master with git am.

This is related to, but not fixed by, the recent xml2/libxml
error-handling work from BUG #18943 / commit 732061150b0.  That patch
improved cleanup and OOM handling around libxml calls, while these
cases are successful execution path leaks.

These are long-standing leaks and look like candidates for
back-patching to all supported branches.

--
Andrey Chernyy

Attachment: xml2-xpath-list-leak-repro.sql
Description: application/sql

Attachment: xml2-xpath-table-leak-repro.sql
Description: application/sql

>From fadb35b4b9f24aa00d3604d2885ad009791b3f3b Mon Sep 17 00:00:00 2001
From: Andrey Chernyy <[email protected]>
Date: Mon, 25 May 2026 22:12:02 +0300
Subject: [PATCH 1/2] Fix libxml string leak in contrib/xml2 xpath_list

xmlXPathCastNodeToString() returns a libxml-allocated xmlChar *, but
pgxmlNodeSetToText() passed it directly to xmlBufferWriteCHAR() in the
plain separator path.  Since xmlBufferWriteCHAR() copies the string
rather than taking ownership, successful xpath_list() calls leaked one
string per emitted node.

Store the cast result locally and free it with xmlFree() after writing it
to the buffer.
---
 contrib/xml2/xpath.c | 13 +++++++++++--
 1 file changed, 11 insertions(+), 2 deletions(-)

diff --git a/contrib/xml2/xpath.c b/contrib/xml2/xpath.c
index 7bf477e0c3f..94819961787 100644
--- a/contrib/xml2/xpath.c
+++ b/contrib/xml2/xpath.c
@@ -147,6 +147,7 @@ pgxmlNodeSetToText(xmlNodeSetPtr nodeset,
 {
 	volatile xmlBufferPtr buf = NULL;
 	xmlChar    *volatile result = NULL;
+	xmlChar    *volatile str = NULL;
 	PgXmlErrorContext *xmlerrcxt;
 
 	/* spin up some error handling */
@@ -172,8 +173,14 @@ pgxmlNodeSetToText(xmlNodeSetPtr nodeset,
 			{
 				if (plainsep != NULL)
 				{
-					xmlBufferWriteCHAR(buf,
-									   xmlXPathCastNodeToString(nodeset->nodeTab[i]));
+					str = xmlXPathCastNodeToString(nodeset->nodeTab[i]);
+					if (str == NULL || pg_xml_error_occurred(xmlerrcxt))
+						xml_ereport(xmlerrcxt, ERROR, ERRCODE_OUT_OF_MEMORY,
+									"could not allocate node text");
+
+					xmlBufferWriteCHAR(buf, str);
+					xmlFree(str);
+					str = NULL;
 
 					/* If this isn't the last entry, write the plain sep. */
 					if (i < (nodeset->nodeNr) - 1)
@@ -216,6 +223,8 @@ pgxmlNodeSetToText(xmlNodeSetPtr nodeset,
 	}
 	PG_CATCH();
 	{
+		if (str)
+			xmlFree(str);
 		if (buf)
 			xmlBufferFree(buf);
 
-- 
2.54.0

>From 3430cf722811dd256f205c3fbf3a8e16431913a0 Mon Sep 17 00:00:00 2001
From: Andrey Chernyy <[email protected]>
Date: Tue, 26 May 2026 00:11:06 +0300
Subject: [PATCH 2/2] Fix libxml leaks in contrib/xml2 xpath_table

xpath_table() did not release libxml objects allocated while evaluating
XPath expressions.  xmlXPathCompiledEval() returns an xmlXPathObjectPtr
that must be freed, and string results copied into the tuple input array
must be freed after BuildTupleFromCStrings() has consumed them.

Track the current libxml objects across the existing PG_TRY block so they
are also released on error.
---
 contrib/xml2/xpath.c | 47 +++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 42 insertions(+), 5 deletions(-)

diff --git a/contrib/xml2/xpath.c b/contrib/xml2/xpath.c
index 94819961787..ac140a640e0 100644
--- a/contrib/xml2/xpath.c
+++ b/contrib/xml2/xpath.c
@@ -643,6 +643,10 @@ xpath_table(PG_FUNCTION_ARGS)
 	StringInfoData query_buf;
 	PgXmlErrorContext *xmlerrcxt;
 	volatile xmlDocPtr doctree = NULL;
+	xmlXPathContextPtr volatile ctxt = NULL;
+	xmlXPathObjectPtr volatile res = NULL;
+	xmlXPathCompExprPtr volatile comppath = NULL;
+	xmlChar    *volatile resstr = NULL;
 
 	InitMaterializedSRF(fcinfo, MAT_SRF_USE_EXPECTED_DESC);
 
@@ -662,7 +666,7 @@ xpath_table(PG_FUNCTION_ARGS)
 
 	attinmeta = TupleDescGetAttInMetadata(rsinfo->setDesc);
 
-	values = (char **) palloc(rsinfo->setDesc->natts * sizeof(char *));
+	values = (char **) palloc0(rsinfo->setDesc->natts * sizeof(char *));
 	xpaths = (xmlChar **) palloc(rsinfo->setDesc->natts * sizeof(xmlChar *));
 
 	/*
@@ -732,10 +736,6 @@ xpath_table(PG_FUNCTION_ARGS)
 		{
 			char	   *pkey;
 			char	   *xmldoc;
-			xmlXPathContextPtr ctxt;
-			xmlXPathObjectPtr res;
-			xmlChar    *resstr;
-			xmlXPathCompExprPtr comppath;
 			HeapTuple	ret_tuple;
 
 			/* Extract the row data as C Strings */
@@ -780,6 +780,11 @@ xpath_table(PG_FUNCTION_ARGS)
 					had_values = false;
 					for (j = 0; j < numpaths; j++)
 					{
+						ctxt = NULL;
+						res = NULL;
+						comppath = NULL;
+						resstr = NULL;
+
 						ctxt = xmlXPathNewContext(doctree);
 						if (ctxt == NULL || pg_xml_error_occurred(xmlerrcxt))
 							xml_ereport(xmlerrcxt,
@@ -798,6 +803,7 @@ xpath_table(PG_FUNCTION_ARGS)
 						/* Now evaluate the path expression. */
 						res = xmlXPathCompiledEval(comppath, ctxt);
 						xmlXPathFreeCompExpr(comppath);
+						comppath = NULL;
 
 						if (res != NULL)
 						{
@@ -842,8 +848,16 @@ xpath_table(PG_FUNCTION_ARGS)
 							 * result tuple.
 							 */
 							values[j + 1] = (char *) resstr;
+							resstr = NULL;
+						}
+
+						if (res != NULL)
+						{
+							xmlXPathFreeObject(res);
+							res = NULL;
 						}
 						xmlXPathFreeContext(ctxt);
+						ctxt = NULL;
 					}
 
 					/* Now add the tuple to the output, if there is one. */
@@ -854,6 +868,16 @@ xpath_table(PG_FUNCTION_ARGS)
 						heap_freetuple(ret_tuple);
 					}
 
+					/* BuildTupleFromCStrings() has copied the values. */
+					for (j = 1; j < rsinfo->setDesc->natts; j++)
+					{
+						if (values[j] != NULL)
+						{
+							xmlFree((xmlChar *) values[j]);
+							values[j] = NULL;
+						}
+					}
+
 					rownr++;
 				} while (had_values);
 			}
@@ -870,6 +894,19 @@ xpath_table(PG_FUNCTION_ARGS)
 	}
 	PG_CATCH();
 	{
+		if (resstr != NULL)
+			xmlFree(resstr);
+		for (j = 1; j < rsinfo->setDesc->natts; j++)
+		{
+			if (values[j] != NULL)
+				xmlFree((xmlChar *) values[j]);
+		}
+		if (res != NULL)
+			xmlXPathFreeObject(res);
+		if (comppath != NULL)
+			xmlXPathFreeCompExpr(comppath);
+		if (ctxt != NULL)
+			xmlXPathFreeContext(ctxt);
 		if (doctree != NULL)
 			xmlFreeDoc(doctree);
 
-- 
2.54.0

Reply via email to