You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Garbage collection stops after exception in SoapClient #10026
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.
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:
A client created without WSDL whose call fails behaves differently — same SoapFault class, collector untouched:
<?phpvar_dump(gc_status()['protected']); // bool(false)$c = newSoapClient(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.
@ndossche thanks, that's the crux, and it also makes #10283 as is unsafe. In soap_real_error_handler() the client converts anyE_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.
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.
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.
Activity
cmb69 commentedon Dec 1, 2022
The problem is Soap's error handling, which calls
_zend_bailout()and there:php-src/Zend/zend.c
Line 1185 in 64f5f53
The fix for the reported issue appears to be trivial:
However, there are more issues in other places (probably not only in ext/soap).
ndossche commentedon Jan 8, 2023
If no one is working on this yet, I can try to check other places too soon-ish, and write a PR.
cmb69 commentedon Jan 8, 2023
Great! I'm not planning to work on this. :)
Add regression test for phpGH-10026
likemusic commentedon Oct 14, 2026
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
SoapFaultin general.A failed WSDL parse flips
gc_status()['protected']totruepermanently. No network is needed — a malformed local file is enough:A client created without WSDL whose call fails behaves differently — same
SoapFaultclass, collector untouched: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_protectedis only reset bygc_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
SoapClientagainst 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 onmax_connectionsabout 150 tests later, with 938 failures all reportingSQLSTATE[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()setsgc_protect(1)before theLONGJMP, on the assumption that the request ends there;ext/soapcatches that bailout in its ownzend_catchand resumes without restoring the flag. Sincegc_protect()returns the previous value, the save/restore idiom such a fix would need is already in the API.ndossche commentedon Oct 14, 2026
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.
likemusic commentedon Oct 15, 2026
@ndossche thanks, that's the crux, and it also makes #10283 as is unsafe. In
soap_real_error_handler()the client converts anyE_FATAL_ERRORSinto aSoapFault, so memory exhaustion or a timeout inside a SOAP call would also leave the GC unprotected. I confirmed this on 8.4: amemory_limitfatal in__doRequest()comes back as aSoapFaultand the script continues.I've opened #24338 with a narrower variant on top of your commits:
soap_errorN()) are marked, and only for those does the handler bail out withoutgc_protect.Happy to rework it in whatever direction you prefer.
ext/soap: report WSDL and schema errors without a bailout
ext/soap: report WSDL and schema errors without a bailout
ext/soap: report encoding errors without a bailout
Chi-teck commentedon Nov 1, 2026
Still reproducible on current releases: 8.3.36, 8.4.27 and 8.5.12 (official
php:*-cliimages).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 TLScertificate verify failed, and connection refused. All of them set it. A later successfulnew 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:
SoapClient. A network failure then never reaches ext/soap. A malformed WSDL still does.gc_status()['protected']after aSoapFaultand restart the process when it's set.likemusic commentedon Nov 3, 2026
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
SoapClientis loading WSDL during construction, which is the path that flipsprotected. 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
SoapFaultand return instead of callingzend_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.