From f131256e702697f843aaef27f51cf3fd7f73f1a5 Mon Sep 17 00:00:00 2001 From: wdrs-dev Date: Thu, 13 Aug 2026 08:25:49 -0300 Subject: [PATCH] =?UTF-8?q?fix(state=20machine):=20PENDING=20is=20queued,?= =?UTF-8?q?=20not=20refused=20=E2=80=94=20don't=20kill=20the=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `processEvent` has three outcomes, not two. `IGNORED` means no transition matched — that is the refusal. `PENDING` means another event is being processed and this one was QUEUED, which is what the default `queuePendingEventHandler` does. Comparing against `PROCESSED` alone turns "queued" into "refused". The authorization guards have no effect of their own — they only ask whether a packet fits the current state — so for them `PENDING` is a fine answer: the event will be processed. But `requireAccepted` treats their `false` as a protocol violation, and a protocol violation tears down the whole transport, not just a channel. The window opens whenever the packet loop reads a packet while a locally-initiated event has suspended to write to the socket: `OpenChannel`, `SendChannelRequest`, a window-change. In an interactive session that is constantly. Observed in the wild as the client sending `SSH_MSG_DISCONNECT` reason 2 with `Unexpected SSH packet SSH_MSG_CHANNEL_DATA in the current protocol state` — four times in six minutes of ordinary typing, with sshd logging the disconnect it RECEIVED from us. `process()` is left alone for events that have side effects: for those, "queued" is genuinely not "done". Measured against a real sshd, driving the library directly: a bench of 8 cases went from 2 failures to 0, twice in a row. The same `== PROCESSED` comparison exists in `SshChannelStateMachine.kt:503` and `SftpStateMachine.kt:471` and is not addressed here. --- .../sshlib/protocol/SshClientStateMachine.kt | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/sshlib/src/main/kotlin/org/connectbot/sshlib/protocol/SshClientStateMachine.kt b/sshlib/src/main/kotlin/org/connectbot/sshlib/protocol/SshClientStateMachine.kt index 81e8e95..84dc554 100644 --- a/sshlib/src/main/kotlin/org/connectbot/sshlib/protocol/SshClientStateMachine.kt +++ b/sshlib/src/main/kotlin/org/connectbot/sshlib/protocol/SshClientStateMachine.kt @@ -661,13 +661,13 @@ internal class SshClientStateMachine( return !isDisconnected() } - suspend fun authorizeAuthenticationPacket(): Boolean = process(SshEvent.AuthorizeAuthenticationPacket) + suspend fun authorizeAuthenticationPacket(): Boolean = authorize(SshEvent.AuthorizeAuthenticationPacket) - suspend fun authorizeAuthenticatedPacket(): Boolean = process(SshEvent.AuthorizeAuthenticatedPacket) + suspend fun authorizeAuthenticatedPacket(): Boolean = authorize(SshEvent.AuthorizeAuthenticatedPacket) - suspend fun authorizeConnectionPacket(): Boolean = process(SshEvent.AuthorizeConnectionPacket) + suspend fun authorizeConnectionPacket(): Boolean = authorize(SshEvent.AuthorizeConnectionPacket) - suspend fun authorizeExtInfo(): Boolean = process(SshEvent.AuthorizeExtInfo) + suspend fun authorizeExtInfo(): Boolean = authorize(SshEvent.AuthorizeExtInfo) suspend fun disconnect(): Boolean = process(SshEvent.Disconnect) @@ -690,6 +690,17 @@ internal class SshClientStateMachine( internal fun formalModel(): SshStateMachineFormalModel = stateMachine.toSshFormalModel() private suspend fun process(event: SshEvent): Boolean = stateMachine.processEvent(event) == ProcessingResult.PROCESSED + + /** + * As guardas de autorização não têm efeito próprio: elas só perguntam "este pacote cabe no + * estado atual?". Quando outro evento já está sendo processado (o laço de pacotes entra no meio + * de um `OpenChannel`/`SendChannelRequest` que suspendeu para escrever no socket), o + * `queuePendingEventHandler` — o padrão do KStateMachine — ENFILEIRA o evento e devolve + * `PENDING`. Enfileirado não é recusado; tratar `PENDING` como recusa derruba o transporte + * inteiro com `SSH_MSG_CHANNEL_DATA in the current protocol state`. + */ + private suspend fun authorize(event: SshEvent): Boolean = + stateMachine.processEvent(event) != ProcessingResult.IGNORED } internal interface SshClientCallbacks {