From 11d6772caa27cae23c61fb88f78396fc89cb87c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 17 Oct 2026 22:24:53 +0200 Subject: [PATCH 1/4] Fix fatal error when SSH client exits before idle timer fires --- src/SshSocksConnector.php | 7 +++--- tests/IntegrationSshSocksConnectorTest.php | 25 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/SshSocksConnector.php b/src/SshSocksConnector.php index 15b1297..19dad4e 100644 --- a/src/SshSocksConnector.php +++ b/src/SshSocksConnector.php @@ -194,14 +194,15 @@ public function idle() if ($this->pending < 1 && $this->idleTimer === null && $this->listen !== null) { $listen =& $this->listen; + $promise = $this->listen; $debug = $this->debug; - $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () use (&$listen, $debug) { - $listen->then(function (Process $process) { + $this->idleTimer = $this->loop->addTimer($this->idlePeriod, function () use (&$listen, $promise, $debug) { + $promise->then(function (Process $process) { $process->terminate(); }, function () { // ignore to avoid reporting unhandled rejection }); - $listen->cancel(); + $promise->cancel(); $listen = null; // @codeCoverageIgnoreStart diff --git a/tests/IntegrationSshSocksConnectorTest.php b/tests/IntegrationSshSocksConnectorTest.php index 5f35d25..f837862 100644 --- a/tests/IntegrationSshSocksConnectorTest.php +++ b/tests/IntegrationSshSocksConnectorTest.php @@ -136,6 +136,31 @@ public function testConnectCancellationWillRejectWithException() Loop::run(); } + public function testConnectCancellationWillRejectWithExceptionWhenProcessExitsBeforeIdleTimeout() + { + $connector = new SshSocksConnector('host'); + + $ref = new \ReflectionProperty($connector, 'cmd'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, 'exec false; #'); + + // delay idle timer, so process exit is reported before idle timer fires + $ref = new \ReflectionProperty($connector, 'idlePeriod'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, 0.2); + + $promise = $connector->connect('example.com:80'); + $promise->cancel(); + + $promise->then(null, $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); + + Loop::run(); + } + public function testConnectWillRejectWithExceptionAndTerminateProcessWhenProcessOutputsLineExceedingMaximumBufferSize() { $connector = new SshSocksConnector('host'); From f602b08407df2cdb416f746d1c7611b1a7b90b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 17 Oct 2026 22:28:06 +0200 Subject: [PATCH 2/4] Fix orphaned SSH client process when previous process exits late --- src/SshSocksConnector.php | 8 +-- tests/IntegrationSshSocksConnectorTest.php | 61 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/SshSocksConnector.php b/src/SshSocksConnector.php index 19dad4e..97eb09b 100644 --- a/src/SshSocksConnector.php +++ b/src/SshSocksConnector.php @@ -105,11 +105,13 @@ public function connect($uri) } // no listening SSH client process started? start one and remember reference - // if spawning SSH client process fails, clear reference for next start + // if spawning SSH client process fails, clear reference for next start unless already replaced if ($this->listen === null) { $listen =& $this->listen; - $this->listen = $this->listen()->then(null, function ($e) use (&$listen) { - $listen = null; + $this->listen = $promise = $this->listen()->then(null, function ($e) use (&$listen, &$promise) { + if ($listen === $promise) { + $listen = null; + } throw $e; }); diff --git a/tests/IntegrationSshSocksConnectorTest.php b/tests/IntegrationSshSocksConnectorTest.php index f837862..9dae6e7 100644 --- a/tests/IntegrationSshSocksConnectorTest.php +++ b/tests/IntegrationSshSocksConnectorTest.php @@ -161,6 +161,67 @@ public function testConnectCancellationWillRejectWithExceptionWhenProcessExitsBe Loop::run(); } + public function testConnectWillTerminateNewProcessWhenPreviousProcessExitsLateAfterIdleTimeout() + { + $connector = new SshSocksConnector('host'); + + $ref = new \ReflectionProperty($connector, 'cmd'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, 'trap "sleep 0.2; exit 0" TERM; while :; do sleep 0.01; done; #'); + + $deferred = new Deferred(); + $socks = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $socks->expects($this->once())->method('connect')->with('example.com:81')->willReturn($deferred->promise()); + + $ref = new \ReflectionProperty($connector, 'socks'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, $socks); + + // first process only exits 0.2s after being terminated by the idle timer + $promise = $connector->connect('example.com:80'); + $promise->cancel(); + + $promise->then(null, $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); + + $process = null; + $onRejected = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); + Loop::addTimer(0.05, function () use ($connector, &$process, $onRejected) { + $ref = new \ReflectionProperty($connector, 'cmd'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, 'echo "debug1: channel 0: new [port listener]" >&2; exec sleep 2; #'); + + $promise = $connector->connect('example.com:81'); + $promise->then(null, $onRejected); + + $ref = new \ReflectionProperty($connector, 'listen'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $listen = $ref->getValue($connector); + assert($listen instanceof PromiseInterface); + + $listen->then(function ($value) use (&$process) { + $process = $value; + }); + }); + + // second process becomes idle only after first process exited + Loop::addTimer(0.5, function () use ($deferred) { + $deferred->reject(new \RuntimeException('Connection refused')); + }); + + Loop::run(); + + $this->assertInstanceOf('React\ChildProcess\Process', $process); + $this->assertTrue($process->isTerminated()); + } + public function testConnectWillRejectWithExceptionAndTerminateProcessWhenProcessOutputsLineExceedingMaximumBufferSize() { $connector = new SshSocksConnector('host'); From d485d1eb540989ff007cb6196d93a630bdad7696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 17 Oct 2026 22:31:47 +0200 Subject: [PATCH 3/4] Fix leaking SOCKS connection when cancelling before SSH client is ready --- src/SshSocksConnector.php | 8 ++++-- tests/IntegrationSshSocksConnectorTest.php | 30 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/SshSocksConnector.php b/src/SshSocksConnector.php index 97eb09b..a66b45f 100644 --- a/src/SshSocksConnector.php +++ b/src/SshSocksConnector.php @@ -148,8 +148,12 @@ public function connect($uri) throw new \RuntimeException('Connection to ' . $uri . ' cancelled while waiting for SSH client'); }); - $this->listen->then(function () use ($uri, $that, $socks, $deferred, &$connecting) { - // SSH client process started => start SOCKS connection + $this->listen->then(function () use ($uri, $that, $socks, $deferred, &$connecting, &$cancelled) { + // SSH client process started => start SOCKS connection (unless connection attempt has already been cancelled) + if ($cancelled) { + return; + } + $connecting = $socks->connect($uri)->then(function (ConnectionInterface $connection) use ($that, $deferred) { // connection to SOCKS server successful => consider idle when connection closes again $connection->on('close', function () use ($that) { diff --git a/tests/IntegrationSshSocksConnectorTest.php b/tests/IntegrationSshSocksConnectorTest.php index 9dae6e7..4301983 100644 --- a/tests/IntegrationSshSocksConnectorTest.php +++ b/tests/IntegrationSshSocksConnectorTest.php @@ -161,6 +161,36 @@ public function testConnectCancellationWillRejectWithExceptionWhenProcessExitsBe Loop::run(); } + public function testConnectCancellationWillNotInvokeSocksConnectorWhenProcessBecomesReadyForOtherPendingConnection() + { + $connector = new SshSocksConnector('host'); + + $ref = new \ReflectionProperty($connector, 'cmd'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, 'echo "debug1: channel 0: new [port listener]" >&2; exec sleep 10; #'); + + $socks = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $socks->expects($this->once())->method('connect')->with('example.com:81')->willReturn(\React\Promise\reject(new \RuntimeException('Connection refused'))); + + $ref = new \ReflectionProperty($connector, 'socks'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, $socks); + + $first = $connector->connect('example.com:80'); + $first->cancel(); + + $first->then(null, $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); + + $second = $connector->connect('example.com:81'); + $second->then(null, $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException'))); + + Loop::run(); + } + public function testConnectWillTerminateNewProcessWhenPreviousProcessExitsLateAfterIdleTimeout() { $connector = new SshSocksConnector('host'); From bab524993f48e820773cc48781958ed9226302be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 17 Oct 2026 23:16:43 +0200 Subject: [PATCH 4/4] Fix reusing stale SSH client process after it exits unexpectedly --- src/SshSocksConnector.php | 20 ++++++------ tests/IntegrationSshSocksConnectorTest.php | 37 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/SshSocksConnector.php b/src/SshSocksConnector.php index a66b45f..d31ae84 100644 --- a/src/SshSocksConnector.php +++ b/src/SshSocksConnector.php @@ -105,15 +105,8 @@ public function connect($uri) } // no listening SSH client process started? start one and remember reference - // if spawning SSH client process fails, clear reference for next start unless already replaced if ($this->listen === null) { - $listen =& $this->listen; - $this->listen = $promise = $this->listen()->then(null, function ($e) use (&$listen, &$promise) { - if ($listen === $promise) { - $listen = null; - } - throw $e; - }); + $this->listen = $this->listen(); // @codeCoverageIgnoreStart if ($this->debug) { @@ -262,13 +255,20 @@ private function listen() $process->terminate(); }); - $process->on('exit', function ($code) use ($deferred, &$last) { + // if SSH client process exits, clear reference for next start unless already replaced + $listen =& $this->listen; + $promise = $deferred->promise(); + $process->on('exit', function ($code) use ($deferred, &$last, &$listen, $promise) { + if ($listen === $promise) { + $listen = null; + } + $deferred->reject(new \RuntimeException( $last, (int) $code )); }); - return $deferred->promise(); + return $promise; } } diff --git a/tests/IntegrationSshSocksConnectorTest.php b/tests/IntegrationSshSocksConnectorTest.php index 4301983..0289e82 100644 --- a/tests/IntegrationSshSocksConnectorTest.php +++ b/tests/IntegrationSshSocksConnectorTest.php @@ -191,6 +191,43 @@ public function testConnectCancellationWillNotInvokeSocksConnectorWhenProcessBec Loop::run(); } + public function testConnectWillStartNewProcessWhenPreviousProcessExitsAfterPortListenerMessage() + { + $connector = new SshSocksConnector('host'); + + $ref = new \ReflectionProperty($connector, 'cmd'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, 'echo "debug1: channel 0: new [port listener]" >&2; exec sleep 0.1; #'); + + $deferred = new Deferred(); + $socks = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $socks->expects($this->once())->method('connect')->with('example.com:80')->willReturn($deferred->promise()); + + $ref = new \ReflectionProperty($connector, 'socks'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, $socks); + + $connector->connect('example.com:80'); + + $onRejected = $this->expectCallableOnceWith($this->isInstanceOf('RuntimeException')); + Loop::addTimer(0.5, function () use ($connector, $onRejected) { + $ref = new \ReflectionProperty($connector, 'cmd'); + if (PHP_VERSION_ID < 80100) { + $ref->setAccessible(true); + } + $ref->setValue($connector, 'echo "Connection closed by remote host" >&2; exit 255; #'); + + $promise = $connector->connect('example.com:80'); + $promise->then(null, $onRejected); + }); + + Loop::run(); + } + public function testConnectWillTerminateNewProcessWhenPreviousProcessExitsLateAfterIdleTimeout() { $connector = new SshSocksConnector('host');