-
Notifications
You must be signed in to change notification settings - Fork 8
Stream SSH mode crashes/hangs in ReactPHP websocket server (channel 2 / exec bootstrap) #3
Copy link
Copy link
Open
Description
Hi! I’m seeing reproducible stream-mode failures with SSH connections in v2.3.0.
Problem
When connecting to remote SSH targets in stream mode, the server could:
- crash with:
RuntimeException: Please close the channel (2) before trying to open it again - crash with phpseclib channel exec errors (e.g. undefined server channel key during
pty-req) - hang after one successful connection, causing later websocket connects to stall/timeout
Environment
- Laravel app in Docker
- websocket server via
php artisan terminal:serve - Nginx reverse proxy (
/terminal-ws) to127.0.0.1:8090 - Remote SSH host reachable manually (
sshworks)
Root cause
In TerminalPtyBridge::startSsh():
enablePTY()+exec('')uses CHANNEL_EXEC bootstrap and can fail on some SSH servers.- very low/zero timeout behavior around startup/read can leave shell channel state inconsistent.
Also in lifecycle:
read()anddisconnect()paths can block/fail in ways that stall a single-threaded event loop.
Reproduction (before)
- Start stream server:
php artisan terminal:serve - Using dynamic configuration open stream terminal to remote SSH host
- Connect once (may work), then reconnect or open another remote
- Observe one of:
- channel open exception (
Please close channel (2)...) - websocket hangs / 502 via proxy
- server loop crashes
- websocket timeout
- channel open exception (
Proposed fix
In src/WebSocket/TerminalPtyBridge.php:
- Replace
enablePTY()+exec('')startup with explicit shell-channel bootstrap:- set normal timeout for startup
- call
write('', SSH2::CHANNEL_SHELL)once to open interactive shell channel - switch to short timeout afterward for non-blocking loop behavior
- Make SSH
read()timeout-safe:- catch
TimeoutException/Throwableand return empty output
- catch
- Make SSH
terminate()best-effort:- wrap disconnect in try/catch and set short timeout before disconnect
Why this is safe
- Keeps existing behavior for local PTY path untouched
- Keeps SSH interactive channel semantics, but avoids fragile CHANNEL_EXEC bootstrap
- Improves robustness under network/server edge cases without changing API surface
What works for me
--- a/src/WebSocket/TerminalPtyBridge.php
+++ b/src/WebSocket/TerminalPtyBridge.php
@@ -107,10 +107,12 @@
}
}
- // Set timeout to 0 for non-blocking reads (critical for ReactPHP event loop)
- $ssh->setTimeout(0);
- $ssh->enablePTY();
- $ssh->exec('');
+ // Bootstrap shell with a normal timeout so CHANNEL_SHELL opens cleanly.
+ // Using an ultra-low timeout here can leave channel state half-open.
+ $ssh->setTimeout(max(5, $this->config->timeout));
+ $ssh->write('', \phpseclib3\Net\SSH2::CHANNEL_SHELL);
+ // Then switch to a tiny timeout for non-blocking event-loop reads.
+ $ssh->setTimeout(0.01);
$this->sshShell = $ssh;
// SSH sessions use pid -1 (sentinel) since there's no local process
$this->registry->register($this->sessionId, -1, $this->userId);
@@ -131,8 +133,14 @@
public function read(): string
{
if ($this->sshShell !== null) {
- // With setTimeout(0), this returns immediately with available data or empty string
- return $this->sshShell->read('') ?: '';
+ // Keep loop non-blocking for SSH sessions.
+ try {
+ return $this->sshShell->read('') ?: '';
+ } catch (\phpseclib3\Exception\TimeoutException) {
+ return '';
+ } catch (\Throwable) {
+ return '';
+ }
}
$output = '';
@@ -204,7 +212,13 @@
public function terminate(): void
{
if ($this->sshShell !== null) {
- $this->sshShell->disconnect();
+ // Avoid blocking the event loop on remote disconnect edge-cases.
+ try {
+ $this->sshShell->setTimeout(0.01);
+ $this->sshShell->disconnect();
+ } catch (\Throwable) {
+ // Best-effort close: keep loop healthy even if SSH close hangs/fails.
+ }
$this->sshShell = null;
$this->registry->unregister($this->sessionId);
return;Validation (after)
- Multiple remote SSH stream connect/disconnect cycles succeed
- No channel(2) exception
- No undefined channel exec key crash
- No event-loop freeze after first connection
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels