Skip to content

Sockets from synchronous stream_socket_client() are left in non-blocking mode on Windows #24173

Description

@vibbow

Description

On Windows, a socket created by a synchronous stream_socket_client() / fsockopen() connect stays in non-blocking mode at the OS level, while the stream reports blocked => true. fread()/fwrite() hide this because they poll before calling recv()/send(). Paths that call recv() directly don't, so they fail immediately instead of waiting for data:

  • stream_socket_recvfrom() returns false at once.
  • socket_import_stream() marks the imported socket as blocking (it copies the stream's is_blocked), but socket_read()/socket_recv() fail immediately with WSAEWOULDBLOCK (10035).

I found this while investigating #24171. It is a separate bug and independent of that fix.

The following code:

<?php
// Child process: a server that replies "hello" to each connection 300 ms
// after accepting it.
$server = <<<'SRV'
$srv = stream_socket_server('tcp://127.0.0.1:0', $en, $es);
echo stream_socket_get_name($srv, false), "\n";
$pending = [];
while (true) {
    $r = [$srv]; $w = $e = null;
    if (stream_select($r, $w, $e, 0, 10000)) {
        $pending[] = [stream_socket_accept($srv), hrtime(true) + 300e6];
    }
    foreach ($pending as $i => [$c, $due]) {
        if (hrtime(true) >= $due) {
            @fwrite($c, "hello");
            fclose($c);
            unset($pending[$i]);
        }
    }
}
SRV;
$proc = proc_open([PHP_BINARY, '-n', '-r', $server], [1 => ['pipe', 'w']], $pipes);
$addr = trim(fgets($pipes[1]));

function test(string $name, string $addr, callable $fn): void {
    $c = stream_socket_client("tcp://$addr", $en, $es, 5);
    $blocked = stream_get_meta_data($c)['blocked'] ? 'true' : 'false';
    $t = hrtime(true);
    $r = $fn($c);
    printf("%-46s blocked=%s  %-28s after %5.1f ms\n",
        $name, $blocked, var_export($r, true), (hrtime(true) - $t) / 1e6);
    fclose($c);
}

test('stream_socket_recvfrom()', $addr, fn($c) => stream_socket_recvfrom($c, 100));

if (extension_loaded('sockets')) {
    test('socket_import_stream() + socket_read()', $addr, function ($c) {
        $s = socket_import_stream($c);
        $r = @socket_read($s, 100);
        return $r === false ? 'false (error ' . socket_last_error($s) . ')' : $r;
    });
}

test('stream_set_blocking(true) + stream_socket_recvfrom()', $addr, function ($c) {
    stream_set_blocking($c, true);
    return stream_socket_recvfrom($c, 100);
});

proc_terminate($proc);

Resulted in this output:

stream_socket_recvfrom()                       blocked=true  false                        after   0.0 ms
socket_import_stream() + socket_read()         blocked=true  'false (error 10035)'        after   0.0 ms
stream_set_blocking(true) + stream_socket_recvfrom() blocked=true  'hello'                      after 293.7 ms

But I expected this output instead (this is the output with the fix below applied, and what the POSIX code path does):

stream_socket_recvfrom()                       blocked=true  'hello'                      after 304.2 ms
socket_import_stream() + socket_read()         blocked=true  'hello'                      after 303.6 ms
stream_set_blocking(true) + stream_socket_recvfrom() blocked=true  'hello'                      after 305.2 ms

Explicitly calling stream_set_blocking($c, true) (the last line) works around it, because it calls ioctlsocket(FIONBIO, 0).

Cause

php_network_connect_socket() in main/network.c switches the socket to non-blocking mode for the connect. For a synchronous connect, it is supposed to restore the original mode afterwards. The Windows versions of the macros are:

#ifdef PHP_WIN32
typedef u_long php_non_blocking_flags_t;
#  define SET_SOCKET_BLOCKING_MODE(sock, save) \
	save = TRUE; ioctlsocket(sock, FIONBIO, &save)
#  define RESTORE_SOCKET_BLOCKING_MODE(sock, save) \
	ioctlsocket(sock, FIONBIO, &save)

SET sets save = TRUE, and FIONBIO only reads its argument; it does not write back the previous mode. So RESTORE passes TRUE again, and the socket stays non-blocking. The POSIX versions save the flags with fcntl(F_GETFL) and restore them correctly.

Proposed fix

Winsock has no way to query a socket's current blocking mode. However, every caller of php_network_connect_socket() passes a freshly created socket, which is blocking by default:

  • php_network_connect_socket_to_host()
  • the Unix socket connect in main/streams/xp_socket.c
  • php_connect_nonb(), used by ext/ftp for passive data connections

So restoring to blocking matches what the POSIX code does:

--- a/main/network.c
+++ b/main/network.c
@@ -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) \

Async connects (STREAM_CLIENT_ASYNC_CONNECT) don't call RESTORE, so they are unaffected.

I tested the patch on a local 8.5.11 NTS x64 build:

  • Repro script: the expected output above.
  • Read timeout: after stream_set_timeout($c, 0, 200000), fread() still times out after about 200 ms with timed_out => true.
  • Non-blocking mode: after stream_set_blocking($c, false), fread() still returns immediately.
  • Connects: connect latency and refused/timed-out connects are unchanged.

I'll open a PR for it.

PHP Version

PHP 8.5.11 (cli) (built: Sep 22 2026 13:51:38) (NTS Visual C++ 2022 x64)
Copyright (c) The PHP Group
Built by The PHP Group
Zend Engine v4.5.11, Copyright (c) Zend Technologies
    with Xdebug v3.5.3, Copyright (c) 2002-2026, by Derick Rethans
    with Zend OPcache v8.5.11, Copyright (c), by Zend Technologies

Operating System

Windows 11 Pro 10.0.26300

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions