From 6321795ee0b858269a1d40f6f64385e7d1b66db8 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Thu, 10 Sep 2026 17:15:50 -0700 Subject: [PATCH 1/4] espressif: guard the forward cleanup handler WOLFSSH_FWD_LOCAL_CLEANUP now runs, so this echoserver's handler for it runs too. It closes only the socket belonging to the channel that ended, and only when one was connected. - gate the handler on the channel id the library passes in the port parameter. A channel can outlive its turn in the single forwarding slot, and a cleanup arriving late would close the next one's socket - guard the close: the open can fail after the setup, with nothing yet connected and appFd still -1 --- .../wolfssh_echoserver/main/echoserver.c | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c b/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c index 3f6f708ac..16d21dc7f 100644 --- a/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c +++ b/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c @@ -510,16 +510,23 @@ static int wolfSSH_FwdDefaultActions(WS_FwdCbAction action, void* vCtx, ctx->state = FWD_STATE_DIRECT; } else if (action == WOLFSSH_FWD_LOCAL_CLEANUP) { - WCLOSESOCKET(ctx->appFd); - if (ctx->hostName) { - WFREE(ctx->hostName, NULL, 0); - ctx->hostName = NULL; - } - if (ctx->originName) { - WFREE(ctx->originName, NULL, 0); - ctx->originName = NULL; + /* Channel id rides in port. Only its holder may tear the slot down. */ + if (port == ctx->channelId) { + /* The open can fail after setup, before anything connected. */ + if (ctx->appFd != (WS_SOCKET_T)-1) { + WCLOSESOCKET(ctx->appFd); + ctx->appFd = -1; + } + if (ctx->hostName) { + WFREE(ctx->hostName, NULL, 0); + ctx->hostName = NULL; + } + if (ctx->originName) { + WFREE(ctx->originName, NULL, 0); + ctx->originName = NULL; + } + ctx->state = FWD_STATE_INIT; } - ctx->state = FWD_STATE_INIT; } else if (action == WOLFSSH_FWD_REMOTE_SETUP) { struct sockaddr_in addr; From 57dab66f57f565a5e40b56aca28ee180933283ec Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 15 Sep 2026 19:49:31 -0700 Subject: [PATCH 2/4] espressif: close a direct forward's socket once The cleanup handler closes the descriptor the context holds, and only the accept path recorded one, so a direct forward's socket was left open when its channel closed. The direct path now records it, and the worker's recovery branch and the handler no longer race for the teardown. - record the connected socket on the direct path, as the accept path does - resolve the closed channel with wolfSSH_GetLastRxId(). wolfSSH_worker() names the channel only for the data and EOF statuses, so the recovery branch compared against a stale zero - let the recovery branch clear its stale copy when the handler got there first, and still tear down a locally opened forward, which draws no callback --- .../wolfssh_echoserver/main/echoserver.c | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c b/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c index 16d21dc7f..9dab73e11 100644 --- a/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c +++ b/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c @@ -1157,20 +1157,35 @@ static int ssh_worker(thread_ctx_t* threadCtx) } else if (rc == WS_CHANNEL_CLOSED) { #ifdef WOLFSSH_FWD - if (threadCtx->fwdCbCtx.state == FWD_STATE_CONNECTED && - lastChannel == threadCtx->fwdCbCtx.channelId) { - /* Read zero-returned. Socket is closed. Go back - to listening. */ - if (fwdFd != -1) { - WCLOSESOCKET(fwdFd); + /* wolfSSH_worker() names the channel only for the + * data and EOF statuses; DoChannelClose() recorded + * the id it retired. */ + wolfSSH_GetLastRxId(ssh, &lastChannel); + if (lastChannel == threadCtx->fwdCbCtx.channelId) { + if (threadCtx->fwdCbCtx.appFd == -1) { + /* The cleanup handler ran ahead of this and + * closed the socket; only this copy of the + * descriptor is stale. */ fwdFd = -1; } - if (threadCtx->fwdCbCtx.originName != NULL) { - WFREE(threadCtx->fwdCbCtx.originName, - NULL, 0); - threadCtx->fwdCbCtx.originName = NULL; + else if (threadCtx->fwdCbCtx.state + == FWD_STATE_CONNECTED) { + /* A locally opened forward is armed by no + * LOCAL_SETUP and so draws no cleanup. Its + * teardown is still ours: go back to + * listening. */ + if (fwdFd != -1) { + WCLOSESOCKET(fwdFd); + fwdFd = -1; + threadCtx->fwdCbCtx.appFd = -1; + } + if (threadCtx->fwdCbCtx.originName != NULL) { + WFREE(threadCtx->fwdCbCtx.originName, + NULL, 0); + threadCtx->fwdCbCtx.originName = NULL; + } + threadCtx->fwdCbCtx.state = FWD_STATE_LISTEN; } - threadCtx->fwdCbCtx.state = FWD_STATE_LISTEN; } #endif continue; @@ -1412,6 +1427,9 @@ static int ssh_worker(thread_ctx_t* threadCtx) threadCtx->fwdCbCtx.hostPort); if (fwdFd > 0) { + /* The cleanup handler closes what it finds here, so a + * direct forward has to record its socket too. */ + threadCtx->fwdCbCtx.appFd = fwdFd; threadCtx->fwdCbCtx.state = FWD_STATE_CONNECTED; } } From b1e6c5e46642a56fbec7211edebcbe86ffeb0328 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Tue, 15 Sep 2026 20:07:44 -0700 Subject: [PATCH 3/4] espressif: retire the forward fd at every close Recording the direct forward's socket in the context left the two sites that close it on the target's own EOF or reset holding a stale copy, so the cleanup handler closed a descriptor number the task had since reissued. Every close now retires the worker's copy, the context's, and any bytes the forward was still holding. - clear appFd where a zero read ends the forward - clear appFd and the worker's own fwdFd on a reset, which that branch never reset - drop the held forward bytes at every close, so a later forward does not send the dead connection's data on its new channel - compare appFd against a cast -1 in both places, since WS_SOCKET_T is unsigned on Windows --- .../examples/wolfssh_echoserver/main/echoserver.c | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c b/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c index 9dab73e11..bf2912cc1 100644 --- a/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c +++ b/ide/Espressif/ESP-IDF/examples/wolfssh_echoserver/main/echoserver.c @@ -1162,7 +1162,9 @@ static int ssh_worker(thread_ctx_t* threadCtx) * the id it retired. */ wolfSSH_GetLastRxId(ssh, &lastChannel); if (lastChannel == threadCtx->fwdCbCtx.channelId) { - if (threadCtx->fwdCbCtx.appFd == -1) { + /* Held bytes belong to the channel going away. */ + fwdBufferIdx = 0; + if (threadCtx->fwdCbCtx.appFd == (WS_SOCKET_T)-1) { /* The cleanup handler ran ahead of this and * closed the socket; only this copy of the * descriptor is stale. */ @@ -1312,6 +1314,8 @@ static int ssh_worker(thread_ctx_t* threadCtx) to listening. */ WCLOSESOCKET(fwdFd); fwdFd = -1; + fwdBufferIdx = 0; + threadCtx->fwdCbCtx.appFd = -1; if (threadCtx->fwdCbCtx.hostName != NULL) { WFREE(threadCtx->fwdCbCtx.hostName, NULL, 0); @@ -1332,6 +1336,9 @@ static int ssh_worker(thread_ctx_t* threadCtx) /* Connection reset. Socket is closed. * Go back to listening. */ WCLOSESOCKET(fwdFd); + fwdFd = -1; + fwdBufferIdx = 0; + threadCtx->fwdCbCtx.appFd = -1; threadCtx->fwdCbCtx.state = FWD_STATE_LISTEN; continue; } From c80623f0e1c4d686db2b411eb669aef627756289 Mon Sep 17 00:00:00 2001 From: John Safranek Date: Wed, 16 Sep 2026 11:32:35 -0700 Subject: [PATCH 4/4] ci: build the Espressif examples weekly A weekly scheduled job builds both ESP-IDF example projects, so a break in the Espressif port surfaces on its own instead of waiting for a user to report it. - compileAllExamples.sh builds every project under examples/, pointing the local components at WOLFSSH_ROOT and WOLFSSL_ROOT - the workflow covers ESP-IDF release-v5.5 and release-v5.1, built against wolfSSL v5.9.1-stable - a scheduled failure opens or comments on an issue labelled espressif-build-failure --- .github/workflows/espressif.yml | 97 +++++++++++++++++++++ ide/Espressif/ESP-IDF/compileAllExamples.sh | 89 +++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 .github/workflows/espressif.yml create mode 100755 ide/Espressif/ESP-IDF/compileAllExamples.sh diff --git a/.github/workflows/espressif.yml b/.github/workflows/espressif.yml new file mode 100644 index 000000000..72ef6958e --- /dev/null +++ b/.github/workflows/espressif.yml @@ -0,0 +1,97 @@ +name: wolfSSH Espressif Build Test + +on: + schedule: + # Weekly: Mondays at 07:00 UTC + - cron: '0 7 * * 1' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + WOLFSSL_REF: v5.9.1-stable + +jobs: + build_examples: + name: ESP-IDF ${{ matrix.idf-ref }} + strategy: + fail-fast: false + matrix: + # release-v5.5 is the current stable line; release-v5.1 is the version + # the example READMEs and VisualGDB projects document. + idf-ref: [ release-v5.5, release-v5.1 ] + runs-on: ubuntu-latest + container: + image: espressif/idf:${{ matrix.idf-ref }} + timeout-minutes: 30 + steps: + - name: Checkout wolfSSH + uses: actions/checkout@v6 + with: + path: wolfssh + + - name: Checkout wolfSSL + uses: actions/checkout@v6 + with: + repository: wolfssl/wolfssl + ref: ${{ env.WOLFSSL_REF }} + path: wolfssl + + - name: Build the Espressif examples + env: + WOLFSSH_ROOT: ${{ github.workspace }}/wolfssh + WOLFSSL_ROOT: ${{ github.workspace }}/wolfssl + run: | + # The image sets IDF_PATH, but the ESP-IDF tool paths come from + # export.sh, which a job step does not get from the entrypoint. + . "${IDF_PATH}/export.sh" + "${WOLFSSH_ROOT}/ide/Espressif/ESP-IDF/compileAllExamples.sh" + + notify_failure: + name: Open issue on scheduled failure + needs: [build_examples] + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + with: + script: | + const label = 'espressif-build-failure'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/` + + `${context.repo.repo}/actions/runs/${context.runId}`; + const body = [ + 'The weekly Espressif example build failed.', + '', + `Run: ${runUrl}`, + `Commit: ${context.sha}`, + ].join('\n'); + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: label, + }); + if (existing.data.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.data[0].number, + body: body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'Weekly Espressif example build failed', + body: body, + labels: [label], + }); + } diff --git a/ide/Espressif/ESP-IDF/compileAllExamples.sh b/ide/Espressif/ESP-IDF/compileAllExamples.sh new file mode 100755 index 000000000..f79079dfa --- /dev/null +++ b/ide/Espressif/ESP-IDF/compileAllExamples.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +# Build every wolfSSH ESP-IDF example project under ./examples/ +# +# The examples carry local wolfssh and wolfssl components that compile the +# library sources in place, so both source trees have to be on disk. Their +# CMakeLists find them through the WOLFSSH_ROOT and WOLFSSL_ROOT environment +# variables, which this script fills in when they are not already set. +# +# Run from an ESP-IDF environment: +# +# . /opt/esp/idf/export.sh +# WOLFSSL_ROOT=/path/to/wolfssl ide/Espressif/ESP-IDF/compileAllExamples.sh +# +# WOLFSSH_ROOT defaults to the tree holding this script, WOLFSSL_ROOT to a +# wolfssl checkout beside it. IDF_TARGET selects the chip; default esp32. + +if [ -z "${IDF_PATH}" ]; then + echo "ERROR: IDF_PATH is not set. Source the ESP-IDF export.sh first." + exit 1 +fi + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) + +if [ -z "${WOLFSSH_ROOT}" ]; then + WOLFSSH_ROOT=$(cd "${SCRIPT_DIR}/../../.." && pwd) +fi + +if [ -z "${WOLFSSL_ROOT}" ]; then + WOLFSSL_ROOT="$(dirname "${WOLFSSH_ROOT}")/wolfssl" +fi + +export WOLFSSH_ROOT +export WOLFSSL_ROOT +export IDF_TARGET="${IDF_TARGET:-esp32}" + +if [ ! -f "${WOLFSSH_ROOT}/wolfssh/ssh.h" ]; then + echo "ERROR: no wolfSSH source in WOLFSSH_ROOT=${WOLFSSH_ROOT}" + exit 1 +fi + +if [ ! -f "${WOLFSSL_ROOT}/wolfssl/ssl.h" ]; then + echo "ERROR: no wolfSSL source in WOLFSSL_ROOT=${WOLFSSL_ROOT}" + echo "Clone wolfssl beside wolfssh or set WOLFSSL_ROOT." + exit 1 +fi + +echo "IDF_PATH = ${IDF_PATH}" +echo "IDF_TARGET = ${IDF_TARGET}" +echo "WOLFSSH_ROOT = ${WOLFSSH_ROOT}" +echo "WOLFSSL_ROOT = ${WOLFSSL_ROOT}" + +BUILT="" +FAILED="" + +for PROJECT in "${SCRIPT_DIR}"/examples/*/; do + # A project directory is one with a top level CMakeLists.txt. + if [ ! -f "${PROJECT}/CMakeLists.txt" ]; then + continue + fi + + NAME=$(basename "${PROJECT}") + + echo + echo "--------------------------------------------------------------" + echo "Building ${NAME} for ${IDF_TARGET}" + echo "--------------------------------------------------------------" + + if (cd "${PROJECT}" && idf.py fullclean && idf.py build); then + BUILT="${BUILT} ${NAME}" + else + FAILED="${FAILED} ${NAME}" + fi +done + +if [ -z "${BUILT}" ] && [ -z "${FAILED}" ]; then + echo "ERROR: no example projects found in ${SCRIPT_DIR}/examples/" + exit 1 +fi + +echo +echo "Built: ${BUILT:- none}" + +if [ -n "${FAILED}" ]; then + echo "Failed:${FAILED}" + exit 1 +fi + +echo "All wolfSSH Espressif examples built."