ssh, internal: flush the worker's queued output on every call - #1217
ssh, internal: flush the worker's queued output on every call#1217yosuke-wolfssl wants to merge 3 commits into
Conversation
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
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.
b9d4cc3 to
a650544
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
Findings: 2
2 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Reported findings require changes before merge.
a650544 to
7b1ec65
Compare
7b1ec65 to
d95204d
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
Fenrir result: Approved ✅
No new issues found in the changed files.
Advisory only — this automated result does not count as a GitHub approval.
Fenrir's latest completed scan found no issues; clearing the prior automated change request.
d95204d to
b598ecc
Compare
| ret = sendRet; | ||
| /* else leave ret as prior receive result (SUCCESS/WANT_READ/CHAN_RXD). */ | ||
| } | ||
| else if (ret == WS_FATAL_ERROR && rxErr != WS_WANT_READ) { |
There was a problem hiding this comment.
The deleted WS_CHANNEL_CLOSED block also protected the close status in ssh->error, and that became load-bearing once the other half of this PR made SendPacketFlush() write ssh->error on hard failures too (internal.c:5008).
DoChannelClose() returns WS_CHANNEL_CLOSED even on a short send (internal.c:12119-12129), leaving bytes queued. If the flush then hard-fails, ret is WS_CHANNEL_CLOSED — neither WS_SUCCESS nor WS_FATAL_ERROR — so neither arm fires and WS_SOCKET_ERROR_E stands. Master left the latched close alone here. Since every drive loop dispatches on wolfSSH_get_error(), wolfsshd.c:2032's orderly arm is skipped and :2058 SIGKILLs the child instead.
Minimal fix, keeping the deliberate supersede behavior for WS_CHAN_RXD / WS_EOF / WS_EXTDATA:
else if (ret == WS_CHANNEL_CLOSED
|| (ret == WS_FATAL_ERROR && rxErr != WS_WANT_READ)) {DoChannelCloseFlushesReply doesn't catch this — with s_sendRefusals = 2 its flush succeeds, so the assertion passes because nothing wrote ssh->error, not because anything preserved it. A variant that refuses both DoChannelClose() sends and then hard-fails the flush with ConnResetIoSend would be the control.
There was a problem hiding this comment.
Thanks — It was real and I had the same wrong instinct you did, that the
deleted block was equivalent to master. It isn't, and the SIGKILL
fall-through is the part that makes it matter.
One correction: the suggested condition breaks DoChannelCloseWantWrite.
Applied as written it restores the close over any flush failure including
back-pressure, so ssh->error reads WS_CHANNEL_CLOSED where that test
asserts WS_WANT_WRITE — 148 pass / 1 fail. fe936c8b's own "a short flush
leaves WS_WANT_WRITE latched" is what it collides with. One extra term keeps
both:
else if ((ret == WS_CHANNEL_CLOSED && sendRet != WS_WANT_WRITE)
|| (ret == WS_FATAL_ERROR && rxErr != WS_WANT_READ)) {
test_DoChannelCloseHardFlushKeepsClose is the control you asked for, with a
RefuseThenResetIoSend mock: both DoChannelClose() sends refused, then the
flush hard-fails. It fails without the arm.
I also took some issues addressed by the report.
F5 (ssh.h precondition), F6 (Espressif copy), F8's blank line, and
the comment now states the rule structurally rather than listing three of five
statuses — which is how F1 got past me. Subject is down to 47 characters.
F2 is paced rather than capped. A cap changes termination: the loop exits with
the flush still owed, ret is WS_FATAL_ERROR not WS_WANT_WRITE, and
wolfSSH_SFTP_PendingSend() reports recvState->toSend, not
ssh->outputBuffer — so the loop below doesn't pick it up and a silent peer
stalls. Pacing fixes the CPU without touching termination. Note tcp_select()
passes NULL for writefds, so it can only pace, not wait for writability; the
comment says so.
F3 I've left out. It's Windows-only, windows-sftp.yml drives the SFTP
subsystem rather than the shell path, and a busy-wait that terminates fails no
assertion — so neither CI nor a local MSVC build would catch a mistake there.
Following up with a test that reaches the loop first.
F7: reasoning recorded in the PR description. F4 and the F8 boilerplate
refactor deferred.
b598ecc to
e2839fa
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
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.
e2839fa to
e5524ca
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
Findings: 2
2 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Reported findings require changes before merge.
e5524ca to
d15c978
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
Findings: 2
1 finding(s) posted as inline comments (see file-level comments below)
Required changes (1)
wolfsshd WIN32 forced-command drain loop becomes an unbounded CPU spin
File: apps/wolfsshd/wolfsshd.c:1131
Function: SHELL_Subsystem
Category: Resource leaks
wolfSSH_worker() now leaves ssh->error == WS_WANT_WRITE on an idle receive with queued output (src/ssh.c:3731-3746), so this unpaced loop never terminates while the peer stops reading the non-blocking socket (set at wolfsshd.c:3412) — a remote 100% CPU spin per session.
Recommendation: Wait for socket writability with a bounded select/timeout between passes and give up after a cap instead of looping on WS_WANT_WRITE.
Referenced code: apps/wolfsshd/wolfsshd.c:1131-1135 (5 lines)
This review was generated automatically by Fenrir. Reported findings require changes before merge.
- The echoserver and Espressif shell loops and the Windows wolfsshd shell loop treat a WS_WANT_WRITE from wolfSSH_worker() as non-fatal.
d15c978 to
c547220
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
Findings: 1
Required changes (1)
wolfsshd Windows drain loop becomes a delay-free busy-wait on the worker's new WS_WANT_WRITE
File: apps/wolfsshd/wolfsshd.c:1131
Function: SHELL_Subsystem
Category: Incorrect error handling
wolfSSH_worker() now leaves ssh->error == WS_WANT_WRITE when an idle receive is followed by a blocked flush (previously the removed second DoReceive() left WS_WANT_READ). This drain loop has no select(), sleep, or iteration cap, so a peer that stops reading pins a CPU core on the non-blocking connection socket for as long as it keeps the socket unwritable.
Recommendation: Wait for write readiness with select() on the write set, or bound the loop with a deadline and a pause between retries.
Referenced code: apps/wolfsshd/wolfsshd.c:1131-1136 (6 lines)
This review was generated automatically by Fenrir. Reported findings require changes before merge.
Note for last Fenrir-bot comments and reviewers:Confirmed, and deferred deliberately. The mechanism is as described: that drain has no Both are folded into one follow-up with Not fixing it in this PR because nothing can verify it. |
fe68694 to
35f752f
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
Findings: 2
2 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Reported findings require changes before merge.
35f752f to
cd5c5e8
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
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.
- wolfSSH_TriggerKeyExchange() writes ssh->error only when SendKexInit() fails. It runs from HighwaterCheck() inside wolfSSH_SendPacket(), so writing WS_SUCCESS there erased what the pass the mark fired on had already reported. - test_TriggerKeyExchangeKeepsError() seeds ssh->error and checks a rekey that starts cleanly leaves it alone.
- wolfSSH_worker() calls wolfSSH_SendPacket() whenever ssh->outputBuffer holds bytes and the session is not disconnected, in place of doing so only for WS_SUCCESS, WS_WANT_READ, WS_CHAN_RXD or WS_EOF. ssh->error keeps the receive's code when the receive failed, and the close's when a WS_CHANNEL_CLOSED pass hard-failed its flush; a function-scope sendRet also masks the WS_REKEYING report. Drops the second DoReceive(), its WS_WINDOW_FULL case, the WOLFSSH_TEST_BLOCK fork, and the separate WS_CHANNEL_CLOSED flush. - SendPacketFlush() records its code in ssh->error on every transport failure path; wolfSSH_SendPacket() says so and what a later write to that field owes it. - wolfssh/ssh.h drops WS_WINDOW_FULL from wolfSSH_worker() and states that a status survives in the return while wolfSSH_get_error() holds the flush's code, except that a WS_CHANNEL_CLOSED whose flush failed hard keeps the close; the wolfSSH_ChannelSendEof() and wolfSSH_stream_read() notes match. Nine comments in ssh.c, unit.c, the echoserver, the Espressif copy and portfwd name the channel's own state or the call that failed instead of restating either. - Fourteen unit tests and the extended TestWorkerReportsDisconnect cover the idle-receive flush, the owed flush across calls, and what ret and ssh->error hold after a receive, send, buffer or callback failure, alongside channel data, extended data, a half-close, a rekey or a channel close. The #ifndef WOLFSSH_TEST_BLOCK guards around TestWorkerReadsWhenSendWouldBlock go with the send-first fork.
cd5c5e8 to
f177bae
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #1217
Scan targets checked: wolfssh-bugs, wolfssh-src
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.
Problem
A read-only application on a non-blocking socket stalls permanently.
A channel read credits the window,
ChannelCreditWindow()bundles aCHANNEL_WINDOW_ADJUSTintossh->outputBuffer, and the socket write blocks. The credit is not re-parked — the packet is already encrypted and sequenced — sowolfSSH_SendPacket()is the only thing that can discharge it. The peer has spent its window and goes silent waiting for that adjust.The application calls
wolfSSH_worker(), aswolfssh/ssh.hdirects. The worker gated its flush onDoReceive()'s return, and an idle socket makesDoReceive()returnWS_FATAL_ERROR— not one of the gated values. No write is attempted, on that call or any later one. The gate also listedWS_WANT_READ, whichDoReceive()never returns; that dead arm is the bug.The fix (
src/ssh.c)wolfSSH_worker()flushes whenever output is queued and the session is live:!ssh->disconnectedkeeps bytes from going out after a DISCONNECT (RFC 4253 §11.1), which the old gate excluded only by accident.ssh->errorkeeps the receive's code when the receive itself failed, and the close's code when aWS_CHANNEL_CLOSEDpass hard-failed its flush. Every other status keeps the code the send set.WS_REKEYINGreport is skipped when the flush failed, so a dead transport is not reported as a rekey to drive.DoReceive(), itsWS_WINDOW_FULLarm, theWOLFSSH_TEST_BLOCKordering fork, and the separateWS_CHANNEL_CLOSEDflush — all four only existed to work around the gate.Why the gate ignores
retentirely. An idle receive and a hard one both surface asWS_FATAL_ERROR, so a narrower gate would have to key onssh->error == WS_WANT_READ. Flushing after a MAC or decrypt failure sends only our own already-framed bytes on a session being torn down, and gating on the idle case would reintroduce the class of bug this fixes: a status nobody thought to list stops the flush. That is how the originalWS_WANT_READarm went dead.ssh->errordiscipline (src/internal.c,src/ssh.c)SendPacketFlush()records its code inssh->erroron every transport failure path, not onlyWS_WANT_WRITE. Otherwise a hard send failure during the flush left the idle receive'sWS_WANT_READin place, and callers routing onwolfSSH_get_error()would select for read on a dead socket.That cuts both ways, and both are now stated on
wolfSSH_SendPacket():ssh->erroron the same pass must be conditional on the flush having succeeded. Four writers satisfy it —wolfSSH_shutdown()'sflushRet == WS_SUCCESS, the rekey mask'ssendRet == WS_SUCCESS, the worker'srxErrprecedence, andSendChannelData()'s writes nested underret == WS_SUCCESS.ssh->error.wolfSSH_TriggerKeyExchange()did, and it runs fromHighwaterCheck()insidewolfSSH_SendPacket(), so a successful flush could zero the field for any of its callers. Fixed at the source rather than in the one caller that noticed._ChannelRead()and_ChannelReadExt()still record the adjust's code themselves:ChannelCreditWindow()can fail withWS_BAD_ARGUMENTorWS_OVERFLOW_Ebefore reaching the transport, whereSendPacketFlush()never runs.wolfSSH_worker(),wolfSSH_ChannelSendEof()andwolfSSH_stream_read()doc blocks inssh.hstate the rule;WS_WINDOW_FULLcomes off the worker's return list, since no path reaches it. No public API change.Consumers
Three shell loops treat anything but
WS_WANT_READas fatal, one line each:examples/echoserver/echoserver.capps/wolfsshd/wolfsshd.c(WIN32)ide/Espressif/.../echoserver.cThis is a pre-existing teardown, not a regression introduced here. Master's
wolfSSH_worker()already returnsWS_WANT_WRITE— the secondDoReceive()path ends inret = sendRet— and master'sSendPacketFlush()already records it inssh->error. So on master these loops already drop a live session whenever the send buffer fills while the peer is still sending, which is an ordinary bulk upload. This change makes that easier to reach, by flushing on passes the old gate skipped, so tolerating the status belongs with it. Everything beyond that is caller-loop work and is deferred; see below.Tests
Fourteen new unit tests cover the flush on an idle receive, the owed flush across calls, and what
retandssh->errorhold after a receive failure, a hard send failure, a discarded buffer, an out-of-bounds send, a missing send callback and a bad buffer state — each alongside channel data, extended data, a half-close, a rekey, or a channel close.test_TriggerKeyExchangeKeepsError()covers the rekey trigger, andTestWorkerReportsDisconnectcovers queued output staying unsent on the disconnect pass.The
#ifndef WOLFSSH_TEST_BLOCKguards aroundTestWorkerReadsWhenSendWouldBlockare gone — the send-first fork they worked around no longer exists, so it runs in every configuration.One arm is knowingly uncovered: a
DoReceive()that returns a plainWS_SUCCESSwith output queued and a failing flush, whereret = sendRet. Reaching it needs a builder for a non-channel packet that nothing else in the suite uses.Verification
unit.test157 passed / 0 failed;regress.testpassed.-Werroracross 6 configurations, plus lint.ssh->errorwrite is backed by a negative control: reverting it makes a named test fail, and no other.-DWOLFSSH_TEST_BLOCK,scripts/sftp.test), the harness whose ordering fork this removes:WOLFSSH_BLOCK_PROBscp.testandget-put.testexit 77(skip) underWOLFSSH_TEST_BLOCKby their own design, sosftp.testis the only script test that reaches this path.Known limitations, not addressed here
Five caller loops mishandle an owed flush. None is made worse by this PR; all predate it. They are deferred together because none has a test that can verify a fix —
scripts/sftp.testdrivessftp_worker(), and nothing drives a shell session under-DWOLFSSH_TEST_BLOCK.examples/echoserver/echoserver.c:806ssh_worker()select()at:999watches read fds with aNULLtimeoutide/Espressif/.../echoserver.c:794select()at:984, identicalapps/wolfsshd/wolfsshd.c:1364WIN32shell loopselect()at:1300whenwolfSSH_stream_peek()has dataexamples/echoserver/echoserver.c:1496sftp_worker()handshake retryapps/wolfsshd/wolfsshd.c:1131WIN32window-change drainapps/wolfsshd/wolfsshd.c:1942already has the shape wanted, in the POSIX loop: one select per iteration watching read and write together, driven by awantWriteflag.An earlier revision fixed the last two with a
tcp_select_write()helper. That was pulled out: fixing two of five left the class half-done and grew the diff without closing the stall, and the helper belongs with the loops it was built for. The follow-up writes the shell-session harness first, then fixes all five.