From c0600e4e3b44aef8621823f9f5db590b67a21589 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Sat, 24 Oct 2026 15:52:43 +0200 Subject: [PATCH 1/4] Fix GH-23586: Keep GC traversal consistent under memory exhaustion --- NEWS | 2 ++ Zend/tests/gh23586.phpt | 42 ++++++++++++++++++++++++++++++++ Zend/tests/gh23586_002.phpt | 23 ++++++++++++++++++ Zend/tests/gh23586_003.phpt | 30 +++++++++++++++++++++++ Zend/zend_gc.c | 48 ++++++++++++++++++++++++++----------- 5 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 Zend/tests/gh23586.phpt create mode 100644 Zend/tests/gh23586_002.phpt create mode 100644 Zend/tests/gh23586_003.phpt diff --git a/NEWS b/NEWS index 24432565e6e0..9e9c06ef46f4 100644 --- a/NEWS +++ b/NEWS @@ -3,6 +3,8 @@ PHP NEWS ?? ??? ????, PHP 8.4.28 - Core: + . Fixed bug GH-23586 (Assertion failure during garbage collection upon memory + exhaustion). (Alessio Attilio) . Fixed bug GH-24184 (System ID not being reinitialized after an Apache graceful restart, potentially causing incompatible OPcache file cache entries to be reused). (Levi Morrison) diff --git a/Zend/tests/gh23586.phpt b/Zend/tests/gh23586.phpt new file mode 100644 index 000000000000..3ff7066a7d6b --- /dev/null +++ b/Zend/tests/gh23586.phpt @@ -0,0 +1,42 @@ +--TEST-- +GH-23586 (GC traversal stack allocation must not exhaust the memory limit) +--SKIPIF-- + +--INI-- +memory_limit=93M +--FILE-- +children[] = $this; + } + $this->children[] = $this; + } + + public function __destruct() { + $this->children = null; + } +} + +for ($n = 0; $n < 2; $n++) { + $top = new Node(); + for ($i = 0; $i < 16; $i++) { + $ci = new Node($top); + for ($j = 0; $j < 16; $j++) { + $cj = new Node($ci); + while ($n) { + $ck = new Node($cj); + } + } + } +} +?> +--EXPECTF-- +Fatal error: Allowed memory size of %d bytes exhausted%S (tried to allocate %d bytes) in %s on line %d diff --git a/Zend/tests/gh23586_002.phpt b/Zend/tests/gh23586_002.phpt new file mode 100644 index 000000000000..3216eeca0afa --- /dev/null +++ b/Zend/tests/gh23586_002.phpt @@ -0,0 +1,23 @@ +--TEST-- +GH-23586 (GC can finish a wide traversal at the memory limit) +--SKIPIF-- + +--INI-- +memory_limit=256M +--FILE-- +children[] = (object) ['parent' => $root]; +} +unset($root); +ini_set('memory_limit', (string) memory_get_usage(true)); +var_dump(gc_collect_cycles()); +?> +--EXPECT-- +int(300002) diff --git a/Zend/tests/gh23586_003.phpt b/Zend/tests/gh23586_003.phpt new file mode 100644 index 000000000000..106df58342f7 --- /dev/null +++ b/Zend/tests/gh23586_003.phpt @@ -0,0 +1,30 @@ +--TEST-- +GH-23586 (GC traversal storage is released when a destructor bails out) +--SKIPIF-- + +--INI-- +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +children[] = (object) ['parent' => $root]; +} +unset($root); +gc_collect_cycles(); +?> +--EXPECTF-- +Fatal error: Destructor bailout in %s on line %d diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c index eb54ead7b110..5e23fc0bd225 100644 --- a/Zend/zend_gc.c +++ b/Zend/zend_gc.c @@ -333,7 +333,8 @@ struct _gc_stack { static zend_never_inline gc_stack* gc_stack_next(gc_stack *stack) { if (UNEXPECTED(!stack->next)) { - gc_stack *segment = emalloc(sizeof(gc_stack)); + /* Traversal must not bail out on the request memory limit after changing refcounts. */ + gc_stack *segment = pemalloc(sizeof(gc_stack), true); segment->prev = stack; segment->next = NULL; stack->next = segment; @@ -371,7 +372,7 @@ static void gc_stack_free(gc_stack *stack) while (p) { stack = p->next; - efree(p); + pefree(p, true); p = stack; } } @@ -1943,7 +1944,7 @@ static zend_never_inline void gc_call_destructors_in_fiber(uint32_t end) } } -ZEND_API int zend_gc_collect_cycles(void) +static int zend_gc_collect_cycles_impl(gc_stack *stack) { int total_count = 0; bool should_rerun_gc = 0; @@ -1961,10 +1962,6 @@ ZEND_API int zend_gc_collect_cycles(void) zend_refcounted *p; uint32_t gc_flags = 0; uint32_t idx, end; - gc_stack stack; - - stack.prev = NULL; - stack.next = NULL; if (GC_G(gc_active)) { GC_G(collector_time) += zend_hrtime() - start_time; @@ -1976,17 +1973,16 @@ ZEND_API int zend_gc_collect_cycles(void) GC_G(gc_active) = 1; GC_TRACE("Marking roots"); - gc_mark_roots(&stack); + gc_mark_roots(stack); GC_TRACE("Scanning roots"); - gc_scan_roots(&stack); + gc_scan_roots(stack); GC_TRACE("Collecting roots"); - count = gc_collect_roots(&gc_flags, &stack); + count = gc_collect_roots(&gc_flags, stack); if (!GC_G(num_roots)) { /* nothing to free */ GC_TRACE("Nothing to free"); - gc_stack_free(&stack); GC_G(gc_active) = 0; goto finish; } @@ -2035,7 +2031,7 @@ ZEND_API int zend_gc_collect_cycles(void) while (idx != end) { if (GC_IS_DTOR_GARBAGE(current->ref)) { p = GC_GET_PTR(current->ref); - count -= gc_remove_nested_data_from_buffer(p, current, &stack); + count -= gc_remove_nested_data_from_buffer(p, current, stack); } current++; idx++; @@ -2058,8 +2054,6 @@ ZEND_API int zend_gc_collect_cycles(void) } } - gc_stack_free(&stack); - /* Destroy zvals. The root buffer may be reallocated. */ GC_TRACE("Destroying zvals"); zend_hrtime_t free_start_time = zend_hrtime(); @@ -2144,6 +2138,32 @@ ZEND_API int zend_gc_collect_cycles(void) return total_count; } +static int zend_gc_collect_cycles_with_stack(gc_stack *stack) +{ + int count; + + zend_try { + count = zend_gc_collect_cycles_impl(stack); + } zend_catch { + gc_stack_free(stack); + zend_bailout(); + } zend_end_try(); + + gc_stack_free(stack); + return count; +} + +ZEND_API int zend_gc_collect_cycles(void) +{ + /* Keep the modified stack outside the function containing setjmp(), so it remains + * valid when freeing traversal storage after a bailout. */ + gc_stack stack; + + stack.prev = NULL; + stack.next = NULL; + return zend_gc_collect_cycles_with_stack(&stack); +} + ZEND_API void zend_gc_get_status(zend_gc_status *status) { status->active = GC_G(gc_active); From 9100298eefab9bcea2015522e3c21534362f4d3a Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Wed, 11 Nov 2026 19:01:51 +0100 Subject: [PATCH 2/4] Narrow GC memory limit fix to wide traversals --- NEWS | 4 ++-- Zend/tests/gh23586.phpt | 42 ----------------------------------------- 2 files changed, 2 insertions(+), 44 deletions(-) delete mode 100644 Zend/tests/gh23586.phpt diff --git a/NEWS b/NEWS index 9e9c06ef46f4..e437d34b1e30 100644 --- a/NEWS +++ b/NEWS @@ -3,8 +3,8 @@ PHP NEWS ?? ??? ????, PHP 8.4.28 - Core: - . Fixed bug GH-23586 (Assertion failure during garbage collection upon memory - exhaustion). (Alessio Attilio) + . Garbage collection can complete wide graph traversals after the request + memory limit has been reached. (Alessio Attilio) . Fixed bug GH-24184 (System ID not being reinitialized after an Apache graceful restart, potentially causing incompatible OPcache file cache entries to be reused). (Levi Morrison) diff --git a/Zend/tests/gh23586.phpt b/Zend/tests/gh23586.phpt deleted file mode 100644 index 3ff7066a7d6b..000000000000 --- a/Zend/tests/gh23586.phpt +++ /dev/null @@ -1,42 +0,0 @@ ---TEST-- -GH-23586 (GC traversal stack allocation must not exhaust the memory limit) ---SKIPIF-- - ---INI-- -memory_limit=93M ---FILE-- -children[] = $this; - } - $this->children[] = $this; - } - - public function __destruct() { - $this->children = null; - } -} - -for ($n = 0; $n < 2; $n++) { - $top = new Node(); - for ($i = 0; $i < 16; $i++) { - $ci = new Node($top); - for ($j = 0; $j < 16; $j++) { - $cj = new Node($ci); - while ($n) { - $ck = new Node($cj); - } - } - } -} -?> ---EXPECTF-- -Fatal error: Allowed memory size of %d bytes exhausted%S (tried to allocate %d bytes) in %s on line %d From b998ca6b8e6cbb352af00b20de81491ceb6b5172 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Wed, 11 Nov 2026 23:46:12 +0100 Subject: [PATCH 3/4] Make wide GC traversal test deterministic --- Zend/tests/gh23586_002.phpt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Zend/tests/gh23586_002.phpt b/Zend/tests/gh23586_002.phpt index 3216eeca0afa..9c5d799da7ce 100644 --- a/Zend/tests/gh23586_002.phpt +++ b/Zend/tests/gh23586_002.phpt @@ -12,7 +12,7 @@ memory_limit=256M children[] = (object) ['parent' => $root]; } unset($root); @@ -20,4 +20,4 @@ ini_set('memory_limit', (string) memory_get_usage(true)); var_dump(gc_collect_cycles()); ?> --EXPECT-- -int(300002) +int(350002) From 0fc8e43d7a2b04049c59780484ce425ed7383eab Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Wed, 11 Nov 2026 23:26:04 +0100 Subject: [PATCH 4/4] Run i386 test suite outside container seccomp --- .github/actions/apt-x32/action.yml | 6 +++--- .github/actions/install-linux-x32/action.yml | 16 ++++++++-------- .github/workflows/test-suite.yml | 13 ++++++------- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/.github/actions/apt-x32/action.yml b/.github/actions/apt-x32/action.yml index a70ff03e9c90..484d252ab333 100644 --- a/.github/actions/apt-x32/action.yml +++ b/.github/actions/apt-x32/action.yml @@ -9,10 +9,10 @@ runs: OPCACHE_TLS_TESTS_DEPS="gcc clang lld" export DEBIAN_FRONTEND=noninteractive - dpkg --add-architecture i386 - apt-get update -y | true + sudo dpkg --add-architecture i386 + sudo env DEBIAN_FRONTEND=noninteractive apt-get update -y | true # TODO: Reenable postgresql + postgresql-contrib packages once they work again. - apt-get install -y \ + sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y \ autoconf \ bison \ curl \ diff --git a/.github/actions/install-linux-x32/action.yml b/.github/actions/install-linux-x32/action.yml index 4ef87ee03fd3..95d7fc738b1c 100644 --- a/.github/actions/install-linux-x32/action.yml +++ b/.github/actions/install-linux-x32/action.yml @@ -5,11 +5,11 @@ runs: - shell: bash run: | set -x - make install - mkdir -p /etc/php.d - chmod 777 /etc/php.d - echo mysqli.default_socket=/var/run/mysqld/mysqld.sock > /etc/php.d/mysqli.ini - echo pdo_mysql.default_socket=/var/run/mysqld/mysqld.sock > /etc/php.d/pdo_mysql.ini - echo opcache.enable_cli=1 >> /etc/php.d/opcache.ini - echo opcache.protect_memory=1 >> /etc/php.d/opcache.ini - echo opcache.preload_user=root >> /etc/php.d/opcache.ini + sudo make install + sudo mkdir -p /etc/php.d + sudo chmod 777 /etc/php.d + echo mysqli.default_socket=/var/run/mysqld/mysqld.sock | sudo tee /etc/php.d/mysqli.ini > /dev/null + echo pdo_mysql.default_socket=/var/run/mysqld/mysqld.sock | sudo tee /etc/php.d/pdo_mysql.ini > /dev/null + echo opcache.enable_cli=1 | sudo tee /etc/php.d/opcache.ini > /dev/null + echo opcache.protect_memory=1 | sudo tee -a /etc/php.d/opcache.ini > /dev/null + echo opcache.preload_user=root | sudo tee -a /etc/php.d/opcache.ini > /dev/null diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index dcd61adf17c8..51c8e4c673c1 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -230,15 +230,14 @@ jobs: fail-fast: false matrix: ${{ fromJson(inputs.branch).jobs.LINUX_X32.matrix }} name: "LINUX_X32_${{ matrix.debug && 'DEBUG' || 'RELEASE' }}_${{ matrix.zts && 'ZTS' || 'NTS' }}" - runs-on: ubuntu-latest + # The container seccomp profile blocks i386 socket syscalls used by the test suite. + runs-on: ubuntu-${{ fromJson(inputs.branch).config.ubuntu_version }} timeout-minutes: 180 - container: - image: ubuntu:${{ fromJson(inputs.branch).config.ubuntu_version }} env: - MYSQL_TEST_HOST: mysql - PDO_MYSQL_TEST_DSN: mysql:host=mysql;dbname=test - PDO_MYSQL_TEST_HOST: mysql - PDO_FIREBIRD_TEST_DSN: firebird:dbname=firebird:test.fdb + MYSQL_TEST_HOST: 127.0.0.1 + PDO_MYSQL_TEST_DSN: mysql:host=127.0.0.1;dbname=test + PDO_MYSQL_TEST_HOST: 127.0.0.1 + PDO_FIREBIRD_TEST_DSN: firebird:dbname=localhost:test.fdb services: mysql: image: mysql:8.4