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.

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