Skip to content

examples/echoserver: hold what a channel send did not take - #1261

Merged
ejohnstown merged 1 commit into
wolfSSL:masterfrom
yosuke-wolfssl:fix/f_10544_rework
Sep 25, 2026
Merged

ejohnstown merged 1 commit into
wolfSSL:masterfrom
yosuke-wolfssl:fix/f_10544_rework

Conversation

@yosuke-wolfssl

@yosuke-wolfssl yosuke-wolfssl commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Problem

ssh_worker() treated every positive write(), send() and wolfSSH_ChannelIdSend()
as a complete transfer. SendChannelData() clamps each send to
min(peerWindowSz, peerMaxPacketSz), so short sends are routine and seven sites
discarded the unsent suffix — the forwarding path reset its buffer index on any
positive return. Not confined to small windows: a 4 MB echo at the default window
loses 98 KiB. Closes f-10544 (High).

Fix (examples/echoserver/echoserver.c)

Each WS_AppCtx stages both directions and keeps what its sink would not take:
buffer/bufferIdx/bufferOff toward the channel, fdBuffer/fdIdx/fdOff toward
the descriptor.

  • app_drain_to_channel() advances bufferOff by what the send took and holds the
    rest on WS_WANT_WRITE, WS_WINDOW_FULL, WS_REKEYING, WS_CHANNEL_NOT_CONF and
    WS_CHAN_RXD. For the latter four it restores the entry ssh->error: a leftover
    WS_WINDOW_FULL fails DoKexInit() on the peer's next KEXINIT.
  • app_pump_to_fd() moves channel data to the pty or forward socket, both now
    nonblocking, and keeps what would block. The descriptor sits in the select() write
    set while it owes bytes, so no write can stall the loop.
  • app_echo_pump() echoes the shell channel, then runs process_bytes(). It
    replaces the EOF drain.
  • app_flush_fd() gives a forward up to 5 s without progress after the session ends
    to take what it still owes.
  • The agent socket stays blocking; app_write_all() retries interrupted sends.

A descriptor leaves the read set while its channel-bound buffer is non-empty, both
pumps run every pass, and a forward clears both buffers as it connects.

Tests (scripts/fwd-bulk.test)

Both phases need python3 and ssh, and skip without them. OpenSSH logs in with a
password, so no key algorithm has to be compiled in.

  • Phase 3 echoes 32 MB through ssh -L while the client keeps sending but stops
    reading for 10 s. The first revision of this PR deadlocks there at ~4.2 MB, blocked in
    send() to the forward; so does this one with the forward left blocking.
  • Phase 4 ends a forward while the echoserver still holds bytes for its target, then
    checks the next forward's target gets only its own. Without the reset on connect, the
    second target receives 69–4113 stale bytes (3/3 runs).

Verification

  • make check 13/13; GCC -Werror clean on 6 configurations; CI green.
  • Phases 3–4 plus a local stress matrix (two-way, rekey mid-stream, SSH peer stalling;
    blocking and -N sockets): 18/18 on macOS, 18/18 on Linux (ubuntu:24.04).

Not in this PR

  • Bytes still owed to a forward's target are dropped when the peer closes the
    channel
    , or the target half-closes, before the target has read them. Pre-existing:
    a client that sends 600 KB and closes at once loses ~120 KB to a stalled target on
    master too, and ~5 KB more here (fdBuffer). OpenSSH and portfwd don't close that
    way, and lost nothing in testing. Keeping them needs the library to hold a closed
    channel's unread data.
  • tests/api.c regression tests. Withdrawn: they force a rekey mid-transfer in
    small-highwater builds, hitting a separate defect that reproduces on master — the
    echo path treats a rekey-blocked send as fatal. They land with that fix.
  • dump_stats() has the same flaw. A plain retry is worse than the truncation: the
    second send finds the window exhausted and the caller ends the session. Staging the
    blob across passes is out of proportion for debug output behind a control byte.
  • The ESP-IDF copy, deferred: its ssh_worker() has diverged by ~580 lines and has
    no select() write set, which this fix depends on.

@yosuke-wolfssl yosuke-wolfssl self-assigned this Sep 18, 2026
Copilot AI lite review requested due to automatic review settings September 18, 2026 00:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Critical EOF flushing and forwarding teardown issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Reworks echo-server buffering to preserve unsent data during short channel and descriptor writes, with regression tests for flow control and half-close behavior.

Changes:

  • Adds persistent staging and reliable drain helpers.
  • Updates echo, agent, and forwarding paths.
  • Adds 8,000-byte round-trip regression tests.
File summaries
File Summary
tests/api.c Adds flow-control, refill, and half-close tests.
examples/echoserver/echoserver.c Preserves partial transfers across worker passes.
Review details

Suppressed comments (1)

examples/echoserver/echoserver.c:1760

  • When the local forwarding socket reaches EOF, this path changes fwdCtx to APP_STATE_LISTEN and continues without resetting bufferIdx/bufferOff. Any staged tail left by an earlier channel send therefore survives into the next accepted connection and is sent on its channel. Reset the staging fields on this EOF/reset teardown as well (and do the same for the analogous agent-socket close paths).
                    if (cnt_r == 0) {
                        /* Read zero-returned. Socket is closed. Go back
                           to listening. */
                        WCLOSESOCKET(fwdFd);
                        fwdFd = -1;
                        threadCtx->fwdCtx.appFd = -1;
                        if (threadCtx->fwdCbCtx.hostName != NULL) {
                            WFREE(threadCtx->fwdCbCtx.hostName, NULL, 0);
                            threadCtx->fwdCbCtx.hostName = NULL;
                        }
                        threadCtx->fwdCtx.state = APP_STATE_LISTEN;
                        continue;
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread examples/echoserver/echoserver.c
Comment thread examples/echoserver/echoserver.c Outdated

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fenrir Automated Review — PR #1261

Scan targets checked: wolfssh-src, wolfssh-bugs

Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)

This review was generated automatically by Fenrir. Reported findings require changes before merge.

Comment thread tests/api.c Outdated
@yosuke-wolfssl yosuke-wolfssl changed the title Fix/f 10544 rework examples/echoserver: hold what a channel send did not take Sep 18, 2026
@wolfSSL-Fenrir-bot
wolfSSL-Fenrir-bot dismissed their stale review September 18, 2026 01:58

Fenrir's latest completed scan found no issues; clearing the prior automated change request.

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fenrir Automated Review — PR #1261

Scan targets checked: wolfssh-src, wolfssh-bugs

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

@yosuke-wolfssl
yosuke-wolfssl force-pushed the fix/f_10544_rework branch 3 times, most recently from ea034be to 44d9b8b Compare September 18, 2026 06:26

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fenrir Automated Review — PR #1261

Scan targets checked: wolfssh-src, wolfssh-bugs

Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)

This review was generated automatically by Fenrir. Reported findings require changes before merge.

Comment thread examples/echoserver/echoserver.c Outdated

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fenrir Automated Review — PR #1261

Scan targets checked: wolfssh-src, wolfssh-bugs

Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)

This review was generated automatically by Fenrir. Reported findings require changes before merge.

Comment thread examples/echoserver/echoserver.c Outdated
Comment thread examples/echoserver/echoserver.c Outdated
ejohnstown
ejohnstown previously approved these changes Sep 22, 2026

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fenrir Automated Review — PR #1261

Scan targets checked: wolfssh-src, wolfssh-bugs

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

@wolfSSL-Fenrir-bot
wolfSSL-Fenrir-bot dismissed stale reviews from themself September 22, 2026 20:39

Fenrir's latest completed scan found no issues; clearing the prior automated change request.

@philljj
philljj self-requested a review September 23, 2026 22:14

@ejohnstown ejohnstown left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two requests before this merges. Both are small.

1. Echo before process_bytes(), as master does

app_echo_pump() now runs process_bytes() on a chunk before app_drain_to_channel() sends it. On master the echo went out first. Sending abc\x05def\n through OpenSSH shows the change:

  • master: abc^Edef\n, then the statistics block.
  • this branch: the statistics block, then abc^Edef\n.

Ctrl-F changes the same way. wolfSSH_TriggerKeyExchange() now fires before the drain, so the drain gets WS_REKEYING and the chunk's echo waits until the rekey finishes. Before, it went out ahead of the rekey. Nothing is lost, but the output changes and the PR doesn't mention it. Please drain first and process the chunk afterwards. app_drain_to_channel() doesn't modify buffer, so the bytes are still there after the send:

    for (;;) {
        word32 freshSz = 0;

        if (app_staged(appCtx) == 0) {
            cnt = wolfSSH_ChannelIdRead(ssh, appCtx->channelId,
                    appCtx->buffer, (word32)sizeof appCtx->buffer);
            /* Only a zero read is dry; a negative one just stops the pump. */
            if (cnt <= 0) {
                *dry = (cnt == 0);
                break;
            }
            #ifdef SHELL_DEBUG
                buf_dump(appCtx->buffer, cnt);
            #endif
            appCtx->bufferIdx = (word32)cnt;
            appCtx->bufferOff = 0;
            freshSz = (word32)cnt;
        }

        ret = app_drain_to_channel(ssh, appCtx, appCtx->channelId, wantWrite);
        if (ret < 0) {
            return ret;
        }
        /* After the drain, so the echo precedes what the chunk asks for */
        if (freshSz > 0 && process_bytes(threadCtx, appCtx->buffer, freshSz)) {
            ChildRunning = 0;
            break;
        }
        if (app_staged(appCtx) > 0) {
            break;
        }
    }

The break on a stop matches master, which echoed only the chunk that held the Ctrl-C and read nothing after it.

2. Explain why app_drain_to_channel() restores ssh->error

Please keep this restore. A review suggested removing it, since for three of the four codes it does nothing. I removed it and tested. With OpenSSH set to -o RekeyLimit=256K and a slow reader, every run ended at 16384 of 4 MiB, with and without -N. With the restore, every run was byte-identical.

Here's why. SendChannelData() stores WS_WINDOW_FULL in ssh->error. DoKexInit() returns any non-zero ssh->error (src/internal.c, the "Propagate potential want write case from SendKexInit" check). DoPacket() doesn't tolerate WS_WINDOW_FULL, so the peer's KEXINIT fails and the session ends. The current comment doesn't say this, so the next reader is likely to delete it. Please change it to something like:

            /* Owed, not failed. Put back the entry code: SendChannelData()
             * leaves WS_WINDOW_FULL in ssh->error, and DoKexInit() returns
             * any nonzero ssh->error, so a stale one fails the peer's next
             * KEXINIT and ends the session. */
            ssh->error = savedError;

The fix for the underlying problem belongs in the library and will be a separate PR. Once that lands, this restore can go.

@yosuke-wolfssl
yosuke-wolfssl force-pushed the fix/f_10544_rework branch 3 times, most recently from 69f84a6 to c80ce5d Compare September 25, 2026 05:06
- WS_AppCtx stages each direction on its own: buffer, bufferIdx and
  bufferOff from appFd to the channel; fdBuffer, fdIdx and fdOff from
  the channel to appFd. thread_ctx_t drops channelBuffer and
  eofBuffer.
- app_drain_to_channel() advances bufferOff by what
  wolfSSH_ChannelIdSend() took, and holds the rest on WS_WANT_WRITE,
  WS_WINDOW_FULL, WS_REKEYING, WS_CHANNEL_NOT_CONF and WS_CHAN_RXD,
  putting back the ssh->error from before that send for all but
  WS_WANT_WRITE. A zero or over-reported send ends the session.
- app_pump_to_fd() reads the channel into fdBuffer and writes it to
  the pty or forward socket, both now nonblocking, keeping what would
  block.
- ssh_worker() leaves appFd out of the read set while buffer holds
  bytes, puts it in the write set while fdBuffer does, and runs both
  pumps on every pass.
- A pty read of zero ends the loop whatever errno holds.
- app_echo_pump() echoes the shell channel through shellCtx.buffer
  and then hands each new chunk to process_bytes(). It replaces the
  EOF drain along with eofOff and eofRead. A negative read ends the
  session.
- app_flush_fd() gives a connected forward up to APP_FLUSH_SECS
  without progress after the loop to take what fdBuffer still holds.
- The agent keeps blocking writes, through app_write_all(), which
  retries an interrupted send.
- A forward clears both of its buffers as it connects.
- fwd-bulk.test phase 3 logs in with a password and echoes 32 MB
  through ssh -L while the client keeps sending but stops reading for
  10 s. Phase 4 ends a forward while bytes are held for its target
  and checks the next forward's target gets only its own.

Issue: F-10544
@yosuke-wolfssl

Copy link
Copy Markdown
Contributor Author

Thank you @ejohnstown for reviewing.
Both applied in 68330b9.

  1. Echo first. app_echo_pump() now drains the chunk and then hands it to process_bytes(), breaking on a stop as master does. It keeps the negative-read split from your earlier comment.
  2. ssh->error restore. Kept, and the comment now says what it prevents: a WS_WINDOW_FULL left behind fails DoKexInit() on the peer's next KEXINIT. The saved value is now taken right before each send rather than on entry, so the restore can't overwrite a status set by an earlier send in the same call.

scripts/fwd-bulk.test also has two new phases. Both use OpenSSH and python3, and skip without them:

  • Phase 3 is your deadlock reproduction: 32 MB echoed through ssh -L while the client keeps sending but stops reading for 10 s. The previous revision stalls there at about 4 MB. OpenSSH logs in with a password, so the phase also runs in the ed448-only and no-pairing jobs.
  • Phase 4 ends a forward while the echoserver still holds bytes for its target, then checks that the next forward's target gets only its own bytes. It covers a reset added in this revision: without it, up to 4 KB from the old connection leaked into the new one.

Rekey with a slow reader (RekeyLimit=256K, with and without -N) is byte-identical 3/3 on macOS and Linux.

@LinuxJedi

Copy link
Copy Markdown
Member

@wolfSSL-Fenrir-bot review

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fenrir Automated Review — PR #1261

Scan targets checked: wolfssh-src, wolfssh-bugs
Coverage: 1 of 1 in-scope changed file(s) opened by the reviewer

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

Review tier: Lite

@ejohnstown
ejohnstown merged commit 6ed8e12 into wolfSSL:master Sep 25, 2026
200 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants