Skip to content

Garbage collection stops after exception in SoapClient #10026

Open

Description

@abienvenu

Description

The following code:

<?php

class A
{
	public $b;
}

class B
{
	public $a;
}

function buildCycle()
{
	$a = new A();
	$b = new B();
	$a->b = $b;
	$b->a = $a;
}

function failySoapCall()
{

	try {
		new SoapClient('https://nowhere.inexistent.space/?WSDL');
	}
	catch (Exception) {
		echo "Soap call failed\n";
	}
}

function gc()
{
	gc_collect_cycles();
	echo "GC Runs: " . gc_status()['runs'] . "\n";
}


buildCycle();
gc();
buildCycle();
gc();
buildCycle();
gc();

failySoapCall();

buildCycle();
gc();
buildCycle();
gc();
buildCycle();
gc();

Resulted in this output:

GC Runs: 1
GC Runs: 2
GC Runs: 3
Soap call failed
GC Runs: 3
GC Runs: 3
GC Runs: 3

But I expected this output instead:

GC Runs: 1
GC Runs: 2
GC Runs: 3
Soap call failed
GC Runs: 4
GC Runs: 5
GC Runs: 6

The SOAP exception apparently stops the garbage collector. I experienced this problem with a large, long running process. Its memory usage is intensive but stable. Until it encounters a SOAP exception: its memory usage skyrockets, and gc_status() reports that no more gc run is done.

PHP Version

PHP 8.1.13

Operating System

Alpine 3.16

