Skip to content

Commit b3ddc42

Browse files
committed
feat(examples): websocket autobahn test suit integration
1 parent 1444d57 commit b3ddc42

19 files changed

+2093
-7
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
name: "autobahn: build/target-tests"
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
pull_request:
8+
types: [opened, synchronize, reopened, labeled]
9+
10+
jobs:
11+
build_autobahn:
12+
# Run on push to master or if PR has 'websocket' label
13+
if: contains(github.event.pull_request.labels.*.name, 'autobahn') || github.event_name == 'push'
14+
name: Build
15+
strategy:
16+
matrix:
17+
#idf_ver: ["release-v5.0", "release-v5.1", "release-v5.2", "release-v5.3", "latest"]
18+
idf_ver: [ "latest"]
19+
idf_target: ["esp32"]
20+
runs-on: ubuntu-22.04
21+
container: espressif/idf:${{ matrix.idf_ver }}
22+
env:
23+
TEST_DIR: components/esp_websocket_client/examples/autobahn-testsuite/testee
24+
steps:
25+
- name: Checkout esp-protocols
26+
uses: actions/checkout@v4
27+
with:
28+
submodules: recursive
29+
- name: Build autobahn testee with IDF-${{ matrix.idf_ver }} for ${{ matrix.idf_target }}
30+
working-directory: ${{ env.TEST_DIR }}
31+
env:
32+
IDF_TARGET: ${{ matrix.idf_target }}
33+
shell: bash
34+
run: |
35+
. ${IDF_PATH}/export.sh
36+
test -f sdkconfig.ci.plain_tcp && cat sdkconfig.ci.plain_tcp >> sdkconfig.defaults || echo "No sdkconfig.ci.plain_tcp"
37+
idf.py set-target ${{ matrix.idf_target }}
38+
idf.py build
39+
- name: Merge binaries with IDF-${{ matrix.idf_ver }} for ${{ matrix.idf_target }}
40+
working-directory: ${{ env.TEST_DIR }}/build
41+
env:
42+
IDF_TARGET: ${{ matrix.idf_target }}
43+
shell: bash
44+
run: |
45+
. ${IDF_PATH}/export.sh
46+
esptool.py --chip ${{ matrix.idf_target }} merge_bin --fill-flash-size 4MB -o flash_image.bin @flash_args
47+
- uses: actions/upload-artifact@v4
48+
with:
49+
name: autobahn_testee_bin_${{ matrix.idf_target }}_${{ matrix.idf_ver }}
50+
path: |
51+
${{ env.TEST_DIR }}/build/bootloader/bootloader.bin
52+
${{ env.TEST_DIR }}/build/partition_table/partition-table.bin
53+
${{ env.TEST_DIR }}/build/*.bin
54+
${{ env.TEST_DIR }}/build/*.elf
55+
${{ env.TEST_DIR }}/build/flasher_args.json
56+
${{ env.TEST_DIR }}/build/config/sdkconfig.h
57+
${{ env.TEST_DIR }}/build/config/sdkconfig.json
58+
if-no-files-found: error
59+
60+
run-target-autobahn:
61+
# Skip running on forks since it won't have access to secrets
62+
if: |
63+
github.repository == 'espressif/esp-protocols' &&
64+
( contains(github.event.pull_request.labels.*.name, 'autobahn') || github.event_name == 'push' )
65+
name: Target test
66+
needs: build_autobahn
67+
strategy:
68+
fail-fast: false
69+
matrix:
70+
idf_ver: ["latest"]
71+
idf_target: ["esp32"]
72+
runs-on:
73+
- self-hosted
74+
- ESP32-ETHERNET-KIT
75+
env:
76+
TEST_DIR: components/esp_websocket_client/examples/autobahn-testsuite
77+
TESTEE_DIR: components/esp_websocket_client/examples/autobahn-testsuite/testee
78+
steps:
79+
- uses: actions/checkout@v4
80+
with:
81+
submodules: recursive
82+
- uses: actions/download-artifact@v4
83+
with:
84+
name: autobahn_testee_bin_${{ matrix.idf_target }}_${{ matrix.idf_ver }}
85+
path: ${{ env.TESTEE_DIR }}/build
86+
- name: Install Docker Compose
87+
run: |
88+
sudo apt-get update
89+
sudo apt-get install -y docker-compose-plugin || sudo apt-get install -y docker-compose
90+
# Ensure user has permission to use Docker (if not already in docker group)
91+
sudo usermod -aG docker $USER || true
92+
# Start Docker service if not running
93+
sudo systemctl start docker || true
94+
- name: Start Autobahn Fuzzing Server
95+
working-directory: ${{ env.TEST_DIR }}
96+
run: |
97+
# Get host IP address for ESP32 to connect to
98+
HOST_IP=$(hostname -I | awk '{print $1}')
99+
echo "HOST_IP=$HOST_IP" >> $GITHUB_ENV
100+
echo "Autobahn server will be accessible at ws://$HOST_IP:9001"
101+
102+
# Start the fuzzing server using pre-built image
103+
# For CI, we may need to specify platform if architecture differs
104+
echo "Starting Autobahn fuzzing server..."
105+
# Set platform for CI if needed (uncomment if you get exec format error)
106+
# export DOCKER_DEFAULT_PLATFORM=linux/amd64
107+
docker compose up -d || docker-compose up -d
108+
109+
# Wait for server to be ready
110+
echo "Waiting for fuzzing server to start..."
111+
sleep 10
112+
113+
# Check if container is running and healthy
114+
if ! docker ps | grep -q ws-fuzzing-server; then
115+
echo "Error: Fuzzing server failed to start"
116+
echo "Container logs:"
117+
docker compose logs || docker-compose logs
118+
echo "Checking available Python executables in container:"
119+
docker compose run --rm fuzzing-server which python python3 || true
120+
exit 1
121+
fi
122+
123+
# Verify the server is actually responding
124+
echo "Checking if server is responding..."
125+
sleep 5
126+
if ! curl -s http://localhost:8080 > /dev/null 2>&1; then
127+
echo "Warning: Server may not be fully ready, but container is running"
128+
docker compose logs --tail=20 || docker-compose logs --tail=20
129+
fi
130+
131+
echo "✓ Fuzzing server started successfully"
132+
- name: Flash ESP32 Testee
133+
working-directory: ${{ env.TESTEE_DIR }}/build
134+
env:
135+
IDF_TARGET: ${{ matrix.idf_target }}
136+
run: |
137+
python -m esptool --chip ${{ matrix.idf_target }} write_flash 0x0 flash_image.bin
138+
- name: Run Autobahn Tests
139+
working-directory: ${{ env.TESTEE_DIR }}
140+
env:
141+
PIP_EXTRA_INDEX_URL: "https://www.piwheels.org/simple"
142+
run: |
143+
# Detect ESP32 port if not set in environment
144+
if [ -z "${ESP_PORT:-}" ]; then
145+
for port in /dev/ttyUSB* /dev/ttyACM*; do
146+
if [ -e "$port" ]; then
147+
export ESP_PORT="$port"
148+
echo "Detected ESP32 port: $ESP_PORT"
149+
break
150+
fi
151+
done
152+
fi
153+
154+
# Default to /dev/ttyUSB0 if still not found
155+
export ESP_PORT="${ESP_PORT:-/dev/ttyUSB0}"
156+
157+
if [ ! -e "$ESP_PORT" ]; then
158+
echo "Error: ESP32 port not found. Please set ESP_PORT environment variable."
159+
echo "Available ports:"
160+
ls -la /dev/tty* || true
161+
exit 1
162+
fi
163+
164+
echo "Using ESP32 port: $ESP_PORT"
165+
export PYENV_ROOT="$HOME/.pyenv"
166+
export PATH="$PYENV_ROOT/bin:$PATH"
167+
eval "$(pyenv init --path)"
168+
eval "$(pyenv init -)"
169+
if ! pyenv versions --bare | grep -q '^3\.12\.6$'; then
170+
echo "Installing Python 3.12.6..."
171+
pyenv install -s 3.12.6
172+
fi
173+
if ! pyenv virtualenvs --bare | grep -q '^myenv$'; then
174+
echo "Creating pyenv virtualenv 'myenv'..."
175+
pyenv virtualenv 3.12.6 myenv
176+
fi
177+
pyenv activate myenv
178+
python --version
179+
pip install --prefer-binary pytest-embedded pytest-embedded-serial-esp pytest-embedded-idf pytest-custom_exit_code esptool pyserial
180+
pip install --extra-index-url https://dl.espressif.com/pypi/ -r $GITHUB_WORKSPACE/ci/requirements.txt
181+
182+
echo "Starting Autobahn test suite on ESP32..."
183+
echo "Tests may take 15-30 minutes to complete..."
184+
185+
# Send server URI via serial (stdin) and monitor for completion
186+
# Script is in the parent directory (TEST_DIR) from TESTEE_DIR
187+
SERVER_URI="ws://$HOST_IP:9001"
188+
echo "Sending server URI to ESP32: $SERVER_URI"
189+
python3 ../scripts/monitor_serial.py --port "$ESP_PORT" --uri "$SERVER_URI" --timeout 2400
190+
- name: Collect Test Reports
191+
working-directory: ${{ env.TEST_DIR }}
192+
if: always()
193+
run: |
194+
# Stop the fuzzing server
195+
docker compose down || docker-compose down
196+
197+
# Check if reports were generated
198+
if [ -d "reports/clients" ]; then
199+
echo "✓ Test reports found"
200+
ls -la reports/clients/
201+
else
202+
echo "⚠ No test reports found in reports/clients/"
203+
fi
204+
- name: Generate Test Summary
205+
working-directory: ${{ env.TEST_DIR }}
206+
if: always()
207+
run: |
208+
# Generate summary from test results
209+
# Check for JSON files in both reports/ and reports/clients/
210+
if [ -d "reports" ] && ( [ -n "$(ls -A reports/*.json 2>/dev/null)" ] || [ -n "$(ls -A reports/clients/*.json 2>/dev/null)" ] ); then
211+
echo "Generating test summary..."
212+
python3 scripts/generate_summary.py
213+
echo ""
214+
echo "Summary generated successfully!"
215+
if [ -f "reports/summary.html" ]; then
216+
echo "HTML summary available at: reports/summary.html"
217+
fi
218+
else
219+
echo "⚠ No JSON test results found, skipping summary generation"
220+
fi
221+
- uses: actions/upload-artifact@v4
222+
if: always()
223+
with:
224+
name: autobahn_reports_${{ matrix.idf_target }}_${{ matrix.idf_ver }}
225+
path: |
226+
${{ env.TEST_DIR }}/reports/**
227+
if-no-files-found: warn
228+

components/esp_websocket_client/esp_websocket_client.c

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,29 @@ static esp_err_t esp_websocket_client_dispatch_event(esp_websocket_client_handle
241241
return esp_event_loop_run(client->event_handle, 0);
242242
}
243243

244+
/**
245+
* @brief Abort the WebSocket connection and initiate reconnection or shutdown
246+
*
247+
* @param client WebSocket client handle
248+
* @param error_type Type of error that caused the abort
249+
*
250+
* @return ESP_OK on success, ESP_FAIL on failure
251+
*
252+
* @note PRECONDITION: client->lock MUST be held by the calling thread before calling this function.
253+
* This function does NOT acquire the lock itself. Calling without the lock will result in
254+
* race conditions and undefined behavior.
255+
*/
244256
static esp_err_t esp_websocket_client_abort_connection(esp_websocket_client_handle_t client, esp_websocket_error_type_t error_type)
245257
{
246258
ESP_WS_CLIENT_STATE_CHECK(TAG, client, return ESP_FAIL);
259+
260+
261+
if (client->state == WEBSOCKET_STATE_CLOSING || client->state == WEBSOCKET_STATE_UNKNOW ||
262+
client->state == WEBSOCKET_STATE_WAIT_TIMEOUT) {
263+
ESP_LOGW(TAG, "Connection already closing/closed, skipping abort");
264+
return ESP_OK;
265+
}
266+
247267
esp_transport_close(client->transport);
248268

249269
if (!client->config->auto_reconnect) {
@@ -256,6 +276,17 @@ static esp_err_t esp_websocket_client_abort_connection(esp_websocket_client_hand
256276
}
257277
client->error_handle.error_type = error_type;
258278
esp_websocket_client_dispatch_event(client, WEBSOCKET_EVENT_DISCONNECTED, NULL, 0);
279+
280+
if (client->errormsg_buffer) {
281+
ESP_LOGD(TAG, "Freeing error buffer (%d bytes) - Free heap: %" PRIu32 " bytes",
282+
client->errormsg_size, esp_get_free_heap_size());
283+
free(client->errormsg_buffer);
284+
client->errormsg_buffer = NULL;
285+
client->errormsg_size = 0;
286+
} else {
287+
ESP_LOGD(TAG, "Disconnect - Free heap: %" PRIu32 " bytes", esp_get_free_heap_size());
288+
}
289+
259290
return ESP_OK;
260291
}
261292

@@ -453,6 +484,8 @@ static void destroy_and_free_resources(esp_websocket_client_handle_t client)
453484
esp_websocket_client_destroy_config(client);
454485
if (client->transport_list) {
455486
esp_transport_list_destroy(client->transport_list);
487+
client->transport_list = NULL;
488+
client->transport = NULL;
456489
}
457490
vSemaphoreDelete(client->lock);
458491
#ifdef CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK
@@ -679,8 +712,18 @@ static int esp_websocket_client_send_with_exact_opcode(esp_websocket_client_hand
679712
} else {
680713
esp_websocket_client_error(client, "esp_transport_write() returned %d, errno=%d", ret, errno);
681714
}
715+
ESP_LOGD(TAG, "Calling abort_connection due to send error");
716+
#ifdef CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK
717+
xSemaphoreGiveRecursive(client->tx_lock);
718+
xSemaphoreTakeRecursive(client->lock, portMAX_DELAY);
719+
esp_websocket_client_abort_connection(client, WEBSOCKET_ERROR_TYPE_TCP_TRANSPORT);
720+
xSemaphoreGiveRecursive(client->lock);
721+
return ret;
722+
#else
723+
// Already holding client->lock, safe to call
682724
esp_websocket_client_abort_connection(client, WEBSOCKET_ERROR_TYPE_TCP_TRANSPORT);
683725
goto unlock_and_return;
726+
#endif
684727
}
685728
opcode = 0;
686729
widx += wlen;
@@ -1019,7 +1062,6 @@ static esp_err_t esp_websocket_client_recv(esp_websocket_client_handle_t client)
10191062
esp_websocket_free_buf(client, false);
10201063
return ESP_OK;
10211064
}
1022-
10231065
esp_websocket_client_dispatch_event(client, WEBSOCKET_EVENT_DATA, client->rx_buffer, rlen);
10241066

10251067
client->payload_offset += rlen;
@@ -1030,15 +1072,35 @@ static esp_err_t esp_websocket_client_recv(esp_websocket_client_handle_t client)
10301072
const char *data = (client->payload_len == 0) ? NULL : client->rx_buffer;
10311073
ESP_LOGD(TAG, "Sending PONG with payload len=%d", client->payload_len);
10321074
#ifdef CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK
1075+
xSemaphoreGiveRecursive(client->lock); // Release client->lock
1076+
1077+
// Now acquire tx_lock with timeout (consistent with PING/CLOSE handling)
10331078
if (xSemaphoreTakeRecursive(client->tx_lock, WEBSOCKET_TX_LOCK_TIMEOUT_MS) != pdPASS) {
1034-
ESP_LOGE(TAG, "Could not lock ws-client within %d timeout", WEBSOCKET_TX_LOCK_TIMEOUT_MS);
1035-
return ESP_FAIL;
1079+
ESP_LOGE(TAG, "Could not lock ws-client within %d timeout for PONG", WEBSOCKET_TX_LOCK_TIMEOUT_MS);
1080+
xSemaphoreTakeRecursive(client->lock, portMAX_DELAY); // Re-acquire client->lock before returning
1081+
esp_websocket_free_buf(client, false); // Free rx_buffer to prevent memory leak
1082+
return ESP_OK; // Return gracefully, caller expects client->lock to be held
10361083
}
1037-
#endif
1084+
1085+
// Re-acquire client->lock to maintain consistency
1086+
xSemaphoreTakeRecursive(client->lock, portMAX_DELAY);
1087+
1088+
1089+
// Another thread may have closed it while we didn't hold client->lock
1090+
if (client->state == WEBSOCKET_STATE_CLOSING || client->state == WEBSOCKET_STATE_UNKNOW ||
1091+
client->state == WEBSOCKET_STATE_WAIT_TIMEOUT || client->transport == NULL) {
1092+
ESP_LOGW(TAG, "Transport closed while preparing PONG, skipping send");
1093+
xSemaphoreGiveRecursive(client->tx_lock);
1094+
esp_websocket_free_buf(client, false); // Free rx_buffer to prevent memory leak
1095+
return ESP_OK; // Caller expects client->lock to be held, which it is
1096+
}
1097+
10381098
esp_transport_ws_send_raw(client->transport, WS_TRANSPORT_OPCODES_PONG | WS_TRANSPORT_OPCODES_FIN, data, client->payload_len,
10391099
client->config->network_timeout_ms);
1040-
#ifdef CONFIG_ESP_WS_CLIENT_SEPARATE_TX_LOCK
10411100
xSemaphoreGiveRecursive(client->tx_lock);
1101+
#else
1102+
esp_transport_ws_send_raw(client->transport, WS_TRANSPORT_OPCODES_PONG | WS_TRANSPORT_OPCODES_FIN, data, client->payload_len,
1103+
client->config->network_timeout_ms);
10421104
#endif
10431105
} else if (client->last_opcode == WS_TRANSPORT_OPCODES_PONG) {
10441106
client->wait_for_pong_resp = false;
@@ -1136,7 +1198,20 @@ static void esp_websocket_client_task(void *pv)
11361198
client->state = WEBSOCKET_STATE_CONNECTED;
11371199
client->wait_for_pong_resp = false;
11381200
client->error_handle.error_type = WEBSOCKET_ERROR_TYPE_NONE;
1201+
client->payload_len = 0;
1202+
client->payload_offset = 0;
1203+
client->last_fin = false;
1204+
client->last_opcode = WS_TRANSPORT_OPCODES_NONE;
1205+
11391206
esp_websocket_client_dispatch_event(client, WEBSOCKET_EVENT_CONNECTED, NULL, 0);
1207+
// Check for any data that may have arrived during handshake
1208+
int immediate_poll = esp_transport_poll_read(client->transport, 0); // Non-blocking
1209+
if (immediate_poll >= 0) {
1210+
esp_err_t recv_result = esp_websocket_client_recv(client);
1211+
if (recv_result == ESP_OK) {
1212+
esp_event_loop_run(client->event_handle, 0);
1213+
}
1214+
}
11401215
break;
11411216
case WEBSOCKET_STATE_CONNECTED:
11421217
if ((CLOSE_FRAME_SENT_BIT & xEventGroupGetBits(client->status_bits)) == 0) { // only send and check for PING
@@ -1214,12 +1289,13 @@ static void esp_websocket_client_task(void *pv)
12141289
esp_websocket_client_abort_connection(client, WEBSOCKET_ERROR_TYPE_TCP_TRANSPORT);
12151290
xSemaphoreGiveRecursive(client->lock);
12161291
} else if (read_select > 0) {
1292+
xSemaphoreTakeRecursive(client->lock, lock_timeout);
12171293
if (esp_websocket_client_recv(client) == ESP_FAIL) {
12181294
ESP_LOGE(TAG, "Error receive data");
1219-
xSemaphoreTakeRecursive(client->lock, lock_timeout);
1295+
// Note: Already holding client->lock from line above
12201296
esp_websocket_client_abort_connection(client, WEBSOCKET_ERROR_TYPE_TCP_TRANSPORT);
1221-
xSemaphoreGiveRecursive(client->lock);
12221297
}
1298+
xSemaphoreGiveRecursive(client->lock);
12231299
} else {
12241300
ESP_LOGV(TAG, "Read poll timeout: skipping esp_transport_poll_read().");
12251301
}

0 commit comments

Comments
 (0)