From 699c0516753281c5b353c11c75ff07ebfb529b71 Mon Sep 17 00:00:00 2001 From: sergey boyko Date: Fri, 21 Aug 2026 07:43:08 -0400 Subject: [PATCH] Fail the write when the peer is gone and fwrite() reports nothing --- src/WritableResourceStream.php | 14 ++- test/WritableResourceStreamTest.php | 159 ++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/WritableResourceStream.php b/src/WritableResourceStream.php index e8e1e27..bda2e1a 100644 --- a/src/WritableResourceStream.php +++ b/src/WritableResourceStream.php @@ -135,11 +135,21 @@ static function ($callbackId) use ( // Broken pipes between processes on macOS/FreeBSD do not detect EOF properly. // fwrite() may write zero bytes on subsequent calls due to the buffer filling again. + // + // A stream that is already at EOF and accepted no bytes has failed even if + // no PHP error surfaced: TLS streams keep returning int(0) from fwrite() + // but stop raising a warning once the EOF flag is set, so $errorCode alone + // cannot be relied on. Without this the write below is mistaken for a + // partial write, the chunk is re-queued and the writable watcher is left + // enabled on a dead descriptor -- which is always writable to the event + // loop, so the callback re-fires forever. /** @psalm-suppress TypeDoesNotContainType $errorCode may be set by error handler. */ - if ($written === 0 && $errorCode !== 0 && $firstWrite) { + if ($written === 0 && ($errorCode !== 0 || \feof($resource)) && $firstWrite) { $writable = false; $suspension?->resume(static fn () => throw new StreamException( - \sprintf('Failed to write to stream (%d): %s', $errorCode, $errorMessage) + $errorCode !== 0 + ? \sprintf('Failed to write to stream (%d): %s', $errorCode, $errorMessage) + : 'Failed to write to stream: the peer closed the connection' )); continue; diff --git a/test/WritableResourceStreamTest.php b/test/WritableResourceStreamTest.php index 287b34d..9e645e1 100644 --- a/test/WritableResourceStreamTest.php +++ b/test/WritableResourceStreamTest.php @@ -100,4 +100,163 @@ public function testUploadBiggerBlockSecure(): void $this->assertStringContainsString($body, buffer($input)); } + + /** + * A peer that is gone leaves the descriptor permanently writable as far as the event + * loop is concerned, so a failed write must be recognised as a failure rather than as + * a partial write. On a TLS stream that cannot be done from $errorCode alone: fwrite() + * returns int(0) and PHP stops raising a warning once the stream's EOF flag is set, + * which is exactly what reading to EOF does. Treating it as a partial write re-queues + * the chunk and returns with the watcher still enabled, spinning the loop forever. + */ + public function testWriteFailsWhenTlsPeerIsGone(): void + { + if (!\extension_loaded('openssl')) { + self::markTestSkipped('ext-openssl is required to establish a TLS connection'); + } + + $this->setTimeout(5); + + [$connection, $client] = $this->createTlsPair(); + + \fclose($client); + + // Reading to EOF sets the stream's EOF flag; from that point fwrite() on the same + // resource returns 0 without reporting anything on affected OpenSSL builds. + $input = new ReadableResourceStream($connection); + self::assertNull($input->read()); + + // Not every build goes quiet. Where fwrite() still reports the failure the existing + // $errorCode check already catches it and there is nothing here to exercise. + $reported = false; + \set_error_handler(static function () use (&$reported): bool { + $reported = true; + + return true; + }); + + try { + \fwrite($connection, 'probe'); + } finally { + \restore_error_handler(); + } + + if ($reported) { + self::markTestSkipped('This build reports failed writes to a closed TLS peer, so $errorCode suffices'); + } + + $output = new WritableResourceStream($connection); + + $this->expectException(StreamException::class); + $this->expectExceptionMessage('Failed to write to stream'); + + $output->write('foobar'); + } + + /** + * @return array{resource, resource} Server side and client side of an established TLS + * connection over loopback. + */ + private function createTlsPair(): array + { + $certificate = $this->createCertificate(); + + $server = \stream_socket_server( + 'tcp://127.0.0.1:0', + $errno, + $errstr, + \STREAM_SERVER_BIND | \STREAM_SERVER_LISTEN, + \stream_context_create(['ssl' => [ + 'local_cert' => $certificate, + 'allow_self_signed' => true, + 'verify_peer' => false, + ]]), + ); + + if ($server === false) { + self::fail("Failed to create TLS server: $errstr"); + } + + $address = \stream_socket_get_name($server, false); + + $client = \stream_socket_client( + "tcp://$address", + $errno, + $errstr, + 5, + \STREAM_CLIENT_CONNECT, + \stream_context_create(['ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ]]), + ); + + if ($client === false) { + self::fail("Failed to connect to TLS server: $errstr"); + } + + $connection = \stream_socket_accept($server, 5); + if ($connection === false) { + self::fail('Failed to accept TLS connection'); + } + + \fclose($server); + + // Both handshakes have to progress in this single process, so drive them + // alternately on non-blocking sockets until both report completion. + \stream_set_blocking($connection, false); + \stream_set_blocking($client, false); + + $serverDone = false; + $clientDone = false; + $deadline = \microtime(true) + 5; + + while (!$serverDone || !$clientDone) { + if (!$serverDone) { + $serverDone = @\stream_socket_enable_crypto($connection, true, \STREAM_CRYPTO_METHOD_TLS_SERVER) === true; + } + + if (!$clientDone) { + $clientDone = @\stream_socket_enable_crypto($client, true, \STREAM_CRYPTO_METHOD_TLS_CLIENT) === true; + } + + if (\microtime(true) > $deadline) { + \unlink($certificate); + self::fail('TLS handshake did not complete'); + } + + \usleep(1000); + } + + \unlink($certificate); + + \stream_set_blocking($connection, true); + \stream_set_blocking($client, true); + + return [$connection, $client]; + } + + private function createCertificate(): string + { + $key = \openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => \OPENSSL_KEYTYPE_RSA]); + $csr = \openssl_csr_new(['commonName' => 'localhost'], $key, ['digest_alg' => 'sha256']); + $certificate = \openssl_csr_sign($csr, null, $key, 1, ['digest_alg' => 'sha256']); + + if ($key === false || $csr === false || $certificate === false) { + self::markTestSkipped('Failed to generate a self-signed certificate'); + } + + \openssl_x509_export($certificate, $certificateOut); + \openssl_pkey_export($key, $keyOut); + + $path = \tempnam(\sys_get_temp_dir(), 'amp-byte-stream-tls-'); + if ($path === false) { + self::fail('Failed to create a temporary file for the certificate'); + } + + \file_put_contents($path, $certificateOut . $keyOut); + + return $path; + } }