diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml new file mode 100644 index 0000000..320b8bc --- /dev/null +++ b/.github/workflows/auto-tag.yml @@ -0,0 +1,71 @@ +name: Auto Tag on Version Bump + +on: + push: + branches: [main] + paths: + - 'library.json' + - 'library.properties' + - 'src/HttpCommon.h' + +permissions: + contents: write + +jobs: + check-and-tag: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history needed for tag comparison + + - name: Extract version from library.json + id: version + run: | + VERSION=$(grep -m1 '"version"' library.json | sed -E 's/.*"version" *: *"([^"]+)".*/\1/') + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "Detected version: ${VERSION}" + + - name: Verify version consistency across files + run: | + VERSION="${{ steps.version.outputs.version }}" + V_PROP=$(grep '^version=' library.properties | cut -d'=' -f2) + V_HDR=$(grep '#define ESP_ASYNC_WEB_CLIENT_VERSION' src/HttpCommon.h | sed -E 's/.*"([^"]+)".*/\1/') + + MISMATCH=0 + if [[ "${VERSION}" != "${V_PROP}" ]]; then + echo "::error::Version mismatch: library.json=${VERSION} vs library.properties=${V_PROP}" + MISMATCH=1 + fi + if [[ "${VERSION}" != "${V_HDR}" ]]; then + echo "::error::Version mismatch: library.json=${VERSION} vs HttpCommon.h=${V_HDR}" + MISMATCH=1 + fi + if [[ $MISMATCH -ne 0 ]]; then + echo "::error::Fix version mismatches before tagging. Use: scripts/sync-version.sh " + exit 1 + fi + echo "All 3 version sources agree: ${VERSION}" + + - name: Check if tag already exists + id: tag_check + run: | + TAG="v${{ steps.version.outputs.version }}" + if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag ${TAG} already exists — skipping." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Tag ${TAG} does not exist — will create." + fi + + - name: Create and push tag + if: steps.tag_check.outputs.exists == 'false' + run: | + TAG="v${{ steps.version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + echo "::notice::Created and pushed tag ${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9acc3e5..5a8a24d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history for changelog generation - name: Set up Python uses: actions/setup-python@v5 @@ -59,16 +61,100 @@ jobs: name: source-archive path: ${{ env.ARCHIVE }} + - name: Extract changelog for this version + id: changelog + run: | + VERSION=$(grep '"version"' library.json | sed -E 's/.*"version" *: *"([^"]+)".*/\1/') + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + # Extract the section for this version from CHANGELOG.md + # Matches everything between "## [X.Y.Z]" and the next "## [" heading + NOTES="" + if [[ -f CHANGELOG.md ]]; then + NOTES=$(awk -v ver="${VERSION}" ' + BEGIN { found=0 } + /^## \[/ { + if (found) exit + if ($0 ~ "\\[" ver "\\]") { found=1; next } + } + found { print } + ' CHANGELOG.md) + fi + + if [[ -z "${NOTES}" ]]; then + echo "No CHANGELOG entry found for ${VERSION}, generating from commits..." + # Get the previous tag + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [[ -n "${PREV_TAG}" ]]; then + NOTES=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges) + else + NOTES=$(git log --pretty=format:"- %s (%h)" --no-merges -20) + fi + fi + + # Write to file to preserve multiline + echo "${NOTES}" > /tmp/release_notes.md + + - name: Generate AI-style release summary + id: summary + run: | + VERSION="${{ steps.changelog.outputs.version }}" + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + + # Build a structured release body + { + echo "# ESPAsyncWebClient v${VERSION}" + echo "" + + # Changelog section + echo "## What's Changed" + cat /tmp/release_notes.md + echo "" + + # Auto-generated commit summary by category + if [[ -n "${PREV_TAG}" ]]; then + echo "## Commit Summary" + echo "" + + # Fixes + FIXES=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges --grep="^[Ff]ix" || true) + if [[ -n "${FIXES}" ]]; then + echo "### Bug Fixes" + echo "${FIXES}" + echo "" + fi + + # Features + FEATURES=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges --grep="^[Ff]eat" || true) + if [[ -n "${FEATURES}" ]]; then + echo "### New Features" + echo "${FEATURES}" + echo "" + fi + + # Other commits + OTHER=$(git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges --invert-grep --grep="^[Ff]ix" --grep="^[Ff]eat" || true) + if [[ -n "${OTHER}" ]]; then + echo "### Other Changes" + echo "${OTHER}" + echo "" + fi + + echo "**Full diff**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...v${VERSION}" + echo "" + fi + + # Stats + echo "## Verification" + echo "- :white_check_mark: All examples built successfully" + echo "- :white_check_mark: Version metadata consistent across library.json, library.properties, and HttpCommon.h" + echo "- :package: Source archive attached" + } > /tmp/full_release_notes.md + - name: Create Release uses: softprops/action-gh-release@v2 with: files: ${{ env.ARCHIVE }} - name: Release ${{ github.ref }} - body: | - ## Changes in this release - See the Git diff for this tag or the Releases page for details. - - ## Verification - - Examples built successfully - - Version metadata consistent - - Source archive attached + name: "v${{ steps.changelog.outputs.version }}" + body_path: /tmp/full_release_notes.md + generate_release_notes: true diff --git a/library.json b/library.json index 8eacfd9..f7afee7 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "ESPAsyncWebClient", - "version": "2.1.0", + "version": "2.1.1", "description": "Asynchronous HTTP client library for ESP32 ", "keywords": [ "http", diff --git a/library.properties b/library.properties index 014bd26..adbe814 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=ESPAsyncWebClient -version=2.1.0 +version=2.1.1 author=playmiel maintainer=playmiel sentence=Asynchronous HTTP client library for ESP32 microcontrollers diff --git a/scripts/sync-version.sh b/scripts/sync-version.sh new file mode 100644 index 0000000..06bb547 --- /dev/null +++ b/scripts/sync-version.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# sync-version.sh — Update version in all 3 source-of-truth files at once. +# Usage: ./scripts/sync-version.sh 2.2.0 +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " + echo "Example: $0 2.2.0" + exit 1 +fi + +NEW_VERSION="$1" + +# Validate semver format +if [[ ! "${NEW_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: '${NEW_VERSION}' is not a valid semver (expected X.Y.Z)" + exit 1 +fi + +echo "Syncing version to ${NEW_VERSION} across all files..." + +# 1. library.json +if [[ -f library.json ]]; then + sed -i -E "s/\"version\" *: *\"[^\"]+\"/\"version\": \"${NEW_VERSION}\"/" library.json + echo " ✓ library.json" +else + echo " ✗ library.json not found" +fi + +# 2. library.properties +if [[ -f library.properties ]]; then + sed -i -E "s/^version=.*/version=${NEW_VERSION}/" library.properties + echo " ✓ library.properties" +else + echo " ✗ library.properties not found" +fi + +# 3. src/HttpCommon.h +if [[ -f src/HttpCommon.h ]]; then + sed -i -E "s/#define ESP_ASYNC_WEB_CLIENT_VERSION \"[^\"]+\"/#define ESP_ASYNC_WEB_CLIENT_VERSION \"${NEW_VERSION}\"/" src/HttpCommon.h + echo " ✓ src/HttpCommon.h" +else + echo " ✗ src/HttpCommon.h not found" +fi + +echo "" +echo "Done! Version is now ${NEW_VERSION} everywhere." +echo "" +echo "Next steps:" +echo " 1. Update CHANGELOG.md with your changes under [${NEW_VERSION}]" +echo " 2. Commit and push to main" +echo " 3. The auto-tag workflow will create the tag v${NEW_VERSION} automatically" +echo " 4. The release workflow will generate the release with auto-generated notes" diff --git a/src/CookieJar.cpp b/src/AsyncCookieJar.cpp similarity index 90% rename from src/CookieJar.cpp rename to src/AsyncCookieJar.cpp index 612d964..53dbb0a 100644 --- a/src/CookieJar.cpp +++ b/src/AsyncCookieJar.cpp @@ -1,4 +1,4 @@ -#include "CookieJar.h" +#include "AsyncCookieJar.h" #include #include #include "AsyncHttpClient.h" @@ -17,31 +17,31 @@ static uint8_t countDomainDots(const String& domain) { return dots; } -CookieJar::CookieJar(AsyncHttpClient* client) : _client(client) {} +AsyncCookieJar::AsyncCookieJar(AsyncHttpClient* client) : _client(client) {} -void CookieJar::lock() const { +void AsyncCookieJar::lock() const { if (_client) _client->lock(); } -void CookieJar::unlock() const { +void AsyncCookieJar::unlock() const { if (_client) _client->unlock(); } -void CookieJar::clearCookies() { +void AsyncCookieJar::clearCookies() { lock(); _cookies.clear(); unlock(); } -void CookieJar::setAllowCookieDomainAttribute(bool enable) { +void AsyncCookieJar::setAllowCookieDomainAttribute(bool enable) { lock(); _allowCookieDomainAttribute = enable; unlock(); } -void CookieJar::addAllowedCookieDomain(const char* domain) { +void AsyncCookieJar::addAllowedCookieDomain(const char* domain) { if (!domain || strlen(domain) == 0) return; String normalized = normalizeDomainForStorage(String(domain)); @@ -60,13 +60,13 @@ void CookieJar::addAllowedCookieDomain(const char* domain) { unlock(); } -void CookieJar::clearAllowedCookieDomains() { +void AsyncCookieJar::clearAllowedCookieDomains() { lock(); _allowedCookieDomains.clear(); unlock(); } -void CookieJar::setCookie(const char* name, const char* value, const char* path, const char* domain, bool secure) { +void AsyncCookieJar::setCookie(const char* name, const char* value, const char* path, const char* domain, bool secure) { if (!name || strlen(name) == 0) return; if (!isValidHttpHeaderValue(String(name))) @@ -108,7 +108,7 @@ void CookieJar::setCookie(const char* name, const char* value, const char* path, unlock(); } -bool CookieJar::isIpLiteral(const String& host) const { +bool AsyncCookieJar::isIpLiteral(const String& host) const { if (host.length() == 0) return false; bool hasColon = false; @@ -131,7 +131,7 @@ bool CookieJar::isIpLiteral(const String& host) const { return hasColon || hasDot; } -bool CookieJar::normalizeCookieDomain(String& domain, const String& host, bool domainAttributeProvided, +bool AsyncCookieJar::normalizeCookieDomain(String& domain, const String& host, bool domainAttributeProvided, bool* outHostOnly) const { if (outHostOnly) *outHostOnly = true; @@ -186,7 +186,7 @@ bool CookieJar::normalizeCookieDomain(String& domain, const String& host, bool d return true; } -bool CookieJar::domainMatches(const String& cookieDomain, const String& host) const { +bool AsyncCookieJar::domainMatches(const String& cookieDomain, const String& host) const { if (cookieDomain.length() == 0) return true; if (host.equalsIgnoreCase(cookieDomain)) @@ -199,7 +199,7 @@ bool CookieJar::domainMatches(const String& cookieDomain, const String& host) co return host.substring(offset).equalsIgnoreCase(cookieDomain); } -bool CookieJar::pathMatches(const String& cookiePath, const String& requestPath) const { +bool AsyncCookieJar::pathMatches(const String& cookiePath, const String& requestPath) const { String req = requestPath; int q = req.indexOf('?'); if (q != -1) @@ -218,7 +218,7 @@ bool CookieJar::pathMatches(const String& cookiePath, const String& requestPath) return req.length() > cpath.length() && req.charAt(cpath.length()) == '/'; } -bool CookieJar::cookieMatchesRequest(const StoredCookie& cookie, const AsyncHttpRequest* request, +bool AsyncCookieJar::cookieMatchesRequest(const StoredCookie& cookie, const AsyncHttpRequest* request, int64_t nowSeconds) const { if (!request) return false; @@ -238,11 +238,11 @@ bool CookieJar::cookieMatchesRequest(const StoredCookie& cookie, const AsyncHttp return !cookie.value.isEmpty(); } -bool CookieJar::isCookieExpired(const StoredCookie& cookie, int64_t nowSeconds) const { +bool AsyncCookieJar::isCookieExpired(const StoredCookie& cookie, int64_t nowSeconds) const { return cookie.expiresAt != -1 && nowSeconds >= cookie.expiresAt; } -void CookieJar::purgeExpiredCookies(int64_t nowSeconds) { +void AsyncCookieJar::purgeExpiredCookies(int64_t nowSeconds) { for (auto it = _cookies.begin(); it != _cookies.end();) { if (isCookieExpired(*it, nowSeconds)) { it = _cookies.erase(it); @@ -252,7 +252,7 @@ void CookieJar::purgeExpiredCookies(int64_t nowSeconds) { } } -void CookieJar::evictOneCookieLocked() { +void AsyncCookieJar::evictOneCookieLocked() { if (_cookies.empty()) return; size_t bestIndex = 0; @@ -303,7 +303,7 @@ void CookieJar::evictOneCookieLocked() { _cookies.erase(_cookies.begin() + static_cast::difference_type>(bestIndex)); } -void CookieJar::applyCookies(AsyncHttpRequest* request) { +void AsyncCookieJar::applyCookies(AsyncHttpRequest* request) { if (!request) return; int64_t now = currentTimeSeconds(); @@ -343,7 +343,7 @@ void CookieJar::applyCookies(AsyncHttpRequest* request) { } } -void CookieJar::storeResponseCookie(const AsyncHttpRequest* request, const String& setCookieValue) { +void AsyncCookieJar::storeResponseCookie(const AsyncHttpRequest* request, const String& setCookieValue) { if (!request) return; String raw = setCookieValue; diff --git a/src/CookieJar.h b/src/AsyncCookieJar.h similarity index 91% rename from src/CookieJar.h rename to src/AsyncCookieJar.h index d1abb8e..0a9d14f 100644 --- a/src/CookieJar.h +++ b/src/AsyncCookieJar.h @@ -1,5 +1,5 @@ -#ifndef COOKIE_JAR_H -#define COOKIE_JAR_H +#ifndef ASYNC_COOKIE_JAR_H +#define ASYNC_COOKIE_JAR_H #include #include @@ -7,9 +7,9 @@ class AsyncHttpClient; -class CookieJar { +class AsyncCookieJar { public: - explicit CookieJar(AsyncHttpClient* client); + explicit AsyncCookieJar(AsyncHttpClient* client); void clearCookies(); void setAllowCookieDomainAttribute(bool enable); @@ -51,4 +51,4 @@ class CookieJar { std::vector _cookies; }; -#endif // COOKIE_JAR_H +#endif // ASYNC_COOKIE_JAR_H diff --git a/src/AsyncHttpClient.cpp b/src/AsyncHttpClient.cpp index d170416..95b203c 100644 --- a/src/AsyncHttpClient.cpp +++ b/src/AsyncHttpClient.cpp @@ -10,7 +10,7 @@ #include #include #include "ConnectionPool.h" -#include "CookieJar.h" +#include "AsyncCookieJar.h" #include "HttpHelpers.h" #include "RedirectHandler.h" @@ -24,7 +24,7 @@ AsyncHttpClient::AsyncHttpClient() : _defaultTimeout(10000), _defaultUserAgent(String("ESPAsyncWebClient/") + ESP_ASYNC_WEB_CLIENT_VERSION), _bodyChunkCallback(nullptr), _maxBodySize(kDefaultMaxBodyBytes), _followRedirects(false), _maxRedirectHops(3), _maxHeaderBytes(kDefaultMaxHeaderBytes) { - _cookieJar.reset(new CookieJar(this)); + _cookieJar.reset(new AsyncCookieJar(this)); _connectionPool.reset(new ConnectionPool(this)); _redirectHandler.reset(new RedirectHandler(this)); #if defined(ARDUINO_ARCH_ESP32) && defined(ASYNC_HTTP_ENABLE_AUTOLOOP) @@ -35,7 +35,7 @@ AsyncHttpClient::AsyncHttpClient() // Optional: spawn a lightweight auto-loop task so users don't need to call client.loop() manually. xTaskCreatePinnedToCore(_autoLoopTaskThunk, // task entry "AsyncHttpAutoLoop", // name - 2048, // stack words + 4096, // stack words this, // parameter 1, // priority (low) &_autoLoopTaskHandle, // handle out @@ -1038,7 +1038,10 @@ void AsyncHttpClient::cleanup(RequestContext* context) { toDelete->close(); delete toDelete; } - tryDequeue(); + // Guard against recursion: cleanup → tryDequeue → executeRequest → triggerError → cleanup → tryDequeue + // The outer tryDequeue's while-loop will handle remaining pending requests. + if (!_inTryDequeue) + tryDequeue(); } void AsyncHttpClient::triggerError(RequestContext* context, HttpClientError errorCode, const char* errorMessage) { @@ -1064,6 +1067,7 @@ void AsyncHttpClient::loop() { unlock(); triggerError(ctx, REQUEST_TIMEOUT, "Request timeout"); lock(); + continue; // ctx may be freed; re-read at current index } #endif if (!ctx->cancelled.load() && !ctx->responseProcessed && ctx->transport && !ctx->headersSent && @@ -1071,6 +1075,7 @@ void AsyncHttpClient::loop() { unlock(); triggerError(ctx, CONNECT_TIMEOUT, "Connect timeout"); lock(); + continue; // ctx may be freed; re-read at current index } if (!ctx->cancelled.load() && !ctx->responseProcessed && ctx->transport && ctx->transport->isHandshaking()) { uint32_t hsTimeout = ctx->transport->getHandshakeTimeoutMs(); @@ -1079,6 +1084,7 @@ void AsyncHttpClient::loop() { unlock(); triggerError(ctx, TLS_HANDSHAKE_TIMEOUT, "TLS handshake timeout"); lock(); + continue; // ctx may be freed; re-read at current index } } if (!ctx->cancelled.load() && !ctx->responseProcessed && ctx->streamingBodyInProgress && @@ -1097,6 +1103,9 @@ void AsyncHttpClient::loop() { } void AsyncHttpClient::tryDequeue() { + if (_inTryDequeue) + return; // prevent recursion via executeRequest → triggerError → cleanup → tryDequeue + _inTryDequeue = true; while (true) { lock(); bool canStart = (_maxParallel == 0 || _activeRequests.size() < _maxParallel); @@ -1110,6 +1119,7 @@ void AsyncHttpClient::tryDequeue() { unlock(); executeRequest(ctx); } + _inTryDequeue = false; } void AsyncHttpClient::sendStreamData(RequestContext* context) { diff --git a/src/AsyncHttpClient.h b/src/AsyncHttpClient.h index a91c3d6..c94cf7e 100644 --- a/src/AsyncHttpClient.h +++ b/src/AsyncHttpClient.h @@ -25,7 +25,7 @@ #include #endif -class CookieJar; +class AsyncCookieJar; class ConnectionPool; class RedirectHandler; @@ -111,7 +111,7 @@ class AsyncHttpClient { void loop(); // manual timeout / queue progression private: - friend class CookieJar; + friend class AsyncCookieJar; friend class ConnectionPool; friend class RedirectHandler; @@ -200,7 +200,8 @@ class AsyncHttpClient { AsyncHttpTLSConfig _defaultTlsConfig; bool _keepAliveEnabled = false; uint32_t _keepAliveIdleMs = 5000; - std::unique_ptr _cookieJar; + bool _inTryDequeue = false; // reentrancy guard to prevent cleanup → tryDequeue recursion + std::unique_ptr _cookieJar; std::unique_ptr _connectionPool; std::unique_ptr _redirectHandler; diff --git a/src/HttpCommon.h b/src/HttpCommon.h index 8dca9aa..4ce0ba0 100644 --- a/src/HttpCommon.h +++ b/src/HttpCommon.h @@ -17,7 +17,7 @@ // Library version (single source of truth inside code). Keep in sync with library.json and library.properties. #ifndef ESP_ASYNC_WEB_CLIENT_VERSION -#define ESP_ASYNC_WEB_CLIENT_VERSION "2.1.0" +#define ESP_ASYNC_WEB_CLIENT_VERSION "2.1.1" #endif struct HttpHeader { diff --git a/src/TcpTransport.cpp b/src/TcpTransport.cpp index a55894e..bda207b 100644 --- a/src/TcpTransport.cpp +++ b/src/TcpTransport.cpp @@ -65,8 +65,14 @@ class AsyncTcpTransport : public AsyncTransport { } void close(bool now = false) override { (void)now; - if (_client) + if (_client) { + _client->onConnect(nullptr, nullptr); + _client->onData(nullptr, nullptr); + _client->onDisconnect(nullptr, nullptr); + _client->onError(nullptr, nullptr); + _client->onTimeout(nullptr, nullptr); _client->close(); + } } bool isSecure() const override { return false; diff --git a/src/TlsTransport.cpp b/src/TlsTransport.cpp index a35d4f9..35719a2 100644 --- a/src/TlsTransport.cpp +++ b/src/TlsTransport.cpp @@ -429,6 +429,13 @@ bool AsyncTlsTransport::canSend() const { void AsyncTlsTransport::close(bool now) { (void)now; + if (_client) { + _client->onConnect(nullptr, nullptr); + _client->onData(nullptr, nullptr); + _client->onDisconnect(nullptr, nullptr); + _client->onError(nullptr, nullptr); + _client->onTimeout(nullptr, nullptr); + } if (_state == State::Established) { mbedtls_ssl_close_notify(&_ssl); } diff --git a/test/test_cookies/test_main.cpp b/test/test_cookies/test_main.cpp index 4b6519f..f139255 100644 --- a/test/test_cookies/test_main.cpp +++ b/test/test_cookies/test_main.cpp @@ -3,7 +3,7 @@ #define private public #include "AsyncHttpClient.h" -#include "CookieJar.h" +#include "AsyncCookieJar.h" #undef private static void test_domain_matching_subdomains() { diff --git a/test/test_redirects/test_main.cpp b/test/test_redirects/test_main.cpp index 6336ff6..37ceba1 100644 --- a/test/test_redirects/test_main.cpp +++ b/test/test_redirects/test_main.cpp @@ -4,7 +4,7 @@ #define private public #include "AsyncHttpClient.h" -#include "CookieJar.h" +#include "AsyncCookieJar.h" #include "RedirectHandler.h" #undef private