From 20d579d8b50e1dd8a666a9d2a1d3a5507ad5663c Mon Sep 17 00:00:00 2001 From: Zhiqi Zhang Date: Sat, 12 Sep 2026 07:27:07 +0000 Subject: [PATCH 1/2] Fixed bug GH-22981 (unix socket connect fails with EAGAIN when listen backlog is full) php_network_connect_socket() runs the connect in non-blocking mode so the timeout can be honoured. On a unix domain socket, connect() then returns EAGAIN as soon as the listen backlog is full, instead of waiting for a slot like a blocking connect would. Retry while that is the case, bounded by the connect timeout. --- NEWS | 5 ++ ext/standard/tests/streams/gh22981.phpt | 79 +++++++++++++++++++++++++ main/network.c | 51 ++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 ext/standard/tests/streams/gh22981.phpt diff --git a/NEWS b/NEWS index 1212b4fae181..6558ecd450d5 100644 --- a/NEWS +++ b/NEWS @@ -15,6 +15,11 @@ PHP NEWS . Fixed bug GH-23385 (SplDoublyLinkedList::serialize() use-after-free when __serialize() removes an element). (David Carlier) +- Streams: + . Fixed bug GH-22981 (Connecting to a unix socket whose listen backlog is + full fails with "Resource temporarily unavailable" instead of waiting). + (Zhiqi Zhang) + 10 Sep 2026, PHP 8.6.0beta3 diff --git a/ext/standard/tests/streams/gh22981.phpt b/ext/standard/tests/streams/gh22981.phpt new file mode 100644 index 000000000000..4ba4d60aa418 --- /dev/null +++ b/ext/standard/tests/streams/gh22981.phpt @@ -0,0 +1,79 @@ +--TEST-- +gh22981: connecting to a unix socket with a full listen backlog +--SKIPIF-- + ['backlog' => 1]]); + $server = stream_socket_server('unix://' . $socketPath, $errno, $errstr, + STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx); + if (!$server) { + fwrite(STDERR, "server: $errstr\n"); + exit(1); + } + fwrite(STDOUT, "ready\n"); + /* let the clients pile up in the listen backlog before accepting */ + usleep(200000); + $end = microtime(true) + 5; + for ($accepted = 0; $accepted < 20 && microtime(true) < $end;) { + $conn = @stream_socket_accept($server, 0.1); + if ($conn) { + fwrite($conn, "pong\n"); + $accepted++; + } + } + exit(0); +} + +register_shutdown_function(function () use ($socketPath, &$proc, &$pipes) { + if (is_resource($proc ?? null)) { + fclose($pipes[1]); + proc_terminate($proc); + proc_close($proc); + } + @unlink($socketPath); +}); + +$proc = proc_open([PHP_BINARY, __FILE__, 'server', $socketPath], + [1 => ['pipe', 'w']], $pipes); +if (!is_resource($proc)) { + echo "cannot start server\n"; + exit(1); +} + +fgets($pipes[1]); /* wait until the server is listening */ + +/* listen(1) only leaves room for a couple of pending connections, so most + of these have to wait for the server to accept */ +$conns = []; +for ($i = 0; $i < 20; $i++) { + $conn = stream_socket_client('unix://' . $socketPath, $errno, $errstr, 5); + if (!$conn) { + printf("connect #%d failed: %s\n", $i + 1, $errstr); + exit(1); + } + $conns[] = $conn; +} + +if (trim((string) fgets($conns[count($conns) - 1])) !== 'pong') { + echo "no pong\n"; + exit(1); +} + +echo "ok\n"; +--EXPECT-- +ok diff --git a/main/network.c b/main/network.c index 01e4f2e778d4..b97d96ac0890 100644 --- a/main/network.c +++ b/main/network.c @@ -350,6 +350,57 @@ PHPAPI int php_network_connect_socket(php_socket_t sockfd, *error_code = error; } +#ifdef AF_UNIX + /* connect() to a unix domain socket whose listen backlog is full + * fails with EAGAIN while the socket is in non-blocking mode, + * whereas a blocking connect would wait for a slot to free up. + * Wait and retry until the timeout (if any) expires instead of + * surfacing the error to the caller. */ + if (!asynchronous && error == EAGAIN && addr->sa_family == AF_UNIX) { +#ifdef HAVE_GETTIMEOFDAY + struct timeval limit_time, time_now; + + if (timeout) { + php_network_set_limit_time(&limit_time, timeout); + } +#endif + + while (true) { + struct timeval slice = {0, 10000}; + + /* nothing to poll for here, the connection never started */ + php_pollfd_for(sockfd, 0, &slice); +#ifdef HAVE_GETTIMEOFDAY + if (timeout) { + gettimeofday(&time_now, NULL); + + if (!timercmp(&time_now, &limit_time, <)) { + error = PHP_TIMEOUT_ERROR_VALUE; + break; + } + } +#endif + if ((n = connect(sockfd, addr, addrlen)) == 0) { + error = 0; + goto ok; + } + error = php_socket_errno(); + if (error != EAGAIN) { + break; + } + } + + if (error_code) { + *error_code = error; + } + if (error_string) { + *error_string = php_socket_error_str(error); + } + + return -1; + } +#endif + if (error != EINPROGRESS) { if (error_string) { *error_string = php_socket_error_str(error); From 188e644970f61d60b860877d793a39710370ecf3 Mon Sep 17 00:00:00 2001 From: Zhiqi Zhang Date: Sat, 12 Sep 2026 07:40:57 +0000 Subject: [PATCH 2/2] Bound the unix socket retry wait by the remaining connect timeout The pause between attempts is capped to the time the caller asked for, so a very small (or zero) timeout is no longer overstretched by the 10ms slice. EWOULDBLOCK is now retried as well for the systems where it differs from EAGAIN, and the test also covers a backlog that never drains: the client has to fail once its connect timeout expires. --- ext/standard/tests/streams/gh22981.phpt | 72 ++++++++++++++++++++----- main/network.c | 23 ++++++-- 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/ext/standard/tests/streams/gh22981.phpt b/ext/standard/tests/streams/gh22981.phpt index 4ba4d60aa418..c5eac6b74315 100644 --- a/ext/standard/tests/streams/gh22981.phpt +++ b/ext/standard/tests/streams/gh22981.phpt @@ -15,17 +15,36 @@ fclose($server); --FILE-- ['backlog' => 1]]); - $server = stream_socket_server('unix://' . $socketPath, $errno, $errstr, + $server = stream_socket_server('unix://' . $path, $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx); if (!$server) { fwrite(STDERR, "server: $errstr\n"); exit(1); } fwrite(STDOUT, "ready\n"); + if ($argv[1] === 'stuck') { + /* never accept anything */ + sleep(3); + exit(0); + } /* let the clients pile up in the listen backlog before accepting */ usleep(200000); $end = microtime(true) + 5; @@ -39,22 +58,15 @@ if (($argv[1] ?? '') === 'server') { exit(0); } -register_shutdown_function(function () use ($socketPath, &$proc, &$pipes) { - if (is_resource($proc ?? null)) { - fclose($pipes[1]); - proc_terminate($proc); - proc_close($proc); - } - @unlink($socketPath); -}); - +/* the server starts accepting only after a delay: everyone has to wait for + it, but nobody fails */ $proc = proc_open([PHP_BINARY, __FILE__, 'server', $socketPath], [1 => ['pipe', 'w']], $pipes); if (!is_resource($proc)) { echo "cannot start server\n"; exit(1); } - +$procs[] = [$proc, $pipes[1]]; fgets($pipes[1]); /* wait until the server is listening */ /* listen(1) only leaves room for a couple of pending connections, so most @@ -75,5 +87,39 @@ if (trim((string) fgets($conns[count($conns) - 1])) !== 'pong') { } echo "ok\n"; + +/* the server never accepts: the connect timeout has to be honoured */ +$proc = proc_open([PHP_BINARY, __FILE__, 'stuck', $stuckPath], + [1 => ['pipe', 'w']], $pipes); +if (!is_resource($proc)) { + echo "cannot start stuck server\n"; + exit(1); +} +$procs[] = [$proc, $pipes[1]]; +fgets($pipes[1]); /* wait until the server is listening */ + +for ($i = 0; $i < 2; $i++) { + $conn = stream_socket_client('unix://' . $stuckPath, $errno, $errstr, 3); + if (!$conn) { + printf("fill #%d failed: %s\n", $i + 1, $errstr); + exit(1); + } + $conns[] = $conn; +} + +$t = microtime(true); +$conn = @stream_socket_client('unix://' . $stuckPath, $errno, $errstr, 0.5); +$elapsed = microtime(true) - $t; + +if ($conn) { + echo "connect unexpectedly succeeded\n"; +} elseif ($elapsed < 0.45 || $elapsed > 2.5) { + printf("connect timeout not honoured: %.2fs (%s)\n", $elapsed, $errstr); +} elseif (strpos($errstr, 'timed out') === false) { + printf("unexpected error: %s\n", $errstr); +} else { + echo "timeout ok\n"; +} --EXPECT-- ok +timeout ok diff --git a/main/network.c b/main/network.c index b97d96ac0890..ae6f0aa7d047 100644 --- a/main/network.c +++ b/main/network.c @@ -322,6 +322,13 @@ static inline void php_network_set_limit_time(struct timeval *limit_time, } #endif +/* whether a connect() error means the attempt has to be made again */ +#if defined(EWOULDBLOCK) && EWOULDBLOCK != EAGAIN +# define CONNECT_WOULD_BLOCK(e) ((e) == EAGAIN || (e) == EWOULDBLOCK) +#else +# define CONNECT_WOULD_BLOCK(e) ((e) == EAGAIN) +#endif + /* Connect to a socket using an interruptible connect with optional timeout. * Optionally, the connect can be made asynchronously, which will implicitly * enable non-blocking mode on the socket. @@ -356,9 +363,11 @@ PHPAPI int php_network_connect_socket(php_socket_t sockfd, * whereas a blocking connect would wait for a slot to free up. * Wait and retry until the timeout (if any) expires instead of * surfacing the error to the caller. */ - if (!asynchronous && error == EAGAIN && addr->sa_family == AF_UNIX) { + if (!asynchronous + && CONNECT_WOULD_BLOCK(error) + && addr->sa_family == AF_UNIX) { #ifdef HAVE_GETTIMEOFDAY - struct timeval limit_time, time_now; + struct timeval limit_time, time_now, remaining; if (timeout) { php_network_set_limit_time(&limit_time, timeout); @@ -368,8 +377,6 @@ PHPAPI int php_network_connect_socket(php_socket_t sockfd, while (true) { struct timeval slice = {0, 10000}; - /* nothing to poll for here, the connection never started */ - php_pollfd_for(sockfd, 0, &slice); #ifdef HAVE_GETTIMEOFDAY if (timeout) { gettimeofday(&time_now, NULL); @@ -378,14 +385,20 @@ PHPAPI int php_network_connect_socket(php_socket_t sockfd, error = PHP_TIMEOUT_ERROR_VALUE; break; } + sub_times(limit_time, time_now, &remaining); + if (timercmp(&remaining, &slice, <)) { + slice = remaining; + } } #endif + /* nothing to poll for here, the connection never started */ + php_pollfd_for(sockfd, 0, &slice); if ((n = connect(sockfd, addr, addrlen)) == 0) { error = 0; goto ok; } error = php_socket_errno(); - if (error != EAGAIN) { + if (!CONNECT_WOULD_BLOCK(error)) { break; } }