Activity

  1. cmb69 commented on Dec 1, 2022

    @cmb69
    Member

    The problem is Soap's error handling, which calls _zend_bailout() and there:

    gc_protect(1);

    The fix for the reported issue appears to be trivial:

     ext/soap/soap.c | 3 ++-
     1 file changed, 2 insertions(+), 1 deletion(-)
    
    diff --git a/ext/soap/soap.c b/ext/soap/soap.c
    index de29917cd8..5b02f74ba9 100644
    --- a/ext/soap/soap.c
    +++ b/ext/soap/soap.c
    @@ -124,7 +124,8 @@ static void soap_error_handler(int error_num, zend_string *error_filename, const
     	SOAP_GLOBAL(soap_version) = _old_soap_version;\
     	if (_bailout) {\
     		zend_bailout();\
    -	}
    +	}\
    +	gc_protect(0);
     
     #define FETCH_THIS_SDL(ss) \
     	{ \

    However, there are more issues in other places (probably not only in ext/soap).

  2. ndossche commented on Jan 8, 2023

    @ndossche
    Member

    If no one is working on this yet, I can try to check other places too soon-ish, and write a PR.

  3. cmb69 commented on Jan 8, 2023

    @cmb69
    Member

    If no one is working on this yet, I can try to check other places too soon-ish, and write a PR.

    Great! I'm not planning to work on this. :)

  4. added a commit that references this issue on Jan 10, 2023
  5. likemusic commented on Oct 14, 2026

    @likemusic

    Still reproducible on PHP 8.3.33 (bundled ext-soap), ten years after #66151 and three years after this report. Two details that may help narrow down the bailout path.

    1. It is the WSDL parse path, not SoapFault in general.

    A failed WSDL parse flips gc_status()['protected'] to true permanently. No network is needed — a malformed local file is enough:

    <?php
    $f = tempnam(sys_get_temp_dir(), 'wsdl');
    file_put_contents($f, 'not a wsdl at all');
    
    var_dump(gc_status()['protected']);   // bool(false)
    try { new SoapClient($f); } catch (Throwable $e) { echo get_class($e), ": ", $e->getMessage(), "\n"; }
    var_dump(gc_status()['protected']);   // bool(true)
    
    gc_disable(); gc_enable();
    var_dump(gc_status()['protected']);   // bool(true) — still protected

    A client created without WSDL whose call fails behaves differently — same SoapFault class, collector untouched:

    <?php
    var_dump(gc_status()['protected']);   // bool(false)
    $c = new SoapClient(null, ['location' => 'http://127.0.0.1:9/nope', 'uri' => 'urn:x']);
    try { $c->anyMethod(); } catch (Throwable $e) { /* SoapFault: Could not connect to host */ }
    var_dump(gc_status()['protected']);   // bool(false)

    So the trigger is specifically the WSDL parsing path, not SOAP error handling as a whole.

    2. Nothing in userland clears the flag. gc_disable() + gc_enable() does not, as shown above; gc_protected is only reset by gc_reset() at request start.

    Why this is worse than "GC is delayed". While the flag is set, gc_possible_root() does not even buffer candidates, so the cycles are not collected late — they are lost for the remainder of the process.

    Impact in practice. In a PHPUnit suite, a single test constructing SoapClient against an unreachable WSDL costs cycle collection for the whole process. Every subsequent Laravel application instance then becomes uncollectable garbage and holds its MySQL connection open; the run died on max_connections about 150 tests later, with 938 failures all reporting SQLSTATE[HY000] [1040] Too many connections. Nothing in that symptom points anywhere near SOAP, and tracing it took a full day.

    The mechanism @cmb69 identified in 2022 still matches the code: zend_bailout() sets gc_protect(1) before the LONGJMP, on the assumption that the request ends there; ext/soap catches that bailout in its own zend_catch and resumes without restoring the flag. Since gc_protect() returns the previous value, the save/restore idiom such a fix would need is already in the API.

  6. ndossche commented on Oct 14, 2026

    @ndossche
    Member

    The fix is much more complicated to do correctly/safely because fatal errors etc can also cause bailouts, and they do need the gc protection.

  7. likemusic commented on Oct 15, 2026

    @likemusic

    @ndossche thanks, that's the crux, and it also makes #10283 as is unsafe. In soap_real_error_handler() the client converts any E_FATAL_ERRORS into a SoapFault, so memory exhaustion or a timeout inside a SOAP call would also leave the GC unprotected. I confirmed this on 8.4: a memory_limit fatal in __doRequest() comes back as a SoapFault and the script continues.

    I've opened #24338 with a narrower variant on top of your commits:

    • SOAP's own errors (soap_errorN()) are marked, and only for those does the handler bail out without gc_protect.
    • Engine fatal errors keep the current protected bailout. An unmarked SOAP error behaves exactly as today, so the change can only narrow the problem, not widen it.
    • The cleanup-and-rethrow sites preserve the original decision instead of re-protecting.
    • One test checks that the memory-limit case stays protected.

    Happy to rework it in whatever direction you prefer.

  8. Chi-teck commented on Nov 1, 2026

    @Chi-teck

    Still reproducible on current releases: 8.3.36, 8.4.27 and 8.5.12 (official php:*-cli images).

    Any failed WSDL download sets gc_status()['protected'], not just a broken WSDL. On 8.5.12 we checked a WSDL URL answering HTTP 403, 429, 502, 503 and 504, a TLS certificate verify failed, and connection refused. All of them set it. A later successful new SoapClient() doesn't reset it.

    That makes it easy to hit in long-running workers: a worker that starts while the SOAP provider is down loses cycle collection for the rest of its life. Ours run Drupal, where every loaded entity is a reference cycle, so each entity the worker handled afterwards leaked about 115 KB. After three outages of the provider's WSDL endpoint, six workers grew to 10–52 GB before they were stopped.

    Two ways to avoid it until a fix ships, for anyone else who lands here:

    • Download the WSDL with your own HTTP client and pass a local file to SoapClient. A network failure then never reaches ext/soap. A malformed WSDL still does.
    • In long-running processes on PHP 8.3+, check gc_status()['protected'] after a SoapFault and restart the process when it's set.
  9. likemusic commented on Nov 3, 2026

    @likemusic

    Thanks @Chi-teck — the confirmation across 8.3.36 / 8.4.27 / 8.5.12 is useful, and "any failed WSDL download, not just a malformed document" is worth pinning down. It fits the earlier narrowing: every case you list (403/429/5xx, TLS verify failure, connection refused) fails while SoapClient is loading WSDL during construction, which is the path that flips protected. A WSDL-less client whose later call fails doesn't, so the boundary is "error while acquiring or parsing WSDL", not "SoapFault in general".

    The fix moved to master: #24366 makes the WSDL and XML schema loaders throw the SoapFault and return instead of calling zend_bailout(), so the cycle collector stays enabled. The download failures you list surface on that same SDL-loading path, so they're covered; encoding/decoding errors are the follow-up #24367. (The stable-branch attempt #24338 was closed — a fresh bailout variant was the wrong direction, per review.)

    Your "a later successful new SoapClient() doesn't reset it" plus the worker-growth numbers are good motivation for why "GC is merely delayed" is the wrong model: while the flag is set the roots aren't buffered at all, so those cycles are lost for the life of the process, which is exactly the 10–52 GB you saw.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions