Sitelet https://github.com/libssh2/libssh2/pull/1945
Skip to content

transport: set OUTBOUND on send_existing EAGAIN exits - #1945

Open
alexanderadam wants to merge 5 commits into
libssh2:masterfrom
alexanderadam:fix/send_existing_bd_eagain
Open

alexanderadam wants to merge 5 commits into
libssh2:masterfrom
alexanderadam:fix/send_existing_bd_eagain

Conversation

@alexanderadam

@alexanderadam alexanderadam commented May 16, 2026 •

Copy link
Copy Markdown

I also fell into this and initially I assumed other issues in my stack. 😆

send_existing() in src/transport.c has three EAGAIN exits. Two of them correctly OR LIBSSH2_SESSION_BLOCK_OUTBOUND into session->socket_block_directions before returning.
However, the "Address is different" branch does not. When this fires, the caller gets EAGAIN with bd == 0.

The branch is fired whenever something else on the same session calls _libssh2_transport_send with a different data pointer while p->olen != 0 from an unfinished write.
The obvious cases are interleaved channel writes, but it also fires from inside libssh2 itself when _libssh2_kex_exchange builds its own packet during a write, which is exactly what #1672 describes from a different angle.

There is also a 'twin defect' at the partial-send return at the end of the same function: if LIBSSH2_SEND accepted some bytes but not all. More are still pending, but bd doesn't get updated.
This is usually already set from a prior iteration, but if the prior transport call was a clean read, bd is 0 and you get the same hang.

Fixes #1672

Related #1397, #1431, #1454 the regression chain that introduced the EAGAIN return on this branch in the first place. The fix there was correct for the data-corruption symptom but left bd unset, which is what #1672 reports and what this PR addresses.

PS: Thank you so much for your work! 🙏 So many people and projects are depending on libssh2 and it's a typical standing-on-the-shoulders-of-giants project.
PPS: huh? How is the CI green now? I'm pretty sure that I got an email that it failed!? 🤔
PPPS: I'm looking for a new adventure in case anybody is looking to hire someone or in case someone would like to work with a (Ruby/Rails/Crystal) dev and I'm also open to move to a PO/PM role.

@alexanderadam
alexanderadam force-pushed the fix/send_existing_bd_eagain branch 5 times, most recently from 4730ab0 to 4f07bda Compare May 16, 2026 22:24
@alexanderadam alexanderadam changed the title transport: set OUTBOUND on send_existing EAGAIN exits transport: set OUTBOUND on send_existing EAGAIN exits May 17, 2026
@willco007

Copy link
Copy Markdown
Member

Sorry for the delay, could you pull up and resolve the conflicts and we'll get this integrated.

Other EAGAIN exits from send_existing/_libssh2_transport_send already
OR in OUTBOUND; this branch was missed. Without it, callers polling
libssh2_session_block_directions() get back 0 and have nothing to
wait on.

Fixes libssh2#1672
if LIBSSH2_SEND took some bytes but not all, more are still pending on
the write side. flag OUTBOUND, otherwise bd ends up 0 when the prior
transport call was a read.
static test, no network. plants a pending packet in session->packet
and calls _libssh2_transport_send with a different data pointer.
only the address-mismatch branch for now, partial-send needs a stub
send callback.
@alexanderadam
alexanderadam force-pushed the fix/send_existing_bd_eagain branch from 4f07bda to 7ba5174 Compare July 1, 2026 12:22
… exits

- "bd" was opaque, rename it.
- add the LIBSSH2_SEND -EAGAIN and partial-send cases via a stub
  callback (libssh2_session_callback_set2), save/restore so test
  order doesn't matter.
- bounds-assert in arm_pending_packet.
- gitignore the binary.
@alexanderadam
alexanderadam force-pushed the fix/send_existing_bd_eagain branch from 7ba5174 to 093d2d8 Compare July 1, 2026 12:40
_libssh2_channel_read with buflen=0 had a bug: the packet-drain loop
below is gated on `bytes_read < buflen` and never enters when both
are zero, so transport_read's BLOCK_INBOUND flag (set on its internal
EAGAIN exit) survives into the terminal !bytes_read branch, which
then returns LIBSSH2_ERROR_EAGAIN. The caller is told to wait on
inbound data they never asked for, and spins.

Reproduced with an HTTP client over a forwarded channel where a
bounded-body reader asked for 0 bytes once the response body length
was fully accounted for. Thousands of channel_read calls with
`wants 0 bytes` before the caller's nonblock-iter ceiling fired.

Fix: early return 0 for buflen=0. Same noop the caller asked for,
no transport involvement.
@alexanderadam
alexanderadam force-pushed the fix/send_existing_bd_eagain branch from 093d2d8 to 6bc7006 Compare July 1, 2026 12:46
@alexanderadam

Copy link
Copy Markdown
Author

@willco007 done 😊

Comment thread src/channel.c
Comment on lines +1953 to +1955
if(channel->remote.eof || channel->remote.close)
return 0;
return 0;

@vszakats vszakats Jul 19, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if(channel->remote.eof || channel->remote.close)
return 0;
return 0;
return 0;

was there something meant to be done under the nested if? if not, let's delete it.

Comment on lines +1 to +8
/* Copyright (C) The libssh2 project and its contributors.
*
* regression test for #1672: every EAGAIN exit in send_existing() must
* leave block_directions non-zero so the caller knows what to wait on.
*
* SPDX-License-Identifier: BSD-3-Clause
*/

@vszakats vszakats Jul 19, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/* Copyright (C) The libssh2 project and its contributors.
*
* regression test for #1672: every EAGAIN exit in send_existing() must
* leave block_directions non-zero so the caller knows what to wait on.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
/* Copyright (C) The libssh2 project and its contributors.
*
* SPDX-License-Identifier: BSD-3-Clause
*/
/* Regression test for #1672: every EAGAIN exit in send_existing() must
leave block_directions non-zero so the caller knows what to wait on. */

Let's keep the copyright header and description separate.

Also could we drop the reference to #1672 and make this comment
self-explanatory, yet short?

Comment on lines +191 to +195
if(test_addr_mismatch(session) != 0)
failures++;
if(test_send_eagain(session) != 0)
failures++;
if(test_partial_send(session) != 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if(test_addr_mismatch(session) != 0)
failures++;
if(test_send_eagain(session) != 0)
failures++;
if(test_partial_send(session) != 0)
if(test_addr_mismatch(session))
failures++;
if(test_send_eagain(session))
failures++;
if(test_partial_send(session))

let's omit != 0 to match rest of codebase.

Comment on lines +12 to +16
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

@vszakats vszakats Jul 19, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>

drop headers already included via libssh2_priv.h and libssh2.h.
also drop stdlib.h unless it's required, in which case I suggest
adding a comment saying /* for funcname() */.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The write stucks at the transport layer.

3 participants