Keep the channel until the peer's close

wolfSSH_ChannelExit() leaves the channel on the list once it has sent the EOF
and the close, so the application's pointer stays valid until the peer answers
and wolfSSH_worker() reports WS_CHANNEL_CLOSED. Removing it locally freed the
pointer under the caller and left the peer's close matching nothing.

- DoChannelClose() sends the EOF ahead of the close, per RFC 4254 section 5.3,
  and sends both whatever the flush reports: DoPacket() consumes the peer's
  close either way, so a message skipped over a blocked flush is never sent.
- It retires the channel and names it on a short write too. The debt belongs to
  the output buffer, not the channel, and withholding the close signal would
  leave the caller timing the teardown out.
- The worker flushes that reply, keeps WS_CHANNEL_CLOSED as the return value,
  and leaves WS_WANT_WRITE latched so the caller knows to drain
  wolfSSH_OutputPending() before closing the socket. ssh.h says so.

Issue: F-8839
pull/1218/head
John Safranek 2026-08-27 13:08:01 -07:00 committed by philljj
parent 5911c51ef3
commit fe936c8b8c
4 changed files with 500 additions and 10 deletions

View File

@ -11382,17 +11382,30 @@ static int DoChannelClose(WOLFSSH* ssh,
if (ret == WS_SUCCESS) {
if (!channel->closeTxd) {
/* EOF ahead of the close, RFC 4254 section 5.3. Both go
* unconditionally: DoPacket() consumes the peer's close whatever
* this returns, so nothing runs it again. */
int eofRet = SendChannelEof(ssh, channel->peerChannel);
ret = SendChannelClose(ssh, channel->peerChannel);
if (ret == WS_SUCCESS)
ret = eofRet;
}
}
if (ret == WS_SUCCESS) {
ret = ChannelRemove(ssh, channelId, WS_CHANNEL_ID_SELF);
}
/* Retire it once the close is bundled: a blocked flush belongs to the
* output buffer, not the channel. */
if (ret == WS_SUCCESS || ret == WS_WANT_WRITE) {
int removeRet = ChannelRemove(ssh, channelId, WS_CHANNEL_ID_SELF);
if (ret == WS_SUCCESS) {
ret = WS_CHANNEL_CLOSED;
ssh->lastRxId = channelId;
if (removeRet != WS_SUCCESS)
ret = removeRet;
else {
/* Report the close even on a short flush; the caller needs the
* graceful-close signal. */
ret = WS_CHANNEL_CLOSED;
ssh->lastRxId = channelId;
}
}
WLOG(WS_LOG_DEBUG, "Leaving DoChannelClose(), ret = %d", ret);

View File

@ -3732,6 +3732,16 @@ int wolfSSH_worker(WOLFSSH* ssh, word32* channelId)
}
#endif /* WOLFSSH_TEST_BLOCK */
/* DoChannelClose() bundles the reply inside DoReceive(), and callers
* treat the close as terminal, so flush it here. The close stays the
* return value; a short flush leaves WS_WANT_WRITE latched. */
if (ret == WS_CHANNEL_CLOSED && ssh->outputBuffer.length != 0) {
int closeErr = ssh->error;
if (wolfSSH_SendPacket(ssh) == WS_SUCCESS)
ssh->error = closeErr;
}
/* WS_EXTDATA and WS_EOF report the channel too, so a multi-channel caller
* can route the drain, or see which channel half-closed. */
if (ret == WS_SUCCESS || ret == WS_CHAN_RXD || ret == WS_EXTDATA
@ -4408,10 +4418,6 @@ int wolfSSH_ChannelExit(WOLFSSH_CHANNEL* channel)
if (ret == WS_SUCCESS)
ret = SendChannelClose(channel->ssh, channel->peerChannel);
if (ret == WS_SUCCESS)
ret = ChannelRemove(channel->ssh,
channel->peerChannel, WS_CHANNEL_ID_PEER);
WLOG(WS_LOG_DEBUG, "Leaving wolfSSH_ChannelExit(), ret = %d", ret);
return ret;
}

View File

@ -4818,6 +4818,33 @@ static WS_MAYBE_UNUSED int FailIoSend(WOLFSSH* ssh, void* buf, word32 sz, void*
(void)ssh; (void)buf; (void)sz; (void)ctx;
return WS_CBIO_ERR_GENERAL;
}
/* Walks a run of plaintext SSH packets and reports whether any of them carries
* msgId. A bare session negotiates no cipher, so queued and sent packets are
* both in the clear. */
static WS_MAYBE_UNUSED int PlainPacketsHaveMsg(const byte* buf, word32 begin, word32 end,
byte msgId)
{
word32 i;
for (i = begin; i + LENGTH_SZ + PAD_LENGTH_SZ + MSG_ID_SZ <= end; ) {
word32 packetSz;
packetSz = ((word32)buf[i] << 24)
| ((word32)buf[i + 1] << 16)
| ((word32)buf[i + 2] << 8)
| (word32)buf[i + 3];
if (packetSz == 0 || i + LENGTH_SZ + packetSz > end)
break;
if (buf[i + LENGTH_SZ + 1] == msgId)
return 1;
i += LENGTH_SZ + packetSz;
}
return 0;
}
#ifndef NO_WOLFSSH_SERVER
/* An unknown extended data type must be ignored (consumed and discarded) per
@ -6602,6 +6629,74 @@ done:
wolfSSH_CTX_free(ctx);
return result;
}
/* wolfSSH_ChannelExit() sends EOF and close, then leaves the channel on the
* list for the peer's own close to retire. Removing it there would free the
* caller's channel pointer and leave the peer's CHANNEL_CLOSE matching nothing,
* which DoReceive() turns into a fatal error on an ordinary shutdown. */
static int test_ChannelExitKeepsChannel(void)
{
WOLFSSH_CTX* ctx = NULL;
WOLFSSH* ssh = NULL;
WOLFSSH_CHANNEL* ch = NULL;
int result = 0;
int ret;
word32 pktSz;
byte pkt[16];
ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL);
if (ctx == NULL)
return -1530;
wolfSSH_SetIOSend(ctx, DiscardIoSend);
wolfSSH_SetIORecv(ctx, PacketIoRecv);
ssh = wolfSSH_new(ctx);
if (ssh == NULL) { result = -1531; goto done; }
ssh->acceptState = ACCEPT_SERVER_USERAUTH_SENT;
ch = ChannelNew(ssh, ID_CHANTYPE_SESSION, 1024, 1024);
if (ch == NULL) { result = -1532; goto done; }
if (ChannelAppend(ssh, ch) != WS_SUCCESS) {
ChannelDelete(ch, ssh->ctx->heap);
result = -1533;
goto done;
}
ch->openConfirmed = 1;
ch->peerWindowSz = 1024;
ch->peerMaxPacketSz = 1024;
ret = wolfSSH_ChannelExit(ch);
if (ret != WS_SUCCESS) { result = -1534; goto done; }
if (!ch->eofTxd) { result = -1535; goto done; }
if (!ch->closeTxd) { result = -1536; goto done; }
/* Still on the list, so the pointer the caller passed is still good. */
if (ssh->channelList != ch) { result = -1537; goto done; }
if (ssh->channelListSz != 1) { result = -1538; goto done; }
/* The peer answers with its own close. */
pktSz = BuildChannelClosePacket(pkt, ch->channel);
s_recvPkt = pkt;
s_recvPktSz = pktSz;
s_recvPktOff = 0;
ret = wolfSSH_worker(ssh, NULL);
if (ret != WS_CHANNEL_CLOSED) { result = -1539; goto done; }
/* Retired now, and by the peer's close rather than by the exit. */
if (ssh->channelList != NULL) { result = -1540; goto done; }
done:
s_recvPkt = NULL;
s_recvPktSz = 0;
s_recvPktOff = 0;
wolfSSH_free(ssh);
wolfSSH_CTX_free(ctx);
return result;
}
/* An EOF arriving on a channel that is not the head of the list is not the
* head's EOF. DoChannelEof() reports WS_EOF for whichever channel it lands on,
* so wolfSSH_stream_read() has to tell the two apart: the head is still open
@ -6673,6 +6768,195 @@ done:
return result;
}
static byte s_sentBuf[512];
static word32 s_sentSz = 0;
static int s_sendRefusals = 0;
/* Refuses the first s_sendRefusals writes with a would-block, then takes
* everything and keeps a copy of what reached the transport. */
static int RefuseThenCaptureIoSend(WOLFSSH* ssh, void* buf, word32 sz,
void* ctx)
{
WOLFSSH_UNUSED(ssh);
WOLFSSH_UNUSED(ctx);
if (s_sendRefusals > 0) {
s_sendRefusals--;
return WS_CBIO_ERR_WANT_WRITE;
}
if (s_sentSz + sz > (word32)sizeof(s_sentBuf))
return WS_CBIO_ERR_GENERAL;
WMEMCPY(s_sentBuf + s_sentSz, buf, sz);
s_sentSz += sz;
return (int)sz;
}
/* DoPacket() consumes the peer's CHANNEL_CLOSE whatever DoChannelClose()
* returns, so the reply gets one chance to be built. A blocked socket must not
* cost it: the EOF and the close both have to be bundled, and the channel
* retired, with the flush left owed to the caller. */
static int test_DoChannelCloseWantWrite(void)
{
WOLFSSH_CTX* ctx = NULL;
WOLFSSH* ssh = NULL;
WOLFSSH_CHANNEL* ch = NULL;
int result = 0;
int ret;
int sawEof = 0;
int sawClose = 0;
word32 chanId;
byte pkt[16];
ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL);
if (ctx == NULL)
return -1570;
/* The socket takes nothing, so everything bundled stays in the buffer. */
wolfSSH_SetIOSend(ctx, WantWriteIoSend);
wolfSSH_SetIORecv(ctx, PacketIoRecv);
ssh = wolfSSH_new(ctx);
if (ssh == NULL) { result = -1571; goto done; }
ssh->acceptState = ACCEPT_SERVER_USERAUTH_SENT;
ch = ChannelNew(ssh, ID_CHANTYPE_SESSION, 1024, 1024);
if (ch == NULL) { result = -1572; goto done; }
if (ChannelAppend(ssh, ch) != WS_SUCCESS) {
ChannelDelete(ch, ssh->ctx->heap);
result = -1573;
goto done;
}
ch->openConfirmed = 1;
ch->peerWindowSz = 1024;
ch->peerMaxPacketSz = 1024;
chanId = ch->channel;
/* The peer closes first. */
s_recvPkt = pkt;
s_recvPktSz = BuildChannelClosePacket(pkt, ch->channel);
s_recvPktOff = 0;
ret = wolfSSH_worker(ssh, NULL);
/* The channel is closed and named even though the flush is owed: a
* back-pressured socket is not a reason to withhold the close signal. */
if (ret != WS_CHANNEL_CLOSED) { result = -1574; goto done; }
if (ssh->lastRxId != chanId) { result = -1575; goto done; }
/* The channel is retired, so ch is freed and must not be read again. */
if (ssh->channelList != NULL) { result = -1576; goto done; }
if (ssh->channelListSz != 0) { result = -1579; goto done; }
/* Both packets are committed, and nothing will revisit the peer's close
* to build them later. Walk the queue and confirm both are there. */
sawEof = PlainPacketsHaveMsg(ssh->outputBuffer.buffer,
ssh->outputBuffer.idx, ssh->outputBuffer.length,
MSGID_CHANNEL_EOF);
sawClose = PlainPacketsHaveMsg(ssh->outputBuffer.buffer,
ssh->outputBuffer.idx, ssh->outputBuffer.length,
MSGID_CHANNEL_CLOSE);
if (!sawEof) { result = -1577; goto done; }
if (!sawClose) { result = -1578; goto done; }
/* And the owed flush is visible: the close is the return value, so
* WS_WANT_WRITE has to reach the caller some other way or the reply sits
* in the buffer while the caller closes the socket. */
if (wolfSSH_get_error(ssh) != WS_WANT_WRITE) { result = -1764; goto done; }
if (!wolfSSH_OutputPending(ssh)) { result = -1765; goto done; }
/* And they really do go out once the socket takes bytes again. */
wolfSSH_SetIOSend(ctx, DiscardIoSend);
ret = wolfSSH_SendPacket(ssh);
if (ret != WS_SUCCESS) { result = -1580; goto done; }
if (ssh->outputBuffer.length != 0) { result = -1581; goto done; }
done:
s_recvPkt = NULL;
s_recvPktSz = 0;
s_recvPktOff = 0;
wolfSSH_free(ssh);
wolfSSH_CTX_free(ctx);
return result;
}
/* The reply to the peer's close still has to reach the wire when the socket
* was full while it was bundled. DoChannelClose() reports WS_CHANNEL_CLOSED
* whatever the flush does and retires the channel, and every caller treats
* that as terminal, so wolfSSH_worker() owes the flush itself. */
static int test_DoChannelCloseFlushesReply(void)
{
WOLFSSH_CTX* ctx = NULL;
WOLFSSH* ssh = NULL;
WOLFSSH_CHANNEL* ch = NULL;
int result = 0;
int ret;
byte pkt[16];
ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL);
if (ctx == NULL)
return -1610;
/* The socket refuses both sends DoChannelClose() makes -- the EOF's and
* the close's -- and takes bytes again by the time the worker flushes. */
s_sendRefusals = 2;
s_sentSz = 0;
wolfSSH_SetIOSend(ctx, RefuseThenCaptureIoSend);
wolfSSH_SetIORecv(ctx, PacketIoRecv);
ssh = wolfSSH_new(ctx);
if (ssh == NULL) { result = -1611; goto done; }
ssh->acceptState = ACCEPT_SERVER_USERAUTH_SENT;
ch = ChannelNew(ssh, ID_CHANTYPE_SESSION, 1024, 1024);
if (ch == NULL) { result = -1612; goto done; }
if (ChannelAppend(ssh, ch) != WS_SUCCESS) {
ChannelDelete(ch, ssh->ctx->heap);
result = -1613;
goto done;
}
ch->openConfirmed = 1;
ch->peerWindowSz = 1024;
ch->peerMaxPacketSz = 1024;
/* The peer closes first. */
s_recvPkt = pkt;
s_recvPktSz = BuildChannelClosePacket(pkt, ch->channel);
s_recvPktOff = 0;
ret = wolfSSH_worker(ssh, NULL);
/* The flush is a courtesy, not the caller's business: it neither replaces
* the close status nor disturbs the latched error. */
if (ret != WS_CHANNEL_CLOSED) { result = -1614; goto done; }
if (wolfSSH_get_error(ssh) != WS_CHANNEL_CLOSED) {
result = -1615;
goto done;
}
/* Nothing is left owed, and both packets really went out. */
if (ssh->outputBuffer.length != 0) { result = -1616; goto done; }
if (!PlainPacketsHaveMsg(s_sentBuf, 0, s_sentSz, MSGID_CHANNEL_EOF)) {
result = -1617;
goto done;
}
if (!PlainPacketsHaveMsg(s_sentBuf, 0, s_sentSz, MSGID_CHANNEL_CLOSE)) {
result = -1618;
goto done;
}
done:
s_recvPkt = NULL;
s_recvPktSz = 0;
s_recvPktOff = 0;
s_sendRefusals = 0;
s_sentSz = 0;
wolfSSH_free(ssh);
wolfSSH_CTX_free(ctx);
return result;
}
/* DoReceive() can retire the head channel mid-read: DoChannelClose() frees it
* while wolfSSH_stream_read() still holds its inputBuffer. If the next head
* already has its EOF latched and drained, the EOF override fires while
@ -7615,6 +7899,155 @@ done:
return result;
}
#endif /* NO_WOLFSSH_CLIENT */
#ifndef NO_WOLFSSH_CLIENT
/* wolfSSH_shutdown() drains for the peer's answer to its close. A peer that
* half-closes first answers with an EOF, which is an ordinary part of the
* teardown and not a failure of it. */
static int test_ShutdownAbsorbsEof(void)
{
WOLFSSH_CTX* ctx = NULL;
WOLFSSH* ssh = NULL;
WOLFSSH_CHANNEL* ch = NULL;
int result = 0;
int ret;
byte pkt[16];
ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_CLIENT, NULL);
if (ctx == NULL)
return -1740;
wolfSSH_SetIOSend(ctx, DiscardIoSend);
wolfSSH_SetIORecv(ctx, PacketIoRecv);
ssh = wolfSSH_new(ctx);
if (ssh == NULL) { result = -1741; goto done; }
ssh->connectState = CONNECT_SERVER_USERAUTH_ACCEPT_DONE;
ch = ChannelNew(ssh, ID_CHANTYPE_SESSION, 1024, 1024);
if (ch == NULL) { result = -1742; goto done; }
if (ChannelAppend(ssh, ch) != WS_SUCCESS) {
ChannelDelete(ch, ssh->ctx->heap);
result = -1743;
goto done;
}
ch->openConfirmed = 1;
ch->peerWindowSz = 1024;
ch->peerMaxPacketSz = 1024;
/* All the peer sends back is its own half-close. */
s_recvPkt = pkt;
s_recvPktSz = BuildChannelEofPacket(pkt, ch->channel);
s_recvPktOff = 0;
ret = wolfSSH_shutdown(ssh);
if (ret != WS_SUCCESS) { result = -1744; goto done; }
/* The teardown went out and the channel is still there, waiting for the
* peer's close. */
if (!ch->eofTxd || !ch->closeTxd) { result = -1745; goto done; }
if (!ch->eofRxd) { result = -1746; goto done; }
if (ssh->channelList != ch) { result = -1747; goto done; }
done:
s_recvPkt = NULL;
s_recvPktSz = 0;
s_recvPktOff = 0;
wolfSSH_free(ssh);
wolfSSH_CTX_free(ctx);
return result;
}
#endif /* NO_WOLFSSH_CLIENT */
#ifndef NO_WOLFSSH_CLIENT
/* wolfSSH_ChannelExit() chains its close on the EOF's send, so a socket that
* will not take the EOF leaves the close unbuilt and the teardown half done.
* The call has to be repeated, and the repeat must finish it rather than put
* a second EOF on the wire. */
static int test_ChannelExitWantWrite(void)
{
WOLFSSH_CTX* ctx = NULL;
WOLFSSH* ssh = NULL;
WOLFSSH_CHANNEL* ch = NULL;
int result = 0;
int ret;
word32 queued;
ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_CLIENT, NULL);
if (ctx == NULL)
return -1748;
wolfSSH_SetIOSend(ctx, WantWriteIoSend);
ssh = wolfSSH_new(ctx);
if (ssh == NULL) { result = -1749; goto done; }
ssh->connectState = CONNECT_SERVER_USERAUTH_ACCEPT_DONE;
ch = ChannelNew(ssh, ID_CHANTYPE_SESSION, 1024, 1024);
if (ch == NULL) { result = -1750; goto done; }
if (ChannelAppend(ssh, ch) != WS_SUCCESS) {
ChannelDelete(ch, ssh->ctx->heap);
result = -1751;
goto done;
}
ch->openConfirmed = 1;
ch->peerWindowSz = 1024;
ch->peerMaxPacketSz = 1024;
/* The EOF is bundled but not away, so the close was never built. */
ret = wolfSSH_ChannelExit(ch);
if (ret != WS_WANT_WRITE) { result = -1752; goto done; }
if (!ch->eofTxd) { result = -1753; goto done; }
if (ch->closeTxd) { result = -1754; goto done; }
queued = ssh->outputBuffer.length;
if (queued == 0) { result = -1755; goto done; }
if (!PlainPacketsHaveMsg(ssh->outputBuffer.buffer, ssh->outputBuffer.idx,
ssh->outputBuffer.length, MSGID_CHANNEL_EOF)) {
result = -1756;
goto done;
}
if (PlainPacketsHaveMsg(ssh->outputBuffer.buffer, ssh->outputBuffer.idx,
ssh->outputBuffer.length, MSGID_CHANNEL_CLOSE)) {
result = -1757;
goto done;
}
/* The repeat builds the close, still against a blocked socket. */
ret = wolfSSH_ChannelExit(ch);
if (ret != WS_WANT_WRITE) { result = -1766; goto done; }
if (!ch->closeTxd) { result = -1767; goto done; }
/* A third call has nothing left to build, so it reports success -- with
* both messages still sitting in the buffer. WS_SUCCESS from this call
* means bundled, not delivered, which is why the header tells callers to
* keep driving wolfSSH_worker() before dropping the socket. */
ret = wolfSSH_ChannelExit(ch);
if (ret != WS_SUCCESS) { result = -1768; goto done; }
if (!wolfSSH_OutputPending(ssh)) { result = -1769; goto done; }
/* With both flags latched the call builds nothing and sends nothing --
* it does not even reach the transport, so a working socket does not
* drain what is queued. The caller owns that. */
wolfSSH_SetIOSend(ctx, DiscardIoSend);
ret = wolfSSH_ChannelExit(ch);
if (ret != WS_SUCCESS) { result = -1758; goto done; }
if (!ch->closeTxd) { result = -1759; goto done; }
if (!wolfSSH_OutputPending(ssh)) { result = -1778; goto done; }
/* Driven by hand, both messages go out and no duplicate follows. */
if (wolfSSH_SendPacket(ssh) != WS_SUCCESS) { result = -1779; goto done; }
if (ssh->outputBuffer.length != 0) { result = -1760; goto done; }
/* And the channel is still there for the peer's close to name. */
if (ssh->channelList != ch) { result = -1761; goto done; }
done:
wolfSSH_free(ssh);
wolfSSH_CTX_free(ctx);
return result;
}
#endif /* NO_WOLFSSH_CLIENT */
static int test_SendChannelData_eofTxd(void)
{
WOLFSSH_CTX* ctx = NULL;
@ -19452,6 +19885,16 @@ int wolfSSH_UnitTest(int argc, char** argv)
(unitResult == 0 ? "SUCCESS" : "FAILED"));
testResult = testResult || unitResult;
unitResult = test_DoChannelCloseWantWrite();
printf("DoChannelCloseWantWrite: %s\n",
(unitResult == 0 ? "SUCCESS" : "FAILED"));
testResult = testResult || unitResult;
unitResult = test_DoChannelCloseFlushesReply();
printf("DoChannelCloseFlushesReply: %s\n",
(unitResult == 0 ? "SUCCESS" : "FAILED"));
testResult = testResult || unitResult;
unitResult = test_AcceptSurvivesChannelEof();
printf("AcceptSurvivesChannelEof: %s\n",
(unitResult == 0 ? "SUCCESS" : "FAILED"));
@ -19481,6 +19924,11 @@ int wolfSSH_UnitTest(int argc, char** argv)
testResult = testResult || unitResult;
#endif /* NO_WOLFSSH_CLIENT */
unitResult = test_ChannelExitKeepsChannel();
printf("ChannelExitKeepsChannel: %s\n",
(unitResult == 0 ? "SUCCESS" : "FAILED"));
testResult = testResult || unitResult;
#endif /* NO_WOLFSSH_SERVER */
#ifndef NO_WOLFSSH_CLIENT
@ -19545,6 +19993,20 @@ int wolfSSH_UnitTest(int argc, char** argv)
testResult = testResult || unitResult;
#endif /* NO_WOLFSSH_CLIENT */
#ifndef NO_WOLFSSH_CLIENT
unitResult = test_ShutdownAbsorbsEof();
printf("ShutdownAbsorbsEof: %s\n",
(unitResult == 0 ? "SUCCESS" : "FAILED"));
testResult = testResult || unitResult;
#endif /* NO_WOLFSSH_CLIENT */
#ifndef NO_WOLFSSH_CLIENT
unitResult = test_ChannelExitWantWrite();
printf("ChannelExitWantWrite: %s\n",
(unitResult == 0 ? "SUCCESS" : "FAILED"));
testResult = testResult || unitResult;
#endif /* NO_WOLFSSH_CLIENT */
unitResult = test_SendChannelData_eofTxd();
printf("SendChannelData_eofTxd: %s\n", (unitResult == 0 ? "SUCCESS" : "FAILED"));
testResult = testResult || unitResult;

View File

@ -323,6 +323,15 @@ WOLFSSH_API int wolfSSH_ChannelReadExt(WOLFSSH_CHANNEL* channel, byte* buf,
word32 bufSz);
WOLFSSH_API int wolfSSH_ChannelSendExt(WOLFSSH_CHANNEL* channel,
const byte* buf, word32 bufSz);
/* Sends EOF then SSH_MSG_CHANNEL_CLOSE. The channel stays on the list, and the
* pointer stays valid, until the peer's close arrives and wolfSSH_worker()
* reports WS_CHANNEL_CLOSED. A walk with wolfSSH_ChannelNext() has to step
* past a channel it has exited rather than re-read the head, which no longer
* moves.
*
* A WS_WANT_WRITE means the teardown is incomplete: the close is only built
* once the EOF is away, so call again until it reports something else. The
* retry costs nothing, a bundled EOF is not sent twice. */
WOLFSSH_API int wolfSSH_ChannelExit(WOLFSSH_CHANNEL* channel);
/* Sends SSH_MSG_CHANNEL_EOF, closing the sending direction and leaving the
* receiving direction open (the half-close of RFC 4254 section 5.3). Data