Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions .github/workflows/autobahn__build-target-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
name: "autobahn: build/target-tests"

on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened, labeled]

jobs:
build_autobahn:
# Run on push to master or if PR has 'websocket' label
if: contains(github.event.pull_request.labels.*.name, 'autobahn') || github.event_name == 'push'
name: Build
strategy:
matrix:
#idf_ver: ["release-v5.0", "release-v5.1", "release-v5.2", "release-v5.3", "latest"]
idf_ver: [ "latest"]
idf_target: ["esp32"]
runs-on: ubuntu-22.04
container: espressif/idf:${{ matrix.idf_ver }}
env:
TEST_DIR: components/esp_websocket_client/examples/autobahn-testsuite/testee
steps:
- name: Checkout esp-protocols
uses: actions/checkout@v4
with:
submodules: recursive
- name: Build autobahn testee with IDF-${{ matrix.idf_ver }} for ${{ matrix.idf_target }}
working-directory: ${{ env.TEST_DIR }}
env:
IDF_TARGET: ${{ matrix.idf_target }}
shell: bash
run: |
. ${IDF_PATH}/export.sh
test -f sdkconfig.ci.plain_tcp && cat sdkconfig.ci.plain_tcp >> sdkconfig.defaults || echo "No sdkconfig.ci.plain_tcp"
idf.py set-target ${{ matrix.idf_target }}
idf.py build
- name: Merge binaries with IDF-${{ matrix.idf_ver }} for ${{ matrix.idf_target }}
working-directory: ${{ env.TEST_DIR }}/build
env:
IDF_TARGET: ${{ matrix.idf_target }}
shell: bash
run: |
. ${IDF_PATH}/export.sh
esptool.py --chip ${{ matrix.idf_target }} merge_bin --fill-flash-size 4MB -o flash_image.bin @flash_args
- uses: actions/upload-artifact@v4
with:
name: autobahn_testee_bin_${{ matrix.idf_target }}_${{ matrix.idf_ver }}
path: |
${{ env.TEST_DIR }}/build/bootloader/bootloader.bin
${{ env.TEST_DIR }}/build/partition_table/partition-table.bin
${{ env.TEST_DIR }}/build/*.bin
${{ env.TEST_DIR }}/build/*.elf
${{ env.TEST_DIR }}/build/flasher_args.json
${{ env.TEST_DIR }}/build/config/sdkconfig.h
${{ env.TEST_DIR }}/build/config/sdkconfig.json
if-no-files-found: error

run-target-autobahn:
# Skip running on forks since it won't have access to secrets
if: |
github.repository == 'espressif/esp-protocols' &&
( contains(github.event.pull_request.labels.*.name, 'autobahn') || github.event_name == 'push' )
name: Target test
needs: build_autobahn
strategy:
fail-fast: false
matrix:
idf_ver: ["latest"]
idf_target: ["esp32"]
runs-on:
- self-hosted
- ESP32-ETHERNET-KIT
env:
TEST_DIR: components/esp_websocket_client/examples/autobahn-testsuite
TESTEE_DIR: components/esp_websocket_client/examples/autobahn-testsuite/testee
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/download-artifact@v4
with:
name: autobahn_testee_bin_${{ matrix.idf_target }}_${{ matrix.idf_ver }}
path: ${{ env.TESTEE_DIR }}/build
- name: Install Docker Compose
run: |
sudo apt-get update
sudo apt-get install -y docker-compose-plugin || sudo apt-get install -y docker-compose
# Ensure user has permission to use Docker (if not already in docker group)
sudo usermod -aG docker $USER || true
# Start Docker service if not running
sudo systemctl start docker || true
- name: Start Autobahn Fuzzing Server
working-directory: ${{ env.TEST_DIR }}
run: |
# Get host IP address for ESP32 to connect to
HOST_IP=$(hostname -I | awk '{print $1}')
echo "HOST_IP=$HOST_IP" >> $GITHUB_ENV
echo "Autobahn server will be accessible at ws://$HOST_IP:9001"

# Start the fuzzing server using pre-built image
# For CI, we may need to specify platform if architecture differs
echo "Starting Autobahn fuzzing server..."
# Set platform for CI if needed (uncomment if you get exec format error)
# export DOCKER_DEFAULT_PLATFORM=linux/amd64
docker compose up -d || docker-compose up -d

# Wait for server to be ready
echo "Waiting for fuzzing server to start..."
sleep 10

# Check if container is running and healthy
if ! docker ps | grep -q ws-fuzzing-server; then
echo "Error: Fuzzing server failed to start"
echo "Container logs:"
docker compose logs || docker-compose logs
echo "Checking available Python executables in container:"
docker compose run --rm fuzzing-server which python python3 || true
exit 1
fi

# Verify the server is actually responding
echo "Checking if server is responding..."
sleep 5
if ! curl -s http://localhost:8080 > /dev/null 2>&1; then
echo "Warning: Server may not be fully ready, but container is running"
docker compose logs --tail=20 || docker-compose logs --tail=20
fi

echo "✓ Fuzzing server started successfully"
- name: Flash ESP32 Testee
working-directory: ${{ env.TESTEE_DIR }}/build
env:
IDF_TARGET: ${{ matrix.idf_target }}
run: |
python -m esptool --chip ${{ matrix.idf_target }} write_flash 0x0 flash_image.bin
- name: Run Autobahn Tests
working-directory: ${{ env.TESTEE_DIR }}
env:
PIP_EXTRA_INDEX_URL: "https://www.piwheels.org/simple"
run: |
# Detect ESP32 port if not set in environment
if [ -z "${ESP_PORT:-}" ]; then
for port in /dev/ttyUSB* /dev/ttyACM*; do
if [ -e "$port" ]; then
export ESP_PORT="$port"
echo "Detected ESP32 port: $ESP_PORT"
break
fi
done
fi

# Default to /dev/ttyUSB0 if still not found
export ESP_PORT="${ESP_PORT:-/dev/ttyUSB0}"

if [ ! -e "$ESP_PORT" ]; then
echo "Error: ESP32 port not found. Please set ESP_PORT environment variable."
echo "Available ports:"
ls -la /dev/tty* || true
exit 1
fi

echo "Using ESP32 port: $ESP_PORT"
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init --path)"
eval "$(pyenv init -)"
if ! pyenv versions --bare | grep -q '^3\.12\.6$'; then
echo "Installing Python 3.12.6..."
pyenv install -s 3.12.6
fi
if ! pyenv virtualenvs --bare | grep -q '^myenv$'; then
echo "Creating pyenv virtualenv 'myenv'..."
pyenv virtualenv 3.12.6 myenv
fi
pyenv activate myenv
python --version
pip install --prefer-binary pytest-embedded pytest-embedded-serial-esp pytest-embedded-idf pytest-custom_exit_code esptool pyserial
pip install --extra-index-url https://dl.espressif.com/pypi/ -r $GITHUB_WORKSPACE/ci/requirements.txt

echo "Starting Autobahn test suite on ESP32..."
echo "Tests may take 15-30 minutes to complete..."

# Send server URI via serial (stdin) and monitor for completion
# Script is in the parent directory (TEST_DIR) from TESTEE_DIR
SERVER_URI="ws://$HOST_IP:9001"
echo "Sending server URI to ESP32: $SERVER_URI"
python3 ../scripts/monitor_serial.py --port "$ESP_PORT" --uri "$SERVER_URI" --timeout 2400
- name: Collect Test Reports
working-directory: ${{ env.TEST_DIR }}
if: always()
run: |
# Stop the fuzzing server
docker compose down || docker-compose down

# Check if reports were generated
if [ -d "reports/clients" ]; then
echo "✓ Test reports found"
ls -la reports/clients/
else
echo "⚠ No test reports found in reports/clients/"
fi
- name: Generate Test Summary
working-directory: ${{ env.TEST_DIR }}
if: always()
run: |
# Generate summary from test results
# Check for JSON files in both reports/ and reports/clients/
if [ -d "reports" ] && ( [ -n "$(ls -A reports/*.json 2>/dev/null)" ] || [ -n "$(ls -A reports/clients/*.json 2>/dev/null)" ] ); then
echo "Generating test summary..."
python3 scripts/generate_summary.py
echo ""
echo "Summary generated successfully!"
if [ -f "reports/summary.html" ]; then
echo "HTML summary available at: reports/summary.html"
fi
else
echo "⚠ No JSON test results found, skipping summary generation"
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: autobahn_reports_${{ matrix.idf_target }}_${{ matrix.idf_ver }}
path: |
${{ env.TEST_DIR }}/reports/**
if-no-files-found: warn

90 changes: 83 additions & 7 deletions components/esp_websocket_client/esp_websocket_client.c
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,29 @@
return esp_event_loop_run(client->event_handle, 0);
}

/**
* @brief Abort the WebSocket connection and initiate reconnection or shutdown
*
* @param client WebSocket client handle
* @param error_type Type of error that caused the abort
*
* @return ESP_OK on success, ESP_FAIL on failure
*
* @note PRECONDITION: client->lock MUST be held by the calling thread before calling this function.
* This function does NOT acquire the lock itself. Calling without the lock will result in
* race conditions and undefined behavior.
*/
static esp_err_t esp_websocket_client_abort_connection(esp_websocket_client_handle_t client, esp_websocket_error_type_t error_type)
{
ESP_WS_CLIENT_STATE_CHECK(TAG, client, return ESP_FAIL);


if (client->state == WEBSOCKET_STATE_CLOSING || client->state == WEBSOCKET_STATE_UNKNOW ||
client->state == WEBSOCKET_STATE_WAIT_TIMEOUT) {
ESP_LOGW(TAG, "Connection already closing/closed, skipping abort");
return ESP_OK;
}

esp_transport_close(client->transport);

if (!client->config->auto_reconnect) {
Expand All @@ -256,6 +276,17 @@
}
client->error_handle.error_type = error_type;
esp_websocket_client_dispatch_event(client, WEBSOCKET_EVENT_DISCONNECTED, NULL, 0);

if (client->errormsg_buffer) {
ESP_LOGD(TAG, "Freeing error buffer (%d bytes) - Free heap: %" PRIu32 " bytes",
client->errormsg_size, esp_get_free_heap_size());
free(client->errormsg_buffer);
client->errormsg_buffer = NULL;
client->errormsg_size = 0;
} else {
ESP_LOGD(TAG, "Disconnect - Free heap: %" PRIu32 " bytes", esp_get_free_heap_size());
}

return ESP_OK;
}

Expand Down Expand Up @@ -453,6 +484,8 @@
esp_websocket_client_destroy_config(client);
if (client->transport_list) {
esp_transport_list_destroy(client->transport_list);
client->transport_list = NULL;
client->transport = NULL;
}
vSemaphoreDelete(client->lock);
#ifdef CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK
Expand Down Expand Up @@ -679,8 +712,18 @@
} else {
esp_websocket_client_error(client, "esp_transport_write() returned %d, errno=%d", ret, errno);
}
ESP_LOGD(TAG, "Calling abort_connection due to send error");
#ifdef CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK
xSemaphoreGiveRecursive(client->tx_lock);
xSemaphoreTakeRecursive(client->lock, portMAX_DELAY);
esp_websocket_client_abort_connection(client, WEBSOCKET_ERROR_TYPE_TCP_TRANSPORT);
xSemaphoreGiveRecursive(client->lock);
return ret;
#else
// Already holding client->lock, safe to call
esp_websocket_client_abort_connection(client, WEBSOCKET_ERROR_TYPE_TCP_TRANSPORT);
goto unlock_and_return;
#endif
}
opcode = 0;
widx += wlen;
Expand Down Expand Up @@ -1019,7 +1062,6 @@
esp_websocket_free_buf(client, false);
return ESP_OK;
}

esp_websocket_client_dispatch_event(client, WEBSOCKET_EVENT_DATA, client->rx_buffer, rlen);

client->payload_offset += rlen;
Expand All @@ -1030,16 +1072,36 @@
const char *data = (client->payload_len == 0) ? NULL : client->rx_buffer;
ESP_LOGD(TAG, "Sending PONG with payload len=%d", client->payload_len);
#ifdef CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK
xSemaphoreGiveRecursive(client->lock); // Release client->lock

// Now acquire tx_lock with timeout (consistent with PING/CLOSE handling)
if (xSemaphoreTakeRecursive(client->tx_lock, WEBSOCKET_TX_LOCK_TIMEOUT_MS) != pdPASS) {
ESP_LOGE(TAG, "Could not lock ws-client within %d timeout", WEBSOCKET_TX_LOCK_TIMEOUT_MS);
return ESP_FAIL;
ESP_LOGE(TAG, "Could not lock ws-client within %d timeout for PONG", WEBSOCKET_TX_LOCK_TIMEOUT_MS);
xSemaphoreTakeRecursive(client->lock, portMAX_DELAY); // Re-acquire client->lock before returning
esp_websocket_free_buf(client, false); // Free rx_buffer to prevent memory leak
return ESP_OK; // Return gracefully, caller expects client->lock to be held
}
#endif

