Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ PHP NEWS
. Fixed chown() and lchown() failing to resolve user names in ZTS builds
when getpwnam_r() needs a larger buffer. (Ilia Alshanetsky)

- Streams:
. Fixed bug GH-24173 (Sockets from synchronous stream_socket_client() are
left in non-blocking mode on Windows). (vibbow)

22 Oct 2026, PHP 8.4.27

- BCMath:
Expand Down
25 changes: 25 additions & 0 deletions ext/standard/tests/streams/gh24173.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
--TEST--
GH-24173 (Sockets from synchronous stream_socket_client() are left in non-blocking mode on Windows)
--EXTENSIONS--
sockets
--SKIPIF--
<?php
if (PHP_OS_FAMILY !== 'Windows') die('skip Windows only');
?>
--FILE--
<?php
$srv = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
$client = stream_socket_client('tcp://' . stream_socket_get_name($srv, false), $errno, $errstr, 5);
$conn = stream_socket_accept($srv);

$sock = socket_import_stream($client);
socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 0, 'usec' => 100000]);

// No data is sent: a blocking socket waits and times out, while a
// non-blocking one fails at once with WSAEWOULDBLOCK.
var_dump(@socket_read($sock, 10));
var_dump(socket_last_error($sock) === SOCKET_ETIMEDOUT);
?>
--EXPECT--
bool(false)
bool(true)
31 changes: 31 additions & 0 deletions ext/standard/tests/streams/gh24173_write_timeout.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
--TEST--
GH-24173 (Timed writes on socket streams must not block past the timeout)
--SKIPIF--
<?php
if (PHP_OS_FAMILY !== 'Windows') die('skip Windows only');
?>
--FILE--
<?php
$srv = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
$client = stream_socket_client('tcp://' . stream_socket_get_name($srv, false), $errno, $errstr, 5);
$conn = stream_socket_accept($srv);

// Neither side reads, so writes eventually fill the socket buffers. A write
// that times out may still have written part of the chunk, so stop at the
// first timeout rather than at the first zero-length write.
$chunk = str_repeat('x', 1 << 20);
foreach (['client' => $client, 'accepted' => $conn] as $name => $stream) {
stream_set_timeout($stream, 0, 200000);
for ($i = 0; $i < 256; $i++) {
@fwrite($stream, $chunk);
if (stream_get_meta_data($stream)['timed_out']) {
break;
}
}
echo $name, ': ';
var_dump(stream_get_meta_data($stream)['timed_out']);
}
?>
--EXPECT--
client: bool(true)
accepted: bool(true)
4 changes: 3 additions & 1 deletion main/network.c
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,10 @@ PHPAPI int php_network_getaddresses(const char *host, int socktype, struct socka
typedef u_long php_non_blocking_flags_t;
# define SET_SOCKET_BLOCKING_MODE(sock, save) \
save = TRUE; ioctlsocket(sock, FIONBIO, &save)
/* Winsock cannot query the current mode; callers pass freshly created
* (blocking) sockets, so restore to blocking. */
# define RESTORE_SOCKET_BLOCKING_MODE(sock, save) \
ioctlsocket(sock, FIONBIO, &save)
save = FALSE; ioctlsocket(sock, FIONBIO, &save)
#else
typedef int php_non_blocking_flags_t;
# define SET_SOCKET_BLOCKING_MODE(sock, save) \
Expand Down
13 changes: 13 additions & 0 deletions main/streams/xp_socket.c
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ static ssize_t php_sockop_write(php_stream *stream, const char *buf, size_t coun
else
ptimeout = &sock->timeout;

#ifdef PHP_WIN32
/* There is no MSG_DONTWAIT on Windows, so switch to non-blocking mode for a
* timed write; otherwise send() could block past the timeout. */
bool restore_blocking = sock->is_blocked && ptimeout
&& php_set_sock_blocking(sock->socket, 0) == SUCCESS;
#endif

retry:
didwrite = send(sock->socket, buf, XP_SOCK_BUF_SIZE(count), (sock->is_blocked && ptimeout) ? MSG_DONTWAIT : 0);

Expand Down Expand Up @@ -113,6 +120,12 @@ static ssize_t php_sockop_write(php_stream *stream, const char *buf, size_t coun
}
}

#ifdef PHP_WIN32
if (restore_blocking) {
php_set_sock_blocking(sock->socket, 1);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be changing semantic and can potentially break things. The issues is that with async connect php_network_connect_socket() returns early and intentionally leaves the OS socket non-blocking, but php_stream_generic_socket_factory() still sets is_blocked = 1. So the first timed fwrite() on such stream switches to non-blocking and then restores to blocking here, which is a mode the socket never had. A later stream_socket_recvfrom() or stream_socket_sendto() then blocks indefinitely on Windows while it still returns immediately on POSIX.

The actual problem is that is_blocked tracks what PHP wants and not what the OS socket is, and Winsock cannot query the real mode. We should probably track it separately (there seems to be still space to do it in ABI safe way in php_netstream_data_t but would need to think about it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also it should check the return value here.

}
#endif

if (didwrite > 0) {
php_stream_notify_progress_increment(PHP_STREAM_CONTEXT(stream), didwrite, 0);
}
Expand Down
Loading