v1.3.3: fix SSH terminal no-output bug (critical)

Three bugs fixed in SshTerminalManager:

1. connect()/connectWithKey() never returned — readOutput() was called
   with withContext(Dispatchers.IO), creating a blocking infinite loop
   that prevented the coroutine from completing. Fixed by launching
   readOutput as a separate coroutine via GlobalScope.launch().

2. \r handling in readOutput() silently ate all terminal output —
   \r cleared currentLine, then the subsequent \n found an empty
   line and skipped it. Fixed: \r now finalizes currentLine instead
   of clearing it, properly handling \r\n line endings.

3. Added initial newline on connect to trigger the shell prompt so
   users see output immediately after connection.
This commit is contained in:
茂之钳
2026-05-26 07:31:30 +00:00
parent eb03204347
commit 902a4c5572
@@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.io.File import java.io.File
@@ -16,6 +17,7 @@ import java.io.OutputStream
import java.util.Vector import java.util.Vector
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@Serializable @Serializable
data class SshConfig( data class SshConfig(
val host: String = "", val host: String = "",
@@ -115,6 +117,7 @@ class SshTerminalManager(
val connectionState: StateFlow<SshConnectionState> = _connectionState.asStateFlow() val connectionState: StateFlow<SshConnectionState> = _connectionState.asStateFlow()
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
private var readOutputJob: kotlinx.coroutines.Job? = null
var fingerprintCallback: ((HostKeyInfo) -> Boolean)? = null var fingerprintCallback: ((HostKeyInfo) -> Boolean)? = null
private var lastConfig: SshConfig? = null private var lastConfig: SshConfig? = null
private var lastKeyPath: String? = null private var lastKeyPath: String? = null
@@ -149,14 +152,20 @@ class SshTerminalManager(
currentLine.clear() currentLine.clear()
running.set(true) running.set(true)
// Send initial newline to trigger shell prompt
try {
outputStream?.write("\n".toByteArray(Charsets.UTF_8))
outputStream?.flush()
} catch (_: Exception) {}
_connectionState.value = SshConnectionState( _connectionState.value = SshConnectionState(
connected = true, connected = true,
host = config.host, host = config.host,
displayName = config.displayName.ifEmpty { config.host } displayName = config.displayName.ifEmpty { config.host }
) )
// Start reading output // Start reading output in a separate coroutine (don't block connect())
withContext(Dispatchers.IO) { readOutputJob = kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) {
readOutput() readOutput()
} }
@@ -218,6 +227,12 @@ class SshTerminalManager(
currentLine.clear() currentLine.clear()
running.set(true) running.set(true)
// Send initial newline to trigger shell prompt
try {
outputStream?.write("\n".toByteArray(Charsets.UTF_8))
outputStream?.flush()
} catch (_: Exception) {}
_connectionState.value = SshConnectionState( _connectionState.value = SshConnectionState(
connected = true, connected = true,
host = config.host, host = config.host,
@@ -225,7 +240,7 @@ class SshTerminalManager(
authMode = "key" authMode = "key"
) )
withContext(Dispatchers.IO) { readOutputJob = kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) {
readOutput() readOutput()
} }
@@ -254,15 +269,20 @@ class SshTerminalManager(
for (ch in text) { for (ch in text) {
when (ch) { when (ch) {
'\n' -> { '\n' -> {
// Newline: finalize current line (ignore empty if already cleared by \r)
if (currentLine.isNotEmpty()) { if (currentLine.isNotEmpty()) {
lineBuffer.add(currentLine.toString()) lineBuffer.add(currentLine.toString())
currentLine.clear() currentLine.clear()
} }
} }
'\r' -> { '\r' -> {
// Bug 3 fix: carriage return — clear current line for overwrite // Carriage return: in \r\n pairs, the next \n handles the line.
// If currentLine has content (no preceding \r), finalize it.
if (currentLine.isNotEmpty()) {
lineBuffer.add(currentLine.toString())
currentLine.clear() currentLine.clear()
} }
}
else -> currentLine.append(ch) else -> currentLine.append(ch)
} }
} }
@@ -394,6 +414,8 @@ class SshTerminalManager(
fun disconnect() { fun disconnect() {
running.set(false) running.set(false)
readOutputJob?.cancel()
readOutputJob = null
closeSftpChannel() closeSftpChannel()
try { channel?.disconnect() } catch (_: Exception) {} try { channel?.disconnect() } catch (_: Exception) {}
try { session?.disconnect() } catch (_: Exception) {} try { session?.disconnect() } catch (_: Exception) {}