From a3e50aac647369fafaaeb0608f806ee6651d941c Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Tue, 8 Sep 2026 20:35:05 -0400 Subject: [PATCH] Do not treat an interrupted wait as a write failure. --- .../Socket/Timeout/BlockingSelectEnforcer.php | 41 ++++++++----- .../Timeout/BlockingSelectEnforcerTest.php | 61 +++++++++++++++++++ 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/FreeDSx/Socket/Timeout/BlockingSelectEnforcer.php b/src/FreeDSx/Socket/Timeout/BlockingSelectEnforcer.php index 7ddd025..2914daa 100644 --- a/src/FreeDSx/Socket/Timeout/BlockingSelectEnforcer.php +++ b/src/FreeDSx/Socket/Timeout/BlockingSelectEnforcer.php @@ -48,7 +48,6 @@ public function write( $write = [$stream]; $read = []; $except = []; - error_clear_last(); $ready = @stream_select( $read, $write, @@ -56,9 +55,7 @@ public function write( $timeout, ); - if ($ready === false) { - $this->throwWriteError(); - } + // The write itself decides whether the socket is still usable. if ($ready === 0) { throw new WriteTimeoutException(sprintf( 'The write operation timed out after %d seconds.', @@ -66,19 +63,10 @@ public function write( )); } - error_clear_last(); - $written = @fwrite( + $remaining = $this->writeRemaining( $stream, $remaining, ); - if ($written === false) { - $this->throwWriteError(); - } - - $remaining = substr( - $remaining, - $written, - ); } } finally { @stream_set_blocking( @@ -87,4 +75,29 @@ public function write( ); } } + + /** + * Writes what is left and returning what still has to go out. + * + * @param resource $stream + */ + private function writeRemaining( + $stream, + string $remaining, + ): string { + error_clear_last(); + $written = @fwrite( + $stream, + $remaining, + ); + + if ($written === false) { + $this->throwWriteError(); + } + + return substr( + $remaining, + $written, + ); + } } diff --git a/tests/unit/Timeout/BlockingSelectEnforcerTest.php b/tests/unit/Timeout/BlockingSelectEnforcerTest.php index 1af698b..22f8680 100644 --- a/tests/unit/Timeout/BlockingSelectEnforcerTest.php +++ b/tests/unit/Timeout/BlockingSelectEnforcerTest.php @@ -93,6 +93,67 @@ public function test_it_throws_when_the_peer_stops_reading(): void ); } + public function test_a_signal_arriving_while_it_waits_does_not_abort_the_send(): void + { + $this->requireFillableSocket(); + + if (!function_exists('pcntl_async_signals')) { + self::markTestSkipped('The pcntl extension is required to interrupt the wait.'); + } + + [$local, $remote] = $this->createSocketPair(); + $this->fillSocket($local); + + pcntl_async_signals(true); + pcntl_signal( + SIGALRM, + static function () use ($remote): void { + // Frees room so the interrupted send can carry on once it resumes. + stream_set_blocking($remote, false); + + while (fread($remote, 65536) !== '') { + } + }, + ); + pcntl_alarm(1); + + try { + $this->subject->write( + $local, + 'payload', + 10, + ); + } finally { + pcntl_alarm(0); + pcntl_signal(SIGALRM, SIG_DFL); + } + + stream_set_blocking($remote, false); + $drained = ''; + + while (($chunk = fread($remote, 65536)) !== '' && $chunk !== false) { + $drained .= $chunk; + } + + self::assertStringEndsWith( + 'payload', + $drained, + ); + } + + /** + * @param resource $stream + */ + private function fillSocket($stream): void + { + stream_set_blocking($stream, false); + + while (@fwrite($stream, str_repeat('x', 65536)) > 0) { + } + + stream_set_blocking($stream, true); + } + /** * @return array{0: resource, 1: resource} */