[php-src] master: tests: Cache failed service probes (#22944)

NickSdot via GitHub <[email protected]>
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: NickSdot (NickSdot)
Committer: GitHub (web-flow)
Pusher: arnaud-lb
Date: 2026-08-06T11:42:56+02:00

Commit: https://github.com/php/php-src/commit/5d5887619228914abe843657be56ddfc98528cfc
Raw diff: https://github.com/php/php-src/commit/5d5887619228914abe843657be56ddfc98528cfc.diff

tests: Cache failed service probes (#22944)

Prevents unavailable optional test services from slowing the suite through repeated connection timeouts. PDO and SNMP availability probes cache the first failure for the duration of one test run, so later SKIPIF checks return immediately.

Only failures are cached; successful probes and test connections are unchanged. PDOs one second timeout applies only to the default availability probe. The caches are private, run-scoped and removed afterwards. A service becoming available mid run is detected on the next test-suite run.

Changed paths:
  A  tests/probe_cache.inc
  A  tests/run-test/test_probe_cache.phpt
  A  tests/run-test/test_skipif_environment.phpt
  M  ext/ldap/tests/skipifbindfailure.inc
  M  ext/mysqli/tests/skipifconnectfailure.inc
  M  ext/mysqli/tests/test_setup/test_helpers.inc
  M  ext/odbc/tests/skipif.inc
  M  ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_basic.phpt
  M  ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt
  M  ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_cyclic_ctor_args.phpt
  M  ext/pdo/tests/attr_statement_class/pdo_prepare_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt
  M  ext/pdo/tests/pdo_027.phpt
  M  ext/pdo/tests/pdo_query_fetch_lazy001.phpt
  M  ext/pdo/tests/pdo_test.inc
  M  ext/pdo_dblib/tests/common.phpt
  M  ext/pdo_dblib/tests/config.inc
  M  ext/pdo_mysql/tests/inc/mysql_pdo_test.inc
  M  ext/pdo_pgsql/tests/bug75402.phpt
  M  ext/pgsql/tests/inc/skipif.inc
  M  ext/snmp/tests/skipif.inc
  M  run-tests.php


Diff:

diff --git a/ext/ldap/tests/skipifbindfailure.inc b/ext/ldap/tests/skipifbindfailure.inc
index 81c7998cfbb5..0b421ef1bd71 100644
--- a/ext/ldap/tests/skipifbindfailure.inc
+++ b/ext/ldap/tests/skipifbindfailure.inc
@@ -1,14 +1,23 @@
 <?php
 require_once 'connect.inc';
+require_once dirname(__DIR__, 3) . '/tests/probe_cache.inc';
 
 if ($skip_on_bind_failure) {
+    $configuration = [$uri, $user, $passwd, $protocol_version];
+
+    try {
+        ProbeCache::getFailure('ldap.bind', $configuration, static function () use ($uri, $user, $passwd, $protocol_version): void {
+            $link = ldap_connect($uri);
+            ldap_set_option($link, LDAP_OPT_PROTOCOL_VERSION, $protocol_version);
+            if (!@ldap_bind($link, $user, $passwd)) {
+                throw new ProbeFailureException(sprintf("Can't bind to LDAP Server - [%d] %s", ldap_errno($link), ldap_error($link)));
+            }
 
-    $link = ldap_connect($uri);
-    ldap_set_option($link, LDAP_OPT_PROTOCOL_VERSION, $protocol_version);
-    if (!@ldap_bind($link, $user, $passwd))
-        die(sprintf("skip Can't bind to LDAP Server - [%d] %s", ldap_errno($link), ldap_error($link)));
-
-    ldap_unbind($link);
+            ldap_unbind($link);
+        });
+    } catch (ProbeFailureException $e) {
+        die("skip {$e->getMessage()}");
+    }
 }
 
 if (isset($require_vendor)) {
diff --git a/ext/mysqli/tests/skipifconnectfailure.inc b/ext/mysqli/tests/skipifconnectfailure.inc
index 986f646f8bb9..f5cd763af140 100644
--- a/ext/mysqli/tests/skipifconnectfailure.inc
+++ b/ext/mysqli/tests/skipifconnectfailure.inc
@@ -1,7 +1,19 @@
 <?php
 require_once 'connect.inc';
-$link = @my_mysqli_connect($host, $user, $passwd, $db, $port, $socket);
-if (!is_object($link))
-    die(sprintf("skip Can't connect to MySQL Server - [%d] %s", mysqli_connect_errno(), mysqli_connect_error()));
-mysqli_close($link);
+require_once dirname(__DIR__, 3) . '/tests/probe_cache.inc';
+
+$configuration = [$host, $port, $user, $passwd, $db, $socket, get_environment_connection_flags()];
+
+try {
+    ProbeCache::getFailure('mysqli', $configuration, static function () use ($host, $user, $passwd, $db, $port, $socket): void {
+        $link = @my_mysqli_connect($host, $user, $passwd, $db, $port, $socket);
+        if (!is_object($link)) {
+            throw new ProbeFailureException(sprintf("Can't connect to MySQL Server - [%d] %s", mysqli_connect_errno(), mysqli_connect_error()));
+        }
+
+        mysqli_close($link);
+    });
+} catch (ProbeFailureException $e) {
+    die("skip {$e->getMessage()}");
+}
 ?>
diff --git a/ext/mysqli/tests/test_setup/test_helpers.inc b/ext/mysqli/tests/test_setup/test_helpers.inc
index c9ab401e7568..d32697aebf82 100644
--- a/ext/mysqli/tests/test_setup/test_helpers.inc
+++ b/ext/mysqli/tests/test_setup/test_helpers.inc
@@ -1,5 +1,7 @@
 <?php
 
+require_once dirname(__DIR__, 4) . '/tests/probe_cache.inc';
+
 function get_default_host(): string {
     static $host = null;
     if ($host === null) {
@@ -110,11 +112,31 @@ function default_mysqli_connect(): \mysqli{
 function mysqli_check_skip_test(): void {
     mysqli_connect_or_skip();
 }
-function mysqli_connect_or_skip() {
+
+function mysqli_connect_or_skip(): mysqli {
+    $configuration = [
+        get_default_host(),
+        get_default_port(),
+        get_default_user(),
+        get_default_password(),
+        get_default_database(),
+        get_default_socket(),
+        get_environment_connection_flags(),
+    ];
+
     try {
-        return default_mysqli_connect();
-    } catch (\mysqli_sql_exception) {
-        die(sprintf("skip Can't connect to MySQL Server - [%d] %s", mysqli_connect_errno(), mysqli_connect_error()));
+        return ProbeCache::getFailure('mysqli', $configuration, static function (): mysqli {
+            try {
+                return default_mysqli_connect();
+            } catch (mysqli_sql_exception $e) {
+                throw new ProbeFailureException(
+                    sprintf("Can't connect to MySQL Server - [%d] %s", mysqli_connect_errno(), mysqli_connect_error()),
+                    $e,
+                );
+            }
+        });
+    } catch (ProbeFailureException $e) {
+        die("skip {$e->getMessage()}");
     }
 }
 function have_innodb(mysqli $link): bool {
@@ -123,11 +145,7 @@ function have_innodb(mysqli $link): bool {
     return $supported === 'YES' || $supported === 'DEFAULT';
 }
 function mysqli_check_innodb_support_skip_test(): void {
-    try {
-        $link = default_mysqli_connect();
-    } catch (\mysqli_sql_exception) {
-        die(sprintf("skip Can't connect to MySQL Server - [%d] %s", mysqli_connect_errno(), mysqli_connect_error()));
-    }
+    $link = mysqli_connect_or_skip();
     if (! have_innodb($link)) {
         die(sprintf("skip Needs InnoDB support"));
     }
diff --git a/ext/odbc/tests/skipif.inc b/ext/odbc/tests/skipif.inc
index 9785f5843a77..a602debe2271 100644
--- a/ext/odbc/tests/skipif.inc
+++ b/ext/odbc/tests/skipif.inc
@@ -1,8 +1,17 @@
 <?php
 
 include 'config.inc';
+require_once dirname(__DIR__, 3) . '/tests/probe_cache.inc';
 
-$conn = @odbc_connect($dsn, $user, $pass);
-if (!$conn) {
-    die('skip could not connect');
+try {
+    $conn = ProbeCache::getFailure('odbc', [$dsn, $user, $pass], static function () use ($dsn, $user, $pass): Odbc\Connection {
+        $conn = @odbc_connect($dsn, $user, $pass);
+        if (!$conn) {
+            throw new ProbeFailureException('could not connect');
+        }
+
+        return $conn;
+    });
+} catch (ProbeFailureException $e) {
+    die("skip {$e->getMessage()}");
 }
diff --git a/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_basic.phpt b/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_basic.phpt
index dfef3bb4839f..bd510b6ac636 100644
--- a/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_basic.phpt
+++ b/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_basic.phpt
@@ -78,7 +78,7 @@ require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
 $db = PDOTest::factory();
 PDOTest::dropTableIfExists($db, "pdo_attr_statement_class_basic");
 ?>
---EXPECT--
+--EXPECTF--
 array(1) {
   [0]=>
   string(12) "PDOStatement"
@@ -89,7 +89,7 @@ StatementWithPublicDestructor::__destruct
 Class derived from PDOStatement, with private constructor:
 bool(true)
 StatementWithPrivateConstructor::__construct
-object(StatementWithPrivateConstructor)#2 (1) {
+object(StatementWithPrivateConstructor)#%d (1) {
   ["queryString"]=>
   string(68) "SELECT id, label FROM pdo_attr_statement_class_basic ORDER BY id ASC"
 }
@@ -97,7 +97,7 @@ string(6) "param1"
 Class derived from a child of PDOStatement:
 bool(true)
 StatementWithPrivateConstructor::__construct
-object(StatementDerivedFromChild)#2 (1) {
+object(StatementDerivedFromChild)#%d (1) {
   ["queryString"]=>
   string(68) "SELECT id, label FROM pdo_attr_statement_class_basic ORDER BY id ASC"
 }
diff --git a/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt b/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt
index 301a8835eee2..aebf969b5832 100644
--- a/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt
+++ b/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt
@@ -49,8 +49,8 @@ require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
 $db = PDOTest::factory();
 PDOTest::dropTableIfExists($db, "pdo_attr_statement_class_ctor_arg_gc");
 ?>
---EXPECT--
-object(Bar)#1 (1) {
+--EXPECTF--
+object(Bar)#%d (1) {
   ["statementClass"]=>
   string(3) "Foo"
 }
diff --git a/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_cyclic_ctor_args.phpt b/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_cyclic_ctor_args.phpt
index 61cfe56f779d..672085441c95 100644
--- a/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_cyclic_ctor_args.phpt
+++ b/ext/pdo/tests/attr_statement_class/pdo_ATTR_STATEMENT_CLASS_cyclic_ctor_args.phpt
@@ -42,18 +42,18 @@ require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
 $db = PDOTest::factory();
 PDOTest::dropTableIfExists($db, "pdo_attr_statement_class_cyclic_ctor_args");
 ?>
---EXPECT--
+--EXPECTF--
 array(1) {
   [0]=>
   string(12) "PDOStatement"
 }
 bool(true)
-object(PDO)#1 (0) {
+object(PDO)#%d (0) {
 }
-object(HoldPdo)#2 (2) {
+object(HoldPdo)#%d (2) {
   ["queryString"]=>
   string(79) "SELECT id, label FROM pdo_attr_statement_class_cyclic_ctor_args ORDER BY id ASC"
   ["v"]=>
-  object(PDO)#1 (0) {
+  object(PDO)#%d (0) {
   }
 }
diff --git a/ext/pdo/tests/attr_statement_class/pdo_prepare_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt b/ext/pdo/tests/attr_statement_class/pdo_prepare_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt
index 86d2cfcefd5f..bc825d4f8523 100644
--- a/ext/pdo/tests/attr_statement_class/pdo_prepare_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt
+++ b/ext/pdo/tests/attr_statement_class/pdo_prepare_ATTR_STATEMENT_CLASS_ctor_arg_gc.phpt
@@ -51,8 +51,8 @@ require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
 $db = PDOTest::factory();
 PDOTest::dropTableIfExists($db, "pdo_prepare_attr_statement_class_ctor_arg_gc");
 ?>
---EXPECT--
-object(Bar)#1 (1) {
+--EXPECTF--
+object(Bar)#%d (1) {
   ["statementClass"]=>
   string(3) "Foo"
 }
diff --git a/ext/pdo/tests/pdo_027.phpt b/ext/pdo/tests/pdo_027.phpt
index 6bb350f0c133..969ec9462139 100644
--- a/ext/pdo/tests/pdo_027.phpt
+++ b/ext/pdo/tests/pdo_027.phpt
@@ -36,13 +36,13 @@ require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
 $db = PDOTest::factory();
 PDOTest::dropTableIfExists($db, "test027");
 ?>
---EXPECT--
-object(PDOStatement)#2 (1) {
+--EXPECTF--
+object(PDOStatement)#%d (1) {
   ["queryString"]=>
   string(21) "SELECT * FROM test027"
 }
 bool(false)
-object(PDORow)#4 (3) {
+object(PDORow)#%d (3) {
   ["queryString"]=>
   string(21) "SELECT * FROM test027"
   ["id"]=>
@@ -52,7 +52,7 @@ object(PDORow)#4 (3) {
 }
 lazy: 1test1
 bool(true)
-object(PDORow)#4 (3) {
+object(PDORow)#%d (3) {
   ["queryString"]=>
   string(21) "SELECT * FROM test027"
   ["id"]=>
diff --git a/ext/pdo/tests/pdo_query_fetch_lazy001.phpt b/ext/pdo/tests/pdo_query_fetch_lazy001.phpt
index cec5e9f6d6c9..7d9538513045 100644
--- a/ext/pdo/tests/pdo_query_fetch_lazy001.phpt
+++ b/ext/pdo/tests/pdo_query_fetch_lazy001.phpt
@@ -29,8 +29,8 @@ require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
 $db = PDOTest::factory();
 PDOTest::dropTableIfExists($db, "pdo_query_fetch_lazy_001");
 ?>
---EXPECT--
-object(PDOStatement)#2 (1) {
+--EXPECTF--
+object(PDOStatement)#%d (1) {
   ["queryString"]=>
   string(38) "SELECT * FROM pdo_query_fetch_lazy_001"
 }
diff --git a/ext/pdo/tests/pdo_test.inc b/ext/pdo/tests/pdo_test.inc
index b44d0b88e77b..817eaa7bf798 100644
--- a/ext/pdo/tests/pdo_test.inc
+++ b/ext/pdo/tests/pdo_test.inc
@@ -1,6 +1,8 @@
 <?php
 # PDO test framework utilities
 
+require_once dirname(__DIR__, 3) . '/tests/probe_cache.inc';
+
 if (getenv('PDOTEST_DSN') === false) {
     $common = '';
     $append = false;
@@ -18,41 +20,41 @@ if (getenv('PDOTEST_DSN') === false) {
 class PDOTest {
     // create an instance of the PDO driver, based on
     // the current environment
-    static function factory($classname = PDO::class, bool $useConnectMethod = false) {
+    static function factory($classname = PDO::class, bool $useConnectMethod = false, ?array $attributes = null) {
         $dsn = getenv('PDOTEST_DSN');
         $user = getenv('PDOTEST_USER');
         $pass = getenv('PDOTEST_PASS');
-        $attr = getenv('PDOTEST_ATTR');
-        if (is_string($attr) && strlen($attr)) {
-            $attr = unserialize($attr);
-        } else {
-            $attr = null;
+        if ($attributes === null) {
+            $attributes = self::getAttributes('PDOTEST_ATTR');
+            if (getenv('TEST_PHP_EVALUATING_SKIPIF') === '1') {
+                $skipAttributes = self::getAttributes('PDOTEST_SKIP_ATTR');
+                if ($skipAttributes !== null) {
+                    $attributes = $skipAttributes + ($attributes ?? []);
+                }
+            }
         }
 
         if ($user === false) $user = NULL;
         if ($pass === false) $pass = NULL;
 
-        if ($useConnectMethod) {
-            $db = $classname::connect($dsn, $user, $pass, $attr);
-        } else {
-            $db = new $classname($dsn, $user, $pass, $attr);
-        }
+        $configuration = [$classname, $useConnectMethod, $dsn, $user, $pass, $attributes];
 
-        if (!$db) {
-            die("Could not create PDO object (DSN=$dsn, user=$user)\n");
+        try {
+            return ProbeCache::getFailure('pdo', $configuration, static function () use ($classname, $useConnectMethod, $dsn, $user, $pass, $attributes): PDO {
+                try {
+                    return self::createConnection($classname, $useConnectMethod, $dsn, $user, $pass, $attributes);
+                } catch (PDOException $e) {
+                    throw new ProbeFailureException($e);
+                }
+            });
+        } catch (ProbeFailureException $e) {
+            throw new PDOException($e->getMessage());
         }
-        // Ignore errors about non-existent tables
-        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);
-
-        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
-        $db->setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER);
-        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true);
-        return $db;
     }
 
     static function skip() {
         try {
-            $db = PDOTest::factory();
+            self::factory();
         } catch (PDOException $e) {
             die("skip " . $e->getMessage());
         }
@@ -95,6 +97,33 @@ class PDOTest {
             default => $db->exec("DROP TABLE IF EXISTS $tableName"),
         };
     }
+
+    private static function createConnection($classname, bool $useConnectMethod, $dsn, $user, $pass, ?array $attributes) {
+        if ($useConnectMethod) {
+            $db = $classname::connect($dsn, $user, $pass, $attributes);
+        } else {
+            $db = new $classname($dsn, $user, $pass, $attributes);
+        }
+
+        if (!$db) {
+            die("Could not create PDO object (DSN=$dsn, user=$user)\n");
+        }
+        // Ignore errors about non-existent tables
+        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);
+
+        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
+        $db->setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER);
+        $db->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true);
+        return $db;
+    }
+
+    private static function getAttributes(string $environmentVariable): ?array {
+        $attributes = getenv($environmentVariable);
+        if (is_string($attributes) && strlen($attributes)) {
+            return unserialize($attributes);
+        }
+        return null;
+    }
 }
 /** See https://stackoverflow.com/a/3732466 */
 function get_dummy_sql_request(): string
diff --git a/ext/pdo_dblib/tests/common.phpt b/ext/pdo_dblib/tests/common.phpt
index 293597b623e3..f3a963ef9b63 100644
--- a/ext/pdo_dblib/tests/common.phpt
+++ b/ext/pdo_dblib/tests/common.phpt
@@ -5,7 +5,7 @@ pdo_dblib
 --REDIRECTTEST--
 # magic auto-configuration
 
-return [
+$config = [
 	'ENV' => [
 		'PDOTEST_DSN'  => getenv('PDO_DBLIB_TEST_DSN')  ?: 'dblib:host=localhost;dbname=test',
 		'PDOTEST_USER' => getenv('PDO_DBLIB_TEST_USER') ?: 'php',
@@ -13,3 +13,11 @@ return [
 	],
 	'TESTS' => __DIR__ . '/ext/pdo/tests',
 ];
+
+if (getenv('PDO_DBLIB_TEST_DSN') === false) {
+	$config['ENV']['PDOTEST_SKIP_ATTR'] = serialize([
+		Pdo\Dblib::ATTR_CONNECTION_TIMEOUT => 1,
+	]);
+}
+
+return $config;
diff --git a/ext/pdo_dblib/tests/config.inc b/ext/pdo_dblib/tests/config.inc
index 1612a80a9336..78bf0804890d 100644
--- a/ext/pdo_dblib/tests/config.inc
+++ b/ext/pdo_dblib/tests/config.inc
@@ -1,5 +1,7 @@
 <?php
 
+require_once dirname(__DIR__, 3) . '/tests/probe_cache.inc';
+
 // bug #72969 reflects a bug with FreeTDS, not with pdo_dblib
 // this function will version detect so the relevant tests can XFAILIF
 // assume this bug isn't present if not using FreeTDS
@@ -41,18 +43,28 @@ function setAttributes(PDO $db) {
 }
 
 function getDbConnection(string $class = PDO::class, ?array $attributes = null) {
+    $connectionAttributes = $attributes;
+    $evaluatingSkipif = getenv('TEST_PHP_EVALUATING_SKIPIF') === '1';
+    if ($evaluatingSkipif && $connectionAttributes === null && getenv('PDO_DBLIB_TEST_DSN') === false) {
+        $connectionAttributes = [Pdo\Dblib::ATTR_CONNECTION_TIMEOUT => 1];
+    }
     [$dsn, $user, $pass] = getCredentials();
 
     try {
-        $db = new $class($dsn, $user, $pass, $attributes);
-        if ($attributes === null) {
-            setAttributes($db);
-        }
-    } catch (PDOException $e) {
+        return ProbeCache::getFailure('pdo', [$class, false, $dsn, $user, $pass, $connectionAttributes], static function () use ($class, $dsn, $user, $pass, $attributes, $connectionAttributes): PDO {
+            try {
+                $db = new $class($dsn, $user, $pass, $connectionAttributes);
+                if ($attributes === null) {
+                    setAttributes($db);
+                }
+                return $db;
+            } catch (PDOException $e) {
+                throw new ProbeFailureException($e);
+            }
+        });
+    } catch (ProbeFailureException $e) {
         die('skip ' . $e->getMessage());
     }
-
-    return $db;
 }
 
 function connectToDb() {
diff --git a/ext/pdo_mysql/tests/inc/mysql_pdo_test.inc b/ext/pdo_mysql/tests/inc/mysql_pdo_test.inc
index 75f299ff9e19..d840e66b6ff5 100644
--- a/ext/pdo_mysql/tests/inc/mysql_pdo_test.inc
+++ b/ext/pdo_mysql/tests/inc/mysql_pdo_test.inc
@@ -20,20 +20,19 @@ class MySQLPDOTest extends PDOTest {
             $attr = is_string($attr) && strlen($attr) ? unserialize($attr) : null;
         }
 
-        if ($useConnectMethod) {
-            $db = $classname::connect($dsn, $user, $pass, $attr);
-        } else {
-            $db = new $classname($dsn, $user, $pass, $attr);
-        }
+        $configuration = [$classname, $useConnectMethod, $dsn, $user, $pass, $attr];
 
-        if (!$db) {
-            die("Could not create PDO object (DSN=$dsn, user=$user)\n");
+        try {
+            return ProbeCache::getFailure('pdo', $configuration, static function () use ($classname, $useConnectMethod, $dsn, $user, $pass, $attr): PDO {
+                try {
+                    return self::createConnection($classname, $useConnectMethod, $dsn, $user, $pass, $attr);
+                } catch (PDOException $e) {
+                    throw new ProbeFailureException($e);
+                }
+            });
+        } catch (ProbeFailureException $e) {
+            throw new PDOException($e->getMessage());
         }
-
-        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
-        $db->setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER);
-
-        return $db;
     }
 
     static function factoryWithAttr($attr) {
@@ -168,7 +167,7 @@ class MySQLPDOTest extends PDOTest {
 
     static function skip() {
         try {
-            $db = self::factory();
+            self::factory();
         } catch (PDOException $e) {
             die('skip could not connect');
         }
@@ -212,5 +211,22 @@ class MySQLPDOTest extends PDOTest {
         $message = $message ?? 'skip Transactional engine not found';
         if (false == self::detect_transactional_mysql_engine($db)) die($message);
     }
+
+    private static function createConnection($classname, bool $useConnectMethod, $dsn, $user, $pass, $attr) {
+        if ($useConnectMethod) {
+            $db = $classname::connect($dsn, $user, $pass, $attr);
+        } else {
+            $db = new $classname($dsn, $user, $pass, $attr);
+        }
+
+        if (!$db) {
+            die("Could not create PDO object (DSN=$dsn, user=$user)\n");
+        }
+
+        $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
+        $db->setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER);
+
+        return $db;
+    }
 }
 ?>
diff --git a/ext/pdo_pgsql/tests/bug75402.phpt b/ext/pdo_pgsql/tests/bug75402.phpt
index be0fbd3f0bee..909c315f8004 100644
--- a/ext/pdo_pgsql/tests/bug75402.phpt
+++ b/ext/pdo_pgsql/tests/bug75402.phpt
@@ -88,12 +88,12 @@ require __DIR__ . '/../../../ext/pdo/tests/pdo_test.inc';
 $db = PDOTest::test_factory(__DIR__ . '/common.phpt');
 $db->exec('DROP TABLE IF EXISTS bug75402');
 ?>
---EXPECT--
-object(stdClass)#2 (1) {
+--EXPECTF--
+object(stdClass)#%d (1) {
   ["entries"]=>
   array(1) {
     [0]=>
-    object(stdClass)#4 (10) {
+    object(stdClass)#%d (10) {
       ["sid"]=>
       string(19) "20171016083645_5337"
       ["sgroupid"]=>
diff --git a/ext/pgsql/tests/inc/skipif.inc b/ext/pgsql/tests/inc/skipif.inc
index 2ce5f46e7787..03a00b0cde7b 100644
--- a/ext/pgsql/tests/inc/skipif.inc
+++ b/ext/pgsql/tests/inc/skipif.inc
@@ -8,15 +8,24 @@
 
 include("config.inc");
 include("lcmess.inc");
+require_once dirname(__DIR__, 4) . '/tests/probe_cache.inc';
 
 if (getenv("SKIP_REPEAT")) {
     // pgsql tests are order-dependent.
     // We should probably change that, but in the meantime do not allow repetition.
     die("skip Cannot repeat pgsql tests");
 }
-$conn = @pg_connect($conn_str);
-if (!$conn) {
-    die("skip could not connect\n");
+try {
+    $conn = ProbeCache::getFailure('pgsql', [$conn_str], static function () use ($conn_str): PgSql\Connection {
+        $conn = @pg_connect($conn_str);
+        if (!$conn) {
+            throw new ProbeFailureException('could not connect');
+        }
+
+        return $conn;
+    });
+} catch (ProbeFailureException $e) {
+    die("skip {$e->getMessage()}\n");
 }
 
 function skip_server_version($version, $op = '<')
diff --git a/ext/snmp/tests/skipif.inc b/ext/snmp/tests/skipif.inc
index 0ae4ee16e5b8..283cfaa5357d 100644
--- a/ext/snmp/tests/skipif.inc
+++ b/ext/snmp/tests/skipif.inc
@@ -1,10 +1,18 @@
 <?php
 require_once (dirname(__FILE__).'/snmp_include.inc');
+require_once dirname(__DIR__, 3) . '/tests/probe_cache.inc';
 
 //test server is available
 // this require snmpget to work ...
 //snmpget  ( string $hostname  , string $community  ,
 //string $object_id  [, int $timeout  [, int $retries  ]] )
 
-if (@snmpget($hostname, $community, '.1.3.6.1.2.1.1.1.0', $timeout) === false)
-    die('skip NO SNMPD on this host or community invalid');
+try {
+    ProbeCache::getFailure('snmp', [$hostname, $community, $timeout], static function () use ($hostname, $community, $timeout, $retries): void {
+        if (@snmpget($hostname, $community, '.1.3.6.1.2.1.1.1.0', $timeout, $retries) === false) {
+            throw new ProbeFailureException('NO SNMPD on this host or community invalid');
+        }
+    });
+} catch (ProbeFailureException $e) {
+    die("skip {$e->getMessage()}");
+}
diff --git a/run-tests.php b/run-tests.php
index 89b5882f6674..6eed6649aec7 100755
--- a/run-tests.php
+++ b/run-tests.php
@@ -232,6 +232,8 @@ function main(): void
         }
     }
 
+    SharedProbeCache::setUp($environment);
+
     if (IS_WINDOWS && empty($environment["SystemRoot"])) {
         $environment["SystemRoot"] = getenv("SystemRoot");
     }
@@ -1073,13 +1075,68 @@ function get_file_cache_dir(): string
     return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'php-run-tests-file-cache';
 }
 
+final class SharedProbeCache
+{
+    private string $directory;
+
+    private function __construct(string $directory)
+    {
+        $this->directory = $directory;
+    }
+
+    public static function setUp(array &$environment): void
+    {
+        unset($environment['TEST_PHP_SHARED_CACHE_DIR']);
+        if (getenv('TEST_PHP_SHARED_CACHE') === '0') {
+            return;
+        }
+
+        $cache = self::create();
+        if ($cache === null) {
+            return;
+        }
+
+        $environment['TEST_PHP_SHARED_CACHE_DIR'] = $cache->directory;
+        register_shutdown_function(static function () use ($cache): void {
+            $cache->cleanup();
+        });
+    }
+
+    private static function create(): ?self
+    {
+        $temporaryDirectory = sys_get_temp_dir();
+        if ($temporaryDirectory === '') {
+            return null;
+        }
+
+        for ($attempt = 0; $attempt < 3; $attempt++) {
+            $directory = $temporaryDirectory
+                . DIRECTORY_SEPARATOR
+                . 'php-run-tests-'
+                . bin2hex(random_bytes(8));
+            if (@mkdir($directory, 0700)) {
+                return new self($directory);
+            }
+        }
+
+        return null;
+    }
+
+    private function cleanup(): void
+    {
+        if (is_dir($this->directory)) {
+            rmdir_recursive($this->directory);
+        }
+    }
+}
+
 function rmdir_recursive($dir)
 {
-    if (!file_exists($dir)) {
+    if (!file_exists($dir) && !is_link($dir)) {
         return;
     }
-    if (!is_dir($dir)) {
-        unlink($dir);
+    if (is_link($dir) || !is_dir($dir)) {
+        @unlink($dir);
         return;
     }
 
@@ -3777,6 +3834,7 @@ public function checkSkip(array $command, string $code, string $checkFile, strin
         }
 
         save_text($checkFile, $code, $tempFile);
+        $env['TEST_PHP_EVALUATING_SKIPIF'] = '1';
         $command[] = $checkFile;
         $result = trim(system_with_timeout($command, $env));
         if (strpos($result, 'nocache') === 0) {
diff --git a/tests/probe_cache.inc b/tests/probe_cache.inc
new file mode 100644
index 000000000000..01ba1bec5564
--- /dev/null
+++ b/tests/probe_cache.inc
@@ -0,0 +1,73 @@
+<?php
+
+final class ProbeFailureException extends Exception
+{
+    public function __construct(string|Throwable $failure, ?Throwable $previous = null)
+    {
+        if ($failure instanceof Throwable) {
+            $previous = $failure;
+            $failure = $failure->getMessage();
+        }
+
+        parent::__construct($failure, 0, $previous);
+    }
+}
+
+final class ProbeCache
+{
+    private const FAILURE_PREFIX = 'failure:';
+
+    /**
+     * Runs a probe once per configuration during SKIPIF, caching only failures.
+     * Outside SKIPIF, the cache is bypassed and wrapped exceptions are rethrown.
+     */
+    public static function getFailure(string $namespace, array $configuration, callable $probe): mixed
+    {
+        if (getenv('TEST_PHP_EVALUATING_SKIPIF') !== '1') {
+            try {
+                return $probe();
+            } catch (ProbeFailureException $e) {
+                throw $e->getPrevious() ?? $e;
+            }
+        }
+
+        $directory = getenv('TEST_PHP_SHARED_CACHE_DIR');
+        if (!is_string($directory) || !is_dir($directory)) {
+            return $probe();
+        }
+
+        $cacheFile = $directory
+            . DIRECTORY_SEPARATOR
+            . 'probe-'
+            . hash('sha256', serialize([$namespace, $configuration]));
+
+        $cache = @fopen($cacheFile, 'c+');
+
+        if ($cache === false || !flock($cache, LOCK_EX)) {
+            if ($cache !== false) {
+                fclose($cache);
+            }
+            return $probe();
+        }
+
+        try {
+            $cached = stream_get_contents($cache);
+            if (is_string($cached) && str_starts_with($cached, self::FAILURE_PREFIX)) {
+                throw new ProbeFailureException(substr($cached, strlen(self::FAILURE_PREFIX)));
+            }
+
+            try {
+                return $probe();
+            } catch (ProbeFailureException $e) {
+                rewind($cache);
+                ftruncate($cache, 0);
+                fwrite($cache, self::FAILURE_PREFIX . $e->getMessage());
+                fflush($cache);
+                throw $e;
+            }
+        } finally {
+            flock($cache, LOCK_UN);
+            fclose($cache);
+        }
+    }
+}
diff --git a/tests/run-test/test_probe_cache.phpt b/tests/run-test/test_probe_cache.phpt
new file mode 100644
index 000000000000..6bc94ba00a91
--- /dev/null
+++ b/tests/run-test/test_probe_cache.phpt
@@ -0,0 +1,223 @@
+--TEST--
+Shared test probe cache caches failures across processes
+--FILE--
+<?php
+require dirname(__DIR__) . '/probe_cache.inc';
+
+function start_probe_cache_process(string $code, array $environment): array
+{
+    $command = getenv('TEST_PHP_EXECUTABLE_ESCAPED')
+        . ' -n -r '
+        . escapeshellarg($code);
+    $process = proc_open(
+        $command,
+        [
+            1 => ['pipe', 'w'],
+            2 => ['redirect', 1],
+        ],
+        $pipes,
+        null,
+        $environment,
+        ['bypass_shell' => true],
+    );
+
+    return [$process, $pipes];
+}
+
+function finish_probe_cache_process($process, array $pipes): string
+{
+    $output = stream_get_contents($pipes[1]);
+    fclose($pipes[1]);
+
+    if (0 !== $exitCode = proc_close($process)) {
+        throw new Exception("PHP subprocess exited with code $exitCode: $output");
+    }
+
+    return $output;
+}
+
+function run_probe_cache_process(string $code, array $environment): string
+{
+    [$process, $pipes] = start_probe_cache_process($code, $environment);
+    return finish_probe_cache_process($process, $pipes);
+}
+
+$cacheDirectory = getenv('TEST_PHP_SHARED_CACHE_DIR');
+if (!is_string($cacheDirectory)) {
+    throw new Exception('Missing shared test cache directory');
+}
+
+$environment = getenv();
+$environment['TEST_PHP_EVALUATING_SKIPIF'] = '1';
+
+$helper = var_export(dirname(__DIR__) . '/probe_cache.inc', true);
+$namespace = 'probe-cache-test-' . bin2hex(random_bytes(8));
+$namespaceCode = var_export($namespace, true);
+$first = run_probe_cache_process(
+    <<<PHP
+    require $helper;
+
+    try {
+        ProbeCache::getFailure(
+            $namespaceCode,
+            ['shared'],
+            static function (): never {
+                throw new ProbeFailureException('shared failure');
+            },
+        );
+    } catch (ProbeFailureException \$e) {
+        echo \$e::class, ': ', \$e->getMessage();
+    }
+    PHP,
+    $environment,
+);
+$second = run_probe_cache_process(
+    <<<PHP
+    require $helper;
+
+    try {
+        ProbeCache::getFailure(
+            $namespaceCode,
+            ['shared'],
+            static function (): never {
+                throw new Exception('Probe should not run');
+            },
+        );
+    } catch (ProbeFailureException \$e) {
+        echo \$e::class, ': ', \$e->getMessage();
+    }
+    PHP,
+    $environment,
+);
+echo "$first\n$second\n";
+
+$probeStarted = $cacheDirectory . '/probe_started';
+$probeStartedCode = var_export($probeStarted, true);
+@unlink($probeStarted);
+[$firstProcess, $firstPipes] = start_probe_cache_process(
+    <<<PHP
+    require $helper;
+
+    try {
+        ProbeCache::getFailure(
+            $namespaceCode,
+            ['concurrent'],
+            static function (): never {
+                file_put_contents($probeStartedCode, 'started');
+                usleep(1000000);
+                throw new ProbeFailureException('concurrent failure');
+            },
+        );
+    } catch (ProbeFailureException \$e) {
+        echo \$e::class, ': ', \$e->getMessage();
+    }
+    PHP,
+    $environment,
+);
+
+$deadline = microtime(true) + 5;
+while (!file_exists($probeStarted) && microtime(true) < $deadline) {
+    usleep(1000);
+}
+if (!file_exists($probeStarted)) {
+    $output = finish_probe_cache_process($firstProcess, $firstPipes);
+    throw new Exception("Concurrent probe did not start: $output");
+}
+
+[$secondProcess, $secondPipes] = start_probe_cache_process(
+    <<<PHP
+    require $helper;
+
+    try {
+        ProbeCache::getFailure(
+            $namespaceCode,
+            ['concurrent'],
+            static function (): never {
+                throw new Exception('Concurrent probe should not run');
+            },
+        );
+    } catch (ProbeFailureException \$e) {
+        echo \$e::class, ': ', \$e->getMessage();
+    }
+    PHP,
+    $environment,
+);
+$first = finish_probe_cache_process($firstProcess, $firstPipes);
+$second = finish_probe_cache_process($secondProcess, $secondPipes);
+echo "$first\n$second\n";
+
+putenv("TEST_PHP_SHARED_CACHE_DIR=$cacheDirectory");
+putenv('TEST_PHP_EVALUATING_SKIPIF=1');
+
+$failureCalls = 0;
+$failureProbe = static function () use (&$failureCalls): never {
+    $failureCalls++;
+    throw new ProbeFailureException("failure $failureCalls");
+};
+
+try {
+    ProbeCache::getFailure($namespace, ['first'], $failureProbe);
+} catch (ProbeFailureException $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+try {
+    ProbeCache::getFailure($namespace, ['first'], $failureProbe);
+} catch (ProbeFailureException $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+try {
+    ProbeCache::getFailure($namespace, ['second'], $failureProbe);
+} catch (ProbeFailureException $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+var_dump($failureCalls);
+
+$successCalls = 0;
+$successProbe = static function () use (&$successCalls): string {
+    $successCalls++;
+    return "success $successCalls";
+};
+
+var_dump(ProbeCache::getFailure($namespace, ['available'], $successProbe));
+var_dump(ProbeCache::getFailure($namespace, ['available'], $successProbe));
+var_dump($successCalls);
+
+putenv('TEST_PHP_EVALUATING_SKIPIF');
+var_dump(ProbeCache::getFailure($namespace, ['first'], static fn(): string => 'uncached success'));
+
+$previous = new Exception('original failure');
+try {
+    ProbeCache::getFailure($namespace, ['wrapped'], static function () use ($previous): never {
+        throw new ProbeFailureException($previous);
+    });
+} catch (Throwable $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+    var_dump($e === $previous);
+}
+
+putenv('TEST_PHP_EVALUATING_SKIPIF=1');
+putenv('TEST_PHP_SHARED_CACHE_DIR');
+try {
+    ProbeCache::getFailure($namespace, ['uncached'], static function (): never {
+        throw new ProbeFailureException('uncached failure');
+    });
+} catch (ProbeFailureException $e) {
+    echo $e::class, ': ', $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+ProbeFailureException: shared failure
+ProbeFailureException: shared failure
+ProbeFailureException: concurrent failure
+ProbeFailureException: concurrent failure
+ProbeFailureException: failure 1
+ProbeFailureException: failure 1
+ProbeFailureException: failure 2
+int(2)
+string(9) "success 1"
+string(9) "success 2"
+int(2)
+string(16) "uncached success"
+Exception: original failure
+bool(true)
+ProbeFailureException: uncached failure
diff --git a/tests/run-test/test_skipif_environment.phpt b/tests/run-test/test_skipif_environment.phpt
new file mode 100644
index 000000000000..8ccb69b0e508
--- /dev/null
+++ b/tests/run-test/test_skipif_environment.phpt
@@ -0,0 +1,14 @@
+--TEST--
+SKIPIF evaluation environment
+--SKIPIF--
+<?php
+if (getenv('TEST_PHP_EVALUATING_SKIPIF') !== '1') {
+    echo 'missing SKIPIF environment marker';
+}
+?>
+--FILE--
+<?php
+var_dump(getenv('TEST_PHP_EVALUATING_SKIPIF'));
+?>
+--EXPECT--
+bool(false)
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.