// Re-acquire client->lock to maintain consistency
xSemaphoreTakeRecursive(client->lock, portMAX_DELAY);


// Another thread may have closed it while we didn't hold client->lock
if (client->state == WEBSOCKET_STATE_CLOSING || client->state == WEBSOCKET_STATE_UNKNOW ||
client->state == WEBSOCKET_STATE_WAIT_TIMEOUT || client->transport == NULL) {
ESP_LOGW(TAG, "Transport closed while preparing PONG, skipping send");
xSemaphoreGiveRecursive(client->tx_lock);
esp_websocket_free_buf(client, false); // Free rx_buffer to prevent memory leak
return ESP_OK; // Caller expects client->lock to be held, which it is
}

esp_transport_ws_send_raw(client->transport, WS_TRANSPORT_OPCODES_PONG | WS_TRANSPORT_OPCODES_FIN, data, client->payload_len,
client->config->network_timeout_ms);
#ifdef CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK
xSemaphoreGiveRecursive(client->tx_lock);
#else
esp_transport_ws_send_raw(client->transport, WS_TRANSPORT_OPCODES_PONG | WS_TRANSPORT_OPCODES_FIN, data, client->payload_len,
client->config->network_timeout_ms);
#endif

Check warning

Code scanning / clang-tidy

The value '138' provided to the cast expression is not in the valid range of values for 'ws_transport_opcodes' [clang-analyzer-optin.core.EnumCastOutOfRange] Warning

The value '138' provided to the cast expression is not in the valid range of values for 'ws_transport_opcodes' [clang-analyzer-optin.core.EnumCastOutOfRange]
} else if (client->last_opcode == WS_TRANSPORT_OPCODES_PONG) {
client->wait_for_pong_resp = false;
} else if (client->last_opcode == WS_TRANSPORT_OPCODES_CLOSE) {
Expand Down Expand Up @@ -1136,7 +1198,20 @@
client->state = WEBSOCKET_STATE_CONNECTED;
client->wait_for_pong_resp = false;
client->error_handle.error_type = WEBSOCKET_ERROR_TYPE_NONE;
client->payload_len = 0;
client->payload_offset = 0;
client->last_fin = false;
client->last_opcode = WS_TRANSPORT_OPCODES_NONE;

esp_websocket_client_dispatch_event(client, WEBSOCKET_EVENT_CONNECTED, NULL, 0);
// Check for any data that may have arrived during handshake
int immediate_poll = esp_transport_poll_read(client->transport, 0); // Non-blocking
if (immediate_poll >= 0) {
esp_err_t recv_result = esp_websocket_client_recv(client);
if (recv_result == ESP_OK) {
esp_event_loop_run(client->event_handle, 0);
}
}
break;
case WEBSOCKET_STATE_CONNECTED:
if ((CLOSE_FRAME_SENT_BIT & xEventGroupGetBits(client->status_bits)) == 0) { // only send and check for PING
Expand Down Expand Up @@ -1214,12 +1289,13 @@
esp_websocket_client_abort_connection(client, WEBSOCKET_ERROR_TYPE_TCP_TRANSPORT);
xSemaphoreGiveRecursive(client->lock);
} else if (read_select > 0) {
xSemaphoreTakeRecursive(client->lock, lock_timeout);
if (esp_websocket_client_recv(client) == ESP_FAIL) {
ESP_LOGE(TAG, "Error receive data");
xSemaphoreTakeRecursive(client->lock, lock_timeout);
// Note: Already holding client->lock from line above
esp_websocket_client_abort_connection(client, WEBSOCKET_ERROR_TYPE_TCP_TRANSPORT);
xSemaphoreGiveRecursive(client->lock);
}
xSemaphoreGiveRecursive(client->lock);
} else {
ESP_LOGV(TAG, "Read poll timeout: skipping esp_transport_poll_read().");
}
Expand Down
Loading
Loading