Skip to content

Stream SSH mode crashes/hangs in ReactPHP websocket server (channel 2 / exec bootstrap) #3

@solpreneur

Description

@solpreneur

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) to 127.0.0.1:8090
  • Remote SSH host reachable manually (ssh works)

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() and disconnect() paths can block/fail in ways that stall a single-threaded event loop.

Reproduction (before)

  1. Start stream server: php artisan terminal:serve
  2. Using dynamic configuration open stream terminal to remote SSH host
  3. Connect once (may work), then reconnect or open another remote
  4. Observe one of:
    • channel open exception (Please close channel (2)...)
    • websocket hangs / 502 via proxy
    • server loop crashes
    • websocket timeout

Proposed fix

In src/WebSocket/TerminalPtyBridge.php:

  1. 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
  2. Make SSH read() timeout-safe:
    • catch TimeoutException / Throwable and return empty output
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions