From e5410ab511eacee6259ba4b8e21a0176d3857191 Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:05:15 +0200 Subject: [PATCH 1/5] Add optional persistent per-worker HTTP clients --- .github/workflows/persistent-http.yml | 23 + docs/persistent-http.md | 75 ++ src/Link.h | 114 +- src/internal/LinkClientHttpEventsImpl.h | 245 ++++ src/internal/LinkClientHttpRequestImpl.h | 236 ++++ src/internal/LinkClientHttpSessionImpl.h | 289 +++++ src/internal/LinkClientImpl.h | 1140 +---------------- src/internal/LinkClientLifecycleRuntimeImpl.h | 238 ++++ src/internal/LinkClientLifecycleStartImpl.h | 266 ++++ src/internal/LinkClientSupportImpl.h | 248 ++++ tests/host/test_persistent.cpp | 85 ++ 11 files changed, 1804 insertions(+), 1155 deletions(-) create mode 100644 .github/workflows/persistent-http.yml create mode 100644 docs/persistent-http.md create mode 100644 src/internal/LinkClientHttpEventsImpl.h create mode 100644 src/internal/LinkClientHttpRequestImpl.h create mode 100644 src/internal/LinkClientHttpSessionImpl.h create mode 100644 src/internal/LinkClientLifecycleRuntimeImpl.h create mode 100644 src/internal/LinkClientLifecycleStartImpl.h create mode 100644 src/internal/LinkClientSupportImpl.h create mode 100644 tests/host/test_persistent.cpp diff --git a/.github/workflows/persistent-http.yml b/.github/workflows/persistent-http.yml new file mode 100644 index 0000000..e5238ad --- /dev/null +++ b/.github/workflows/persistent-http.yml @@ -0,0 +1,23 @@ +name: Persistent HTTP + +on: + push: + branches: [ '**' ] + pull_request: + workflow_dispatch: + +jobs: + host-logic: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and run persistent-client logic tests + run: | + g++ -std=c++20 -Wall -Wextra -pedantic \ + -Itests/host/stubs \ + -Isrc \ + tests/host/test_persistent.cpp \ + -o /tmp/link-persistent-host-tests + /tmp/link-persistent-host-tests diff --git a/docs/persistent-http.md b/docs/persistent-http.md new file mode 100644 index 0000000..6f9a9c6 --- /dev/null +++ b/docs/persistent-http.md @@ -0,0 +1,75 @@ +# Persistent HTTP clients + +Link normally creates and cleans up one ESP-IDF HTTP client for every request. This remains the default and preserves existing behavior. + +Applications that repeatedly call the same API can opt into one persistent HTTP client per Link worker: + +```cpp +LinkConfig config; +config.maxConcurrentRequests = 2; +config.connectionMode = LinkConnectionMode::PersistentPerWorker; +config.persistentIdleTimeoutMs = 5U * 60U * 1000U; +config.persistentMaxRequestsPerHandle = 0; + +Link client; +LinkResult result = client.init(config); +``` + +`persistentIdleTimeoutMs == 0` disables idle eviction. `persistentMaxRequestsPerHandle == 0` disables request-count eviction. + +## Ownership and concurrency + +A persistent handle belongs to exactly one worker. A worker processes one request at a time, so the handle is never used concurrently. The maximum number of retained HTTP clients is therefore bounded by `maxConcurrentRequests`. + +Link does not use a global lease pool and does not move handles between workers. + +## Same-origin reuse + +A worker reuses its handle only when the next URL has the same: + +- scheme +- case-insensitive host +- effective port + +Explicit default ports are normalized, so `https://example.com` and `https://EXAMPLE.com:443` are the same origin. + +A cross-origin redirect or later request replaces the worker's retained handle. Existing redirect policy still controls whether the origin change is allowed, and caller-supplied headers are stripped after an allowed cross-origin redirect. + +## Request isolation + +Before and after every transfer, Link resets request-specific state on the ESP-IDF handle. Caller-supplied headers and POST data are removed before the queued request storage is released. + +If setup, transfer, callback processing, response buffering, or state scrubbing fails, the retained handle is marked unusable and cleaned up. The failed operation is returned to the caller normally. + +Link never automatically replays a request. This prevents duplicate POST, PUT, PATCH, or DELETE operations. + +## Lifetime + +Persistent handles are cleaned up when: + +- their origin changes +- their idle timeout expires +- their configured request limit is reached +- a request poisons the handle +- the owning worker exits during Link shutdown + +Idle expiration is lazy: it is checked when the worker receives its next request. No maintenance task or timer is created. + +## Diagnostics + +`LinkClient::diagnostics()` returns a snapshot containing request, handle, eviction, and transport-event counters. + +Useful invariants are: + +```cpp +LinkDiagnostics diagnostics = client.diagnostics(); + +// While running: +assert(diagnostics.activeHttpClients <= config.maxConcurrentRequests); + +// After successful deinit(): +assert(diagnostics.activeHttpClients == 0); +assert(diagnostics.httpClientCreates == diagnostics.httpClientCleanups); +``` + +`httpClientReuses` counts reuse of an ESP-IDF client handle. Transport connection and disconnection events are tracked separately because a retained handle may reconnect its underlying socket. diff --git a/src/Link.h b/src/Link.h index da57253..e6b0305 100644 --- a/src/Link.h +++ b/src/Link.h @@ -57,6 +57,8 @@ enum class LinkState : uint8_t { Uninitialized, Starting, Running, Stopping }; enum class LinkStackType : uint8_t { Internal, Psram, Auto }; +enum class LinkConnectionMode : uint8_t { PerRequest, PersistentPerWorker }; + enum class LinkMethod : uint8_t { Get, Post, Put, Patch, Delete, Head }; enum class LinkResponseMode : uint8_t { Buffered, Stream }; @@ -101,6 +103,9 @@ struct LinkConfig { size_t maxConcurrentRequests = 3; uint32_t defaultTimeoutMs = 15000; + LinkConnectionMode connectionMode = LinkConnectionMode::PerRequest; + uint32_t persistentIdleTimeoutMs = 5U * 60U * 1000U; + uint32_t persistentMaxRequestsPerHandle = 0; size_t maxUrlSize = 512; size_t maxRequestBodySize = 8192; @@ -118,6 +123,22 @@ struct LinkConfig { uint8_t maxRedirects = 3; }; +struct LinkDiagnostics { + uint32_t requestsSubmitted = 0; + uint32_t requestsCompleted = 0; + uint32_t httpClientCreates = 0; + uint32_t httpClientReuses = 0; + uint32_t httpClientCleanups = 0; + uint32_t originReuseMisses = 0; + uint32_t idleEvictions = 0; + uint32_t requestLimitEvictions = 0; + uint32_t poisonedEvictions = 0; + uint32_t transportConnectedEvents = 0; + uint32_t transportDisconnectedEvents = 0; + size_t activeHttpClients = 0; + size_t peakHttpClients = 0; +}; + class LinkBody; class LinkBodyView; namespace link_internal { @@ -458,6 +479,7 @@ template class LinkClient { bool isInitialized() const; LinkState state() const; + LinkDiagnostics diagnostics() const; LinkResult fetch(const Request &request); @@ -476,8 +498,7 @@ template class LinkClient { return headerResult; } if (!request.onResponse.assign(std::forward(callback))) { - return LinkResult::error( - LinkErrorCode::CallbackTooLarge, + return LinkResult::error(LinkErrorCode::CallbackTooLarge, "response callback is too large" ); } @@ -493,7 +514,7 @@ template class LinkClient { template LinkResult post( const char *url, const LinkHeaders &headers, const LinkBodyView &body, Callback &&callback - ) { + ) { Request request; request.method = LinkMethod::Post; request.url = url; @@ -545,7 +566,7 @@ template class LinkClient { template LinkResult postJson( const char *url, const LinkHeaders &headers, const JsonDocument &json, Callback &&callback - ) { + ) { Request request; request.method = LinkMethod::Post; request.url = url; @@ -560,9 +581,10 @@ template class LinkClient { return acceptResult; } if (!request.headers.has("Content-Type")) { - LinkResult headerResult = request.headers.set("Content-Type", "application/json"); - if (!headerResult) { - return headerResult; + LinkResult contentTypeResult = + request.headers.set("Content-Type", "application/json"); + if (!contentTypeResult) { + return contentTypeResult; } } if (!request.onJsonResponse.assign(std::forward(callback))) { @@ -615,14 +637,6 @@ template class LinkClient { private: using QueuedRequest = link_internal::QueuedLinkRequest; - struct WorkerRecord { - LinkClient *owner = nullptr; - size_t index = 0; - TaskHandle_t handle = nullptr; - bool createdWithCaps = false; - bool active = false; - }; - #if defined(ESP32) struct HttpEventContext { LinkClient *owner = nullptr; @@ -638,7 +652,67 @@ template class LinkClient { size_t totalReceived = 0; }; + struct WorkerHttpSession { + esp_http_client_handle_t client = nullptr; + LinkOwnedBuffer originHost; + uint16_t originPort = 0; + bool originHttps = false; + uint32_t createdAtMs = 0; + uint32_t lastUsedAtMs = 0; + uint32_t requestCount = 0; + bool poisoned = false; + HttpEventContext eventContext; + }; +#endif + + struct WorkerRecord { + LinkClient *owner = nullptr; + size_t index = 0; + TaskHandle_t handle = nullptr; + bool createdWithCaps = false; + bool active = false; +#if defined(ESP32) + WorkerHttpSession http; +#endif + }; + +#if defined(ESP32) + enum class HttpSessionCleanupReason : uint8_t { + None, + OriginChanged, + IdleExpired, + RequestLimitReached, + Poisoned, + Shutdown + }; + static esp_err_t httpEventHandler(esp_http_client_event_t *event); + void resetHttpEventContext( + HttpEventContext &context, + QueuedRequest &request, + LinkResponse &response, + const char *currentUrl, + uint8_t redirectCount + ); + esp_http_client_handle_t createHttpClient( + HttpEventContext &context, const char *url, uint32_t timeoutMs + ); + LinkResult preparePersistentHttpClient( + WorkerRecord &worker, + const char *url, + uint32_t timeoutMs, + esp_http_client_handle_t &client + ); + void cleanupPersistentHttpClient(WorkerRecord &worker, HttpSessionCleanupReason reason); + void cleanupHttpClient(esp_http_client_handle_t client); + bool scrubHttpClientRequest( + esp_http_client_handle_t client, const LinkHeaders &headers, size_t appliedHeaderCount + ); + bool persistentSessionMatchesUrl(const WorkerHttpSession &session, const char *url) const; + void recordHttpClientCreated(); + void recordHttpClientReused(); + void recordTransportConnected(); + void recordTransportDisconnected(); #endif static void taskEntry(void *arg); @@ -646,7 +720,7 @@ template class LinkClient { bool popRequest(size_t &slotIndex); void releaseSlot(size_t slotIndex); void invokeCancelled(QueuedRequest &request); - void processRequest(QueuedRequest &request); + void processRequest(WorkerRecord &worker, QueuedRequest &request); LinkResult validateConfig(const LinkConfig &config) const; bool shouldUsePsramStack() const; LinkResult addJsonAccept(LinkHeaders &headers) const; @@ -656,15 +730,13 @@ template class LinkClient { void wakeWorkers(); LinkResult waitForWorkers(bool waitForever); LinkResult freeRuntimeStorage(); + void recordRequestCompleted(); -#if defined(ESP32) - void performHttpRequest(QueuedRequest &request); -#else - void performHttpRequest(QueuedRequest &request); -#endif + void performHttpRequest(WorkerRecord &worker, QueuedRequest &request); mutable LinkMutex _mutex; LinkConfig _config{}; + LinkDiagnostics _diagnostics{}; // When deinit() times out, Stopping means workers may still own slots. // Do not free task-owned storage until all workers become inactive. LinkState _state = LinkState::Uninitialized; diff --git a/src/internal/LinkClientHttpEventsImpl.h b/src/internal/LinkClientHttpEventsImpl.h new file mode 100644 index 0000000..fcecef2 --- /dev/null +++ b/src/internal/LinkClientHttpEventsImpl.h @@ -0,0 +1,245 @@ +#if defined(ESP32) +template +LinkResult LinkClient::preparePersistentHttpClient( + WorkerRecord &worker, + const char *url, + uint32_t timeoutMs, + esp_http_client_handle_t &client +) { + WorkerHttpSession &session = worker.http; + const link_internal::LinkUrlOrigin origin = link_internal::linkParseOrigin(url); + if (!origin.valid) { + return LinkResult::error(LinkErrorCode::InvalidUrl, "url origin is invalid"); + } + + const bool sameOrigin = persistentSessionMatchesUrl(session, url); + const uint32_t nowMs = millis(); + const link_internal::LinkPersistentReuseDecision decision = + link_internal::linkEvaluatePersistentReuse( + session.client != nullptr, + session.poisoned, + sameOrigin, + nowMs, + session.lastUsedAtMs, + _config.persistentIdleTimeoutMs, + session.requestCount, + _config.persistentMaxRequestsPerHandle + ); + + switch (decision) { + case link_internal::LinkPersistentReuseDecision::Reuse: + client = session.client; + recordHttpClientReused(); + return LinkResult::ok(); + case link_internal::LinkPersistentReuseDecision::OriginChanged: + cleanupPersistentHttpClient(worker, HttpSessionCleanupReason::OriginChanged); + break; + case link_internal::LinkPersistentReuseDecision::IdleExpired: + cleanupPersistentHttpClient(worker, HttpSessionCleanupReason::IdleExpired); + break; + case link_internal::LinkPersistentReuseDecision::RequestLimitReached: + cleanupPersistentHttpClient(worker, HttpSessionCleanupReason::RequestLimitReached); + break; + case link_internal::LinkPersistentReuseDecision::Poisoned: + cleanupPersistentHttpClient(worker, HttpSessionCleanupReason::Poisoned); + break; + case link_internal::LinkPersistentReuseDecision::Create: + break; + } + + if (!session.originHost.assignText(origin.host, origin.hostSize)) { + return LinkResult::error(LinkErrorCode::AllocationFailed, "http origin allocation failed"); + } + session.originPort = origin.port; + session.originHttps = origin.https; + session.eventContext.owner = this; + session.client = createHttpClient(session.eventContext, url, timeoutMs); + if (session.client == nullptr) { + session.originHost.clear(); + session.originPort = 0; + session.originHttps = false; + return LinkResult::error(LinkErrorCode::AllocationFailed, "http client allocation failed"); + } + session.createdAtMs = nowMs; + session.lastUsedAtMs = nowMs; + session.requestCount = 0; + session.poisoned = false; + client = session.client; + return LinkResult::ok(); +} + +template +void LinkClient::resetHttpEventContext( + HttpEventContext &context, + QueuedRequest &request, + LinkResponse &response, + const char *currentUrl, + uint8_t redirectCount +) { + context.owner = this; + context.request = &request; + context.response = &response; + context.streamInfo.httpStatus = 0; + context.streamInfo.contentLength = -1; + context.streamInfo.headers.clear(); + context.streamInfo.headers.configureLimits( + _config.maxHeaderCount, + _config.maxHeaderNameSize, + _config.maxHeaderValueSize, + _config.maxTotalHeaderSize + ); + context.streamStarted = false; + context.streamDispositionSet = false; + context.suppressStreamCallbacks = false; + context.eventError = LinkError{}; + context.currentUrl = currentUrl; + context.redirectCount = redirectCount; + context.totalReceived = 0; +} + +template +bool LinkClient::scrubHttpClientRequest( + esp_http_client_handle_t client, const LinkHeaders &headers, size_t appliedHeaderCount +) { + bool clean = true; + for (size_t i = 0; i < appliedHeaderCount; ++i) { + const char *headerName = headers.nameAt(i); + if (headerName == nullptr) { + continue; + } + + bool alreadyDeleted = false; + const size_t headerNameSize = std::strlen(headerName); + for (size_t previousIndex = 0; previousIndex < i; ++previousIndex) { + const char *previousName = headers.nameAt(previousIndex); + if (previousName != nullptr && + link_internal::linkAsciiEqual( + headerName, headerNameSize, previousName, std::strlen(previousName) + )) { + alreadyDeleted = true; + break; + } + } + if (!alreadyDeleted && esp_http_client_delete_header(client, headerName) != ESP_OK) { + clean = false; + } + } + if (esp_http_client_set_post_field(client, nullptr, 0) != ESP_OK) { + clean = false; + } + return clean; +} + +template +esp_err_t LinkClient::httpEventHandler(esp_http_client_event_t *event) { + if (event == nullptr || event->user_data == nullptr) { + return ESP_OK; + } + HttpEventContext *context = static_cast(event->user_data); + if (context->owner == nullptr) { + return ESP_OK; + } + + switch (event->event_id) { + case HTTP_EVENT_ON_CONNECTED: + context->owner->recordTransportConnected(); + return ESP_OK; + case HTTP_EVENT_DISCONNECTED: + context->owner->recordTransportDisconnected(); + return ESP_OK; + default: + break; + } + + if (context->request == nullptr || context->response == nullptr) { + return ESP_OK; + } + + switch (event->event_id) { + case HTTP_EVENT_ON_HEADER: + if (event->header_key != nullptr && event->header_value != nullptr) { + LinkResult result = + context->request->responseMode == LinkResponseMode::Stream + ? context->streamInfo.headers.add(event->header_key, event->header_value) + : context->response->headers.add(event->header_key, event->header_value); + if (!result) { + context->eventError = {result.code, result.message}; + return ESP_FAIL; + } + } + break; + case HTTP_EVENT_ON_DATA: + if (event->data == nullptr || event->data_len <= 0) { + break; + } + if (context->owner->state() == LinkState::Stopping) { + context->eventError = {LinkErrorCode::Cancelled, "request cancelled"}; + return ESP_FAIL; + } + if (context->request->responseMode == LinkResponseMode::Stream) { + if (!context->streamDispositionSet) { + context->streamInfo.httpStatus = esp_http_client_get_status_code(event->client); + context->streamInfo.contentLength = + esp_http_client_get_content_length(event->client); + const link_internal::LinkRedirectDecision redirect = + link_internal::linkEvaluateRedirect( + context->owner->_config, + context->request->method, + context->streamInfo.httpStatus, + context->streamInfo.headers, + context->redirectCount, + context->currentUrl + ); + context->suppressStreamCallbacks = + redirect.action != link_internal::LinkRedirectAction::None; + context->streamDispositionSet = true; + } + if (context->suppressStreamCallbacks) { + break; + } + if (!context->streamStarted) { + context->request->onStreamStart(context->streamInfo); + context->streamStarted = true; + } + LinkStreamChunk chunk; + chunk.data = static_cast(event->data); + chunk.size = static_cast(event->data_len); + chunk.totalReceived = context->totalReceived + chunk.size; + const LinkStreamAction action = context->request->onStreamChunk(chunk); + context->totalReceived = chunk.totalReceived; + if (action == LinkStreamAction::Cancel) { + context->eventError = {LinkErrorCode::Cancelled, "request cancelled"}; + return ESP_FAIL; + } + break; + } + + { + const size_t chunkSize = static_cast(event->data_len); + const size_t currentSize = context->response->body.size(); + if (chunkSize > context->owner->_config.maxResponseBodySize || + currentSize > context->owner->_config.maxResponseBodySize - chunkSize) { + context->eventError = { + LinkErrorCode::ResponseTooLarge, + "response body is too large" + }; + return ESP_FAIL; + } + if (!context->response->body + .append(static_cast(event->data), chunkSize, true)) { + context->eventError = { + LinkErrorCode::AllocationFailed, + "response body allocation failed" + }; + return ESP_FAIL; + } + } + break; + default: + break; + } + return ESP_OK; +} + + +#endif diff --git a/src/internal/LinkClientHttpRequestImpl.h b/src/internal/LinkClientHttpRequestImpl.h new file mode 100644 index 0000000..8e95bb7 --- /dev/null +++ b/src/internal/LinkClientHttpRequestImpl.h @@ -0,0 +1,236 @@ +#if defined(ESP32) +template +void LinkClient::performHttpRequest( + WorkerRecord &worker, QueuedRequest &request +) { + char *currentUrl = + link_memory::duplicateString(request.url.c_str(), std::strlen(request.url.c_str())); + if (currentUrl == nullptr) { + if (request.responseMode == LinkResponseMode::Stream) { + LinkStreamResult result; + result.error = {LinkErrorCode::AllocationFailed, "url allocation failed"}; + request.onStreamEnd(result); + } else if (request.parseJsonResponse) { + LinkJsonResponse response; + response.error = {LinkErrorCode::AllocationFailed, "url allocation failed"}; + request.onJsonResponse(response); + } else { + LinkResponse response; + response.error = {LinkErrorCode::AllocationFailed, "url allocation failed"}; + request.onResponse(response); + } + return; + } + + uint8_t redirects = 0; + bool includeRequestHeaders = true; + while (true) { + LinkResponse response; + response.headers.configureLimits( + _config.maxHeaderCount, + _config.maxHeaderNameSize, + _config.maxHeaderValueSize, + _config.maxTotalHeaderSize + ); + + HttpEventContext temporaryContext; + HttpEventContext *context = &temporaryContext; + esp_http_client_handle_t client = nullptr; + bool persistent = _config.connectionMode == LinkConnectionMode::PersistentPerWorker; + LinkError setupError; + + if (persistent) { + LinkResult persistentResult = + preparePersistentHttpClient(worker, currentUrl, request.timeoutMs, client); + if (!persistentResult) { + setupError = {persistentResult.code, persistentResult.message}; + } else { + context = &worker.http.eventContext; + } + } else { + temporaryContext.owner = this; + client = createHttpClient(temporaryContext, currentUrl, request.timeoutMs); + if (client == nullptr) { + setupError = {LinkErrorCode::AllocationFailed, "http client allocation failed"}; + } + } + + if (client == nullptr) { + link_memory::release(currentUrl); + if (request.responseMode == LinkResponseMode::Stream) { + LinkStreamResult result; + result.error = setupError; + request.onStreamEnd(result); + } else if (request.parseJsonResponse) { + LinkJsonResponse jsonResponse; + jsonResponse.error = setupError; + request.onJsonResponse(jsonResponse); + } else { + response.error = setupError; + request.onResponse(response); + } + return; + } + + resetHttpEventContext(*context, request, response, currentUrl, redirects); + + size_t appliedHeaderCount = 0; + setupError = link_internal_http::mapSetupError( + esp_http_client_set_url(client, currentUrl), + "http url setup failed" + ); + if (setupError.code == LinkErrorCode::Ok) { + setupError = link_internal_http::mapSetupError( + esp_http_client_set_timeout_ms(client, static_cast(request.timeoutMs)), + "http timeout setup failed" + ); + } + if (setupError.code == LinkErrorCode::Ok) { + setupError = link_internal_http::mapSetupError( + esp_http_client_set_method(client, link_internal_http::toEspMethod(request.method)), + "http method setup failed" + ); + } + if (setupError.code == LinkErrorCode::Ok) { + setupError = link_internal_http::mapSetupError( + esp_http_client_set_post_field(client, nullptr, 0), + "http request body reset failed" + ); + } + for (size_t i = 0; includeRequestHeaders && setupError.code == LinkErrorCode::Ok && + i < request.headers.size(); + ++i) { + const char *headerName = request.headers.nameAt(i); + setupError = link_internal_http::mapSetupError( + esp_http_client_set_header(client, headerName, request.headers.valueAt(i)), + "http header setup failed" + ); + if (setupError.code == LinkErrorCode::Ok) { + appliedHeaderCount++; + } + } + if (setupError.code == LinkErrorCode::Ok && request.body.size() > 0) { + setupError = link_internal_http::mapSetupError( + esp_http_client_set_post_field( + client, + reinterpret_cast(request.body.data()), + static_cast(request.body.size()) + ), + "http request body setup failed" + ); + } + + const esp_err_t err = + setupError.code == LinkErrorCode::Ok ? esp_http_client_perform(client) : ESP_FAIL; + response.httpStatus = esp_http_client_get_status_code(client); + context->streamInfo.httpStatus = response.httpStatus; + context->streamInfo.contentLength = esp_http_client_get_content_length(client); + LinkError transportError = setupError.code == LinkErrorCode::Ok + ? link_internal_http::mapEspError(err, client, currentUrl) + : setupError; + const bool scrubbed = scrubHttpClientRequest(client, request.headers, appliedHeaderCount); + + response.error = + link_internal::linkPreserveOperationError(context->eventError, transportError); + if (!scrubbed && response.error.code == LinkErrorCode::Ok) { + response.error = {LinkErrorCode::InternalError, "http request cleanup failed"}; + } + const bool poisoned = response.error.code != LinkErrorCode::Ok; + + if (persistent) { + worker.http.lastUsedAtMs = millis(); + worker.http.requestCount++; + worker.http.poisoned = poisoned; + if (poisoned) { + cleanupPersistentHttpClient(worker, HttpSessionCleanupReason::Poisoned); + } + } else { + cleanupHttpClient(client); + } + + context->request = nullptr; + context->response = nullptr; + context->currentUrl = nullptr; + + if (response.error.code == LinkErrorCode::Ok) { + const LinkHeaders &responseHeaders = request.responseMode == LinkResponseMode::Stream + ? context->streamInfo.headers + : response.headers; + const link_internal::LinkRedirectDecision redirect = + link_internal::linkEvaluateRedirect( + _config, + request.method, + response.httpStatus, + responseHeaders, + redirects, + currentUrl + ); + if (redirect.action == link_internal::LinkRedirectAction::Error) { + response.error = redirect.error; + } else if (redirect.action == link_internal::LinkRedirectAction::Follow) { + char *nextUrl = + link_memory::duplicateString(redirect.location, std::strlen(redirect.location)); + if (nextUrl == nullptr) { + response.error = { + LinkErrorCode::AllocationFailed, + "redirect url allocation failed" + }; + } else { + if (redirect.stripRequestHeaders) + includeRequestHeaders = false; + link_memory::release(currentUrl); + currentUrl = nextUrl; + redirects++; + continue; + } + } + } + + link_memory::release(currentUrl); + + if (request.responseMode == LinkResponseMode::Stream) { + if (!context->streamStarted && response.error.code == LinkErrorCode::Ok) { + request.onStreamStart(context->streamInfo); + } + LinkStreamResult streamResult; + streamResult.error = response.error; + streamResult.httpStatus = response.httpStatus; + streamResult.totalReceived = context->totalReceived; + request.onStreamEnd(streamResult); + return; + } + + if (request.parseJsonResponse) { + LinkJsonResponse jsonResponse; + jsonResponse.error = response.error; + jsonResponse.httpStatus = response.httpStatus; + LinkResult headerCopyResult = jsonResponse.headers.copyFrom(response.headers); + if (!headerCopyResult) { + jsonResponse.error = {headerCopyResult.code, headerCopyResult.message}; + } + if (jsonResponse.error.code == LinkErrorCode::Ok) { + if (response.body.size() > _config.maxSerializedJsonSize) { + jsonResponse.error = { + LinkErrorCode::JsonParseFailed, + "serialized json response is too large" + }; + } else { + DeserializationError jsonError = deserializeJson( + jsonResponse.json, + response.body.c_str(), + response.body.size() + ); + if (jsonError) { + jsonResponse.error = {LinkErrorCode::JsonParseFailed, "json parse failed"}; + } + } + } + request.onJsonResponse(jsonResponse); + return; + } + + request.onResponse(response); + return; + } +} +#endif diff --git a/src/internal/LinkClientHttpSessionImpl.h b/src/internal/LinkClientHttpSessionImpl.h new file mode 100644 index 0000000..779a41c --- /dev/null +++ b/src/internal/LinkClientHttpSessionImpl.h @@ -0,0 +1,289 @@ +#if !defined(ESP32) +template +void LinkClient::performHttpRequest( + WorkerRecord &worker, QueuedRequest &request +) { + (void)worker; + if (request.responseMode == LinkResponseMode::Stream) { + LinkStreamResult result; + result.error = {LinkErrorCode::InternalError, "http execution requires ESP32"}; + result.httpStatus = 0; + result.totalReceived = 0; + if (request.onStreamEnd) { + request.onStreamEnd(result); + } + return; + } + if (request.parseJsonResponse) { + LinkJsonResponse response; + response.error = {LinkErrorCode::InternalError, "http execution requires ESP32"}; + if (request.onJsonResponse) { + request.onJsonResponse(response); + } + return; + } + LinkResponse response; + response.error = {LinkErrorCode::InternalError, "http execution requires ESP32"}; + if (request.onResponse) { + request.onResponse(response); + } +} +#endif + +#if defined(ESP32) +namespace link_internal_http { + +inline esp_http_client_method_t toEspMethod(LinkMethod method) { + switch (method) { + case LinkMethod::Get: + return HTTP_METHOD_GET; + case LinkMethod::Post: + return HTTP_METHOD_POST; + case LinkMethod::Put: + return HTTP_METHOD_PUT; + case LinkMethod::Patch: + return HTTP_METHOD_PATCH; + case LinkMethod::Delete: + return HTTP_METHOD_DELETE; + case LinkMethod::Head: + return HTTP_METHOD_HEAD; + } + return HTTP_METHOD_GET; +} + +inline bool isHttps(const char *url) { + return url != nullptr && std::strncmp(url, "https://", 8) == 0; +} + +inline bool getSocketError(esp_http_client_handle_t client, int &socketError) { +#if defined(ESP_IDF_VERSION) && defined(ESP_IDF_VERSION_VAL) && \ + ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0) + if (client == nullptr) { + return false; + } + socketError = esp_http_client_get_errno(client); + return socketError != 0 && socketError != -1; +#else + (void)client; + (void)socketError; + return false; +#endif +} + +inline bool hasTlsError(esp_http_client_handle_t client) { +#if defined(ESP_IDF_VERSION) && defined(ESP_IDF_VERSION_VAL) && \ + ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0) + if (client == nullptr) { + return false; + } + int tlsError = 0; + int tlsFlags = 0; + const esp_err_t tlsResult = + esp_http_client_get_and_clear_last_tls_error(client, &tlsError, &tlsFlags); + return tlsResult != ESP_OK || tlsError != 0 || tlsFlags != 0; +#else + (void)client; + return false; +#endif +} + +inline LinkError mapEspError(esp_err_t err, esp_http_client_handle_t client, const char *url) { + if (err == ESP_OK) { + return {LinkErrorCode::Ok, "ok"}; + } + if (isHttps(url) && hasTlsError(client)) { + return {LinkErrorCode::TlsFailed, "https request failed"}; + } +#if defined(ESP_ERR_TIMEOUT) + if (err == ESP_ERR_TIMEOUT) { + return {LinkErrorCode::Timeout, "http request timed out"}; + } +#endif +#if defined(ESP_ERR_HTTP_READ_TIMEOUT) + if (err == ESP_ERR_HTTP_READ_TIMEOUT || err == ESP_ERR_HTTP_EAGAIN || + err == ESP_ERR_HTTP_CONNECTING) { + return {LinkErrorCode::Timeout, "http request timed out"}; + } +#endif +#if defined(ESP_ERR_HTTP_CONNECT) + if (err == ESP_ERR_HTTP_CONNECT) { + return {LinkErrorCode::ConnectionFailed, "http connection failed"}; + } +#endif +#if defined(ESP_ERR_HTTP_WRITE_DATA) + if (err == ESP_ERR_HTTP_WRITE_DATA) { + return {LinkErrorCode::SendFailed, "http send failed"}; + } +#endif +#if defined(ESP_ERR_HTTP_FETCH_HEADER) + if (err == ESP_ERR_HTTP_FETCH_HEADER || err == ESP_ERR_HTTP_CONNECTION_CLOSED || + err == ESP_ERR_HTTP_INCOMPLETE_DATA) { + return {LinkErrorCode::ReceiveFailed, esp_err_to_name(err)}; + } +#endif + int socketError = 0; + if (getSocketError(client, socketError)) { + if (socketError == ETIMEDOUT) { + return {LinkErrorCode::Timeout, "http request timed out"}; + } + if (socketError == ECONNREFUSED || socketError == ENETUNREACH || + socketError == EHOSTUNREACH || socketError == ENOTCONN) { + return {LinkErrorCode::ConnectionFailed, "http connection failed"}; + } + if (socketError == EPIPE) { + return {LinkErrorCode::SendFailed, "http send failed"}; + } + if (socketError == ECONNRESET) { + return {LinkErrorCode::ReceiveFailed, "http receive failed"}; + } + } + return {LinkErrorCode::ReceiveFailed, esp_err_to_name(err)}; +} + +inline LinkError mapSetupError(esp_err_t err, const char *message) { + if (err == ESP_OK) + return {LinkErrorCode::Ok, "ok"}; + if (err == ESP_ERR_NO_MEM) { + return {LinkErrorCode::AllocationFailed, "http request setup allocation failed"}; + } + return {LinkErrorCode::InternalError, message}; +} + +} // namespace link_internal_http + +template +void LinkClient::recordHttpClientCreated() { + LinkLock lock(_mutex); + if (!lock) + return; + _diagnostics.httpClientCreates++; + _diagnostics.activeHttpClients++; + if (_diagnostics.activeHttpClients > _diagnostics.peakHttpClients) { + _diagnostics.peakHttpClients = _diagnostics.activeHttpClients; + } +} + +template +void LinkClient::recordHttpClientReused() { + LinkLock lock(_mutex); + if (lock) { + _diagnostics.httpClientReuses++; + } +} + +template +void LinkClient::recordTransportConnected() { + LinkLock lock(_mutex); + if (lock) { + _diagnostics.transportConnectedEvents++; + } +} + +template +void LinkClient::recordTransportDisconnected() { + LinkLock lock(_mutex); + if (lock) { + _diagnostics.transportDisconnectedEvents++; + } +} + +template +esp_http_client_handle_t LinkClient::createHttpClient( + HttpEventContext &context, const char *url, uint32_t timeoutMs +) { + esp_http_client_config_t httpConfig = {}; + httpConfig.url = url; + httpConfig.timeout_ms = static_cast(timeoutMs); + httpConfig.event_handler = &LinkClient::httpEventHandler; + httpConfig.user_data = &context; + httpConfig.disable_auto_redirect = true; + httpConfig.buffer_size = static_cast(_config.streamChunkSize); +#if LINK_HAS_CRT_BUNDLE + if (link_internal_http::isHttps(url)) { + httpConfig.crt_bundle_attach = esp_crt_bundle_attach; + } +#endif + + esp_http_client_handle_t client = esp_http_client_init(&httpConfig); + if (client != nullptr) { + recordHttpClientCreated(); + } + return client; +} + +template +void LinkClient::cleanupHttpClient(esp_http_client_handle_t client) { + if (client == nullptr) + return; + (void)esp_http_client_cleanup(client); + LinkLock lock(_mutex); + if (!lock) + return; + _diagnostics.httpClientCleanups++; + if (_diagnostics.activeHttpClients > 0) { + _diagnostics.activeHttpClients--; + } +} + +template +bool LinkClient::persistentSessionMatchesUrl( + const WorkerHttpSession &session, const char *url +) const { + if (session.client == nullptr || session.originHost.empty()) + return false; + const link_internal::LinkUrlOrigin next = link_internal::linkParseOrigin(url); + link_internal::LinkUrlOrigin stored; + stored.host = session.originHost.c_str(); + stored.hostSize = session.originHost.size(); + stored.port = session.originPort; + stored.https = session.originHttps; + stored.valid = true; + return link_internal::linkSameOrigin(stored, next); +} + +template +void LinkClient::cleanupPersistentHttpClient( + WorkerRecord &worker, HttpSessionCleanupReason reason +) { + WorkerHttpSession &session = worker.http; + if (session.client == nullptr) + return; + + cleanupHttpClient(session.client); + session.client = nullptr; + session.originHost.clear(); + session.originPort = 0; + session.originHttps = false; + session.createdAtMs = 0; + session.lastUsedAtMs = 0; + session.requestCount = 0; + session.poisoned = false; + session.eventContext.request = nullptr; + session.eventContext.response = nullptr; + session.eventContext.currentUrl = nullptr; + session.eventContext.streamInfo.headers.clear(); + + LinkLock lock(_mutex); + if (!lock) + return; + switch (reason) { + case HttpSessionCleanupReason::OriginChanged: + _diagnostics.originReuseMisses++; + break; + case HttpSessionCleanupReason::IdleExpired: + _diagnostics.idleEvictions++; + break; + case HttpSessionCleanupReason::RequestLimitReached: + _diagnostics.requestLimitEvictions++; + break; + case HttpSessionCleanupReason::Poisoned: + _diagnostics.poisonedEvictions++; + break; + case HttpSessionCleanupReason::None: + case HttpSessionCleanupReason::Shutdown: + break; + } +} + + +#endif diff --git a/src/internal/LinkClientImpl.h b/src/internal/LinkClientImpl.h index a7af0df..b8b8706 100644 --- a/src/internal/LinkClientImpl.h +++ b/src/internal/LinkClientImpl.h @@ -1,1136 +1,8 @@ #pragma once -#include - -#if defined(ESP32) -#include -#include -#include -#if __has_include() -#include -#endif -#if __has_include() -#include -#define LINK_HAS_CRT_BUNDLE 1 -#else -#define LINK_HAS_CRT_BUNDLE 0 -#endif -#endif - -namespace link_internal { - -enum class LinkRedirectAction : uint8_t { None, Follow, Error }; - -struct LinkRedirectDecision { - LinkRedirectAction action = LinkRedirectAction::None; - const char *location = nullptr; - bool stripRequestHeaders = false; - LinkError error; -}; - -struct LinkUrlOrigin { - const char *host = nullptr; - size_t hostSize = 0; - uint16_t port = 0; - bool https = false; - bool valid = false; -}; - -inline char linkLowerAscii(char value) { - return value >= 'A' && value <= 'Z' ? static_cast(value - 'A' + 'a') : value; -} - -inline bool linkAsciiEqual(const char *left, size_t leftSize, const char *right, size_t rightSize) { - if (left == nullptr || right == nullptr || leftSize != rightSize) - return false; - for (size_t i = 0; i < leftSize; ++i) { - if (linkLowerAscii(left[i]) != linkLowerAscii(right[i])) - return false; - } - return true; -} - -inline LinkUrlOrigin linkParseOrigin(const char *url) { - LinkUrlOrigin origin; - if (url == nullptr) - return origin; - - const char *authority = nullptr; - if (std::strncmp(url, "https://", 8) == 0) { - origin.https = true; - origin.port = 443; - authority = url + 8; - } else if (std::strncmp(url, "http://", 7) == 0) { - origin.port = 80; - authority = url + 7; - } else { - return origin; - } - - const char *authorityEnd = authority; - while (*authorityEnd != '\0' && *authorityEnd != '/' && *authorityEnd != '?' && - *authorityEnd != '#') { - if (*authorityEnd == '@') - return origin; - authorityEnd++; - } - if (authority == authorityEnd) - return origin; - - const char *hostBegin = authority; - const char *hostEnd = authorityEnd; - const char *portBegin = nullptr; - if (*hostBegin == '[') { - hostBegin++; - hostEnd = hostBegin; - while (hostEnd < authorityEnd && *hostEnd != ']') - hostEnd++; - if (hostEnd == authorityEnd || hostEnd == hostBegin) - return origin; - const char *afterBracket = hostEnd + 1; - if (afterBracket < authorityEnd) { - if (*afterBracket != ':') - return origin; - portBegin = afterBracket + 1; - } - } else { - for (const char *cursor = authority; cursor < authorityEnd; ++cursor) { - if (*cursor == ':') { - if (portBegin != nullptr) - return origin; - hostEnd = cursor; - portBegin = cursor + 1; - } - } - } - if (hostEnd == hostBegin) - return origin; - - if (portBegin != nullptr) { - if (portBegin == authorityEnd) - return origin; - uint32_t port = 0; - for (const char *cursor = portBegin; cursor < authorityEnd; ++cursor) { - if (*cursor < '0' || *cursor > '9') - return origin; - const uint32_t digit = static_cast(*cursor - '0'); - if (port > 6553U || (port == 6553U && digit > 5U)) - return origin; - port = (port * 10U) + digit; - } - if (port == 0) - return origin; - origin.port = static_cast(port); - } - - origin.host = hostBegin; - origin.hostSize = static_cast(hostEnd - hostBegin); - origin.valid = true; - return origin; -} - -inline bool linkSameOrigin(const LinkUrlOrigin &left, const LinkUrlOrigin &right) { - return left.valid && right.valid && left.https == right.https && left.port == right.port && - linkAsciiEqual(left.host, left.hostSize, right.host, right.hostSize); -} - -inline LinkError -linkPreserveOperationError(const LinkError &operationError, const LinkError &transportError) { - return operationError.code == LinkErrorCode::Ok ? transportError : operationError; -} - -inline bool linkIsRedirectStatus(int status) { - return status == 301 || status == 302 || status == 303 || status == 307 || status == 308; -} - -inline LinkRedirectDecision linkEvaluateRedirect( - const LinkConfig &config, - LinkMethod method, - int status, - const LinkHeaders &headers, - uint8_t redirectCount, - const char *currentUrl -) { - LinkRedirectDecision decision; - if (!config.followRedirects || method != LinkMethod::Get || !linkIsRedirectStatus(status)) { - return decision; - } - - const char *location = headers.get("Location"); - if (location == nullptr || !linkUrlLooksValid(location)) { - return decision; - } - - decision.location = location; - if (redirectCount >= config.maxRedirects) { - decision.action = LinkRedirectAction::Error; - decision.error = {LinkErrorCode::RedirectLimitReached, "redirect limit reached"}; - return decision; - } - - if (std::strlen(location) > config.maxUrlSize) { - decision.action = LinkRedirectAction::Error; - decision.error = {LinkErrorCode::UrlTooLarge, "redirect url is too large"}; - return decision; - } - - const LinkUrlOrigin currentOrigin = linkParseOrigin(currentUrl); - const LinkUrlOrigin redirectOrigin = linkParseOrigin(location); - if (!currentOrigin.valid || !redirectOrigin.valid) { - decision.action = LinkRedirectAction::Error; - decision.error = {LinkErrorCode::RedirectRejected, "redirect origin is invalid"}; - return decision; - } - if (currentOrigin.https && !redirectOrigin.https && !config.allowHttpsToHttpRedirects) { - decision.action = LinkRedirectAction::Error; - decision.error = {LinkErrorCode::RedirectRejected, "https to http redirect rejected"}; - return decision; - } - const bool sameOrigin = linkSameOrigin(currentOrigin, redirectOrigin); - if (!sameOrigin && !config.allowCrossOriginRedirects) { - decision.action = LinkRedirectAction::Error; - decision.error = {LinkErrorCode::RedirectRejected, "cross-origin redirect rejected"}; - return decision; - } - - decision.action = LinkRedirectAction::Follow; - decision.stripRequestHeaders = !sameOrigin; - return decision; -} - -inline bool linkWorkerSignalCapacity(const LinkConfig &config, UBaseType_t &out) { - constexpr UBaseType_t maximum = std::numeric_limits::max(); - if (config.queueSize > maximum || config.maxConcurrentRequests > maximum) { - return false; - } - const UBaseType_t queueSize = static_cast(config.queueSize); - const UBaseType_t workers = static_cast(config.maxConcurrentRequests); - if (queueSize > maximum - workers) { - return false; - } - out = queueSize + workers; - return true; -} - -} // namespace link_internal - -template -LinkResult LinkClient::validateConfig(const LinkConfig &config) const { - if (config.queueSize == 0 || config.maxConcurrentRequests == 0) { - return LinkResult::error( - LinkErrorCode::InvalidConfig, - "queue and concurrency must be nonzero" - ); - } - if (config.queueSize < config.maxConcurrentRequests) { - return LinkResult::error( - LinkErrorCode::InvalidConfig, - "queue size must be at least max concurrent requests" - ); - } - if (!link_task_support::isValidStackSize(config.stackSizeBytes)) { - return LinkResult::error(LinkErrorCode::InvalidConfig, "worker stack size is invalid"); - } - if (config.defaultTimeoutMs == 0 || config.maxUrlSize == 0 || config.maxRequestBodySize == 0 || - config.maxResponseBodySize == 0 || config.maxSerializedJsonSize == 0 || - config.maxHeaderCount == 0 || config.maxHeaderNameSize == 0 || - config.maxHeaderValueSize == 0 || config.maxTotalHeaderSize == 0 || - config.streamChunkSize == 0) { - return LinkResult::error(LinkErrorCode::InvalidConfig, "memory limits must be nonzero"); - } - if (config.maxHeaderNameSize + config.maxHeaderValueSize > config.maxTotalHeaderSize) { - return LinkResult::error(LinkErrorCode::InvalidConfig, "header total limit is too small"); - } - UBaseType_t signalCapacity = 0; - if (!link_internal::linkWorkerSignalCapacity(config, signalCapacity)) { - return LinkResult::error( - LinkErrorCode::InvalidConfig, - "worker signal capacity is too large" - ); - } - return LinkResult::ok(); -} - -template -bool LinkClient::shouldUsePsramStack() const { - if (_config.stackType == LinkStackType::Psram) { - return true; - } - return _config.stackType == LinkStackType::Auto && link_task_support::hasExternalStackSupport(); -} - -template -LinkResult LinkClient::init(const LinkConfig &config) { - { - LinkLock lock(_mutex); - if (!lock) { - return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); - } - if (_state != LinkState::Uninitialized) { - return LinkResult::error( - LinkErrorCode::AlreadyInitialized, - "link is already initialized" - ); - } - LinkResult configResult = validateConfig(config); - if (!configResult) { - return configResult; - } - - _state = LinkState::Starting; - _config = config; - _slots = new (std::nothrow) QueuedRequest[config.queueSize]; - _slotUsed = new (std::nothrow) bool[config.queueSize]; - _queue = new (std::nothrow) size_t[config.queueSize]; - _workers = new (std::nothrow) WorkerRecord[config.maxConcurrentRequests]; - if (_slots == nullptr || _slotUsed == nullptr || _queue == nullptr || _workers == nullptr) { - delete[] _slots; - delete[] _slotUsed; - delete[] _queue; - delete[] _workers; - _slots = nullptr; - _slotUsed = nullptr; - _queue = nullptr; - _workers = nullptr; - _state = LinkState::Uninitialized; - return LinkResult::error( - LinkErrorCode::AllocationFailed, - "link storage allocation failed" - ); - } - for (size_t i = 0; i < config.queueSize; ++i) { - _slotUsed[i] = false; - _queue[i] = 0; - } - _queueHead = 0; - _queueTail = 0; - _queueCount = 0; - _nextRequestId = 1; - _stopWakeIssued = false; - } - -#if defined(ESP32) - UBaseType_t signalCapacity = 0; - if (!link_internal::linkWorkerSignalCapacity(config, signalCapacity)) { - forceDeinitBlocking(); - return LinkResult::error( - LinkErrorCode::InvalidConfig, - "worker signal capacity is too large" - ); - } - _items = xSemaphoreCreateCounting(signalCapacity, 0); - if (_items == nullptr) { - delete[] _slots; - delete[] _slotUsed; - delete[] _queue; - delete[] _workers; - _slots = nullptr; - _slotUsed = nullptr; - _queue = nullptr; - _workers = nullptr; - _state = LinkState::Uninitialized; - return LinkResult::error(LinkErrorCode::AllocationFailed, "link queue semaphore failed"); - } - - for (size_t i = 0; i < config.maxConcurrentRequests; ++i) { - _workers[i].owner = this; - _workers[i].index = i; - _workers[i].active = true; - char name[16]{}; - snprintf(name, sizeof(name), "link-%u", static_cast(i)); - const BaseType_t created = link_task_support::createTask( - &LinkClient::taskEntry, - name, - config.stackSizeBytes, - &_workers[i], - config.priority, - &_workers[i].handle, - config.coreId, - shouldUsePsramStack(), - _workers[i].createdWithCaps - ); - if (created != pdPASS) { - _workers[i].active = false; - { - LinkLock lock(_mutex); - if (lock) { - _state = LinkState::Stopping; - } - } - forceDeinitBlocking(); - return LinkResult::error( - LinkErrorCode::AllocationFailed, - "worker task creation failed" - ); - } - } -#endif - - { - LinkLock lock(_mutex); - if (!lock) { - forceDeinitBlocking(); - return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); - } - _state = LinkState::Running; - } - return LinkResult::ok(); -} - -template LinkResult LinkClient::deinit() { - return deinitInternal(false); -} - -template void LinkClient::forceDeinitBlocking() { - (void)deinitInternal(true); -} - -template void LinkClient::markStopping() { - LinkLock lock(_mutex); - if (lock && _state != LinkState::Uninitialized) { - _state = LinkState::Stopping; - } -} - -template void LinkClient::wakeWorkers() { -#if defined(ESP32) - LinkLock lock(_mutex); - if (!lock || _stopWakeIssued || _items == nullptr) { - return; - } - _stopWakeIssued = true; - for (size_t i = 0; i < _config.maxConcurrentRequests; ++i) { - xSemaphoreGive(_items); - } -#endif -} - -template -LinkResult LinkClient::waitForWorkers(bool waitForever) { -#if defined(ESP32) - uint32_t timeoutMs = _config.defaultTimeoutMs + 100; - if (timeoutMs < _config.defaultTimeoutMs) { - timeoutMs = UINT32_MAX; - } - const uint32_t started = millis(); - while (true) { - bool workersRunning = false; - { - LinkLock lock(_mutex); - if (!lock) { - return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); - } - if (_workers != nullptr) { - for (size_t i = 0; i < _config.maxConcurrentRequests; ++i) { - workersRunning = workersRunning || _workers[i].active; - } - } - } - if (!workersRunning) { - return LinkResult::ok(); - } - if (!waitForever && static_cast(millis() - started) >= timeoutMs) { - return LinkResult::error(LinkErrorCode::Timeout, "timed out waiting for link workers"); - } - link_task_support::delayMs(10); - } -#else - (void)waitForever; - return LinkResult::ok(); -#endif -} - -template -LinkResult LinkClient::freeRuntimeStorage() { - { - LinkLock lock(_mutex); - if (!lock) { - return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); - } - if (_state == LinkState::Uninitialized) { - return LinkResult::ok(); - } -#if defined(ESP32) - if (_items != nullptr) { - vSemaphoreDelete(_items); - _items = nullptr; - } -#endif - delete[] _slots; - delete[] _slotUsed; - delete[] _queue; - delete[] _workers; - _slots = nullptr; - _slotUsed = nullptr; - _queue = nullptr; - _workers = nullptr; - _queueHead = 0; - _queueTail = 0; - _queueCount = 0; - _stopWakeIssued = false; - _config = LinkConfig{}; - _state = LinkState::Uninitialized; - } - return LinkResult::ok(); -} - -template -LinkResult LinkClient::deinitInternal(bool waitForever) { - { - LinkLock lock(_mutex); - if (!lock) { - return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); - } - if (_state == LinkState::Uninitialized) { - return LinkResult::ok(); - } - } - - markStopping(); - wakeWorkers(); - - LinkResult waitResult = waitForWorkers(waitForever); - if (!waitResult) { - return waitResult; - } - - return freeRuntimeStorage(); -} - -template bool LinkClient::isInitialized() const { - LinkLock lock(const_cast(_mutex)); - return lock && _state == LinkState::Running; -} - -template LinkState LinkClient::state() const { - LinkLock lock(const_cast(_mutex)); - if (!lock) { - return LinkState::Uninitialized; - } - return _state; -} - -template -LinkResult LinkClient::fetch(const Request &request) { - QueuedRequest queued; - LinkConfig configSnapshot; - uint32_t requestId = 0; - { - LinkLock lock(_mutex); - if (!lock) { - return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); - } - if (_state == LinkState::Stopping) { - return LinkResult::error(LinkErrorCode::Stopping, "link is stopping"); - } - if (_state != LinkState::Running) { - return LinkResult::error(LinkErrorCode::NotInitialized, "link is not initialized"); - } - configSnapshot = _config; - requestId = _nextRequestId++; - } - - LinkResult copyResult = queued.copyFrom(request, configSnapshot, requestId); - if (!copyResult) { - return copyResult; - } - - { - LinkLock lock(_mutex); - if (!lock) { - return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); - } - if (_state == LinkState::Stopping) { - return LinkResult::error(LinkErrorCode::Stopping, "link is stopping"); - } - if (_state != LinkState::Running) { - return LinkResult::error(LinkErrorCode::NotInitialized, "link is not initialized"); - } - if (_queueCount >= _config.queueSize) { - return LinkResult::error(LinkErrorCode::QueueFull, "link queue is full"); - } - - size_t slotIndex = _config.queueSize; - for (size_t i = 0; i < _config.queueSize; ++i) { - if (!_slotUsed[i]) { - slotIndex = i; - break; - } - } - if (slotIndex == _config.queueSize) { - return LinkResult::error(LinkErrorCode::QueueFull, "link queue is full"); - } - - _slots[slotIndex] = std::move(queued); - _slotUsed[slotIndex] = true; - _queue[_queueTail] = slotIndex; - _queueTail = (_queueTail + 1) % _config.queueSize; - _queueCount++; - } - -#if defined(ESP32) - if (_items != nullptr) { - xSemaphoreGive(_items); - } -#endif - return LinkResult::ok(); -} - -template -bool LinkClient::popRequest(size_t &slotIndex) { - LinkLock lock(_mutex); - if (!lock || _queueCount == 0 || _queue == nullptr) { - return false; - } - slotIndex = _queue[_queueHead]; - _queueHead = (_queueHead + 1) % _config.queueSize; - _queueCount--; - return true; -} - -template -void LinkClient::releaseSlot(size_t slotIndex) { - LinkLock lock(_mutex); - if (!lock || _slots == nullptr || _slotUsed == nullptr || slotIndex >= _config.queueSize) { - return; - } - _slots[slotIndex].reset(); - _slotUsed[slotIndex] = false; -} - -template -void LinkClient::invokeCancelled(QueuedRequest &request) { - if (request.responseMode == LinkResponseMode::Stream) { - if (request.onStreamEnd) { - LinkStreamResult result; - result.error = {LinkErrorCode::Cancelled, "request cancelled"}; - result.httpStatus = 0; - result.totalReceived = 0; - request.onStreamEnd(result); - } - return; - } - if (request.parseJsonResponse) { - if (request.onJsonResponse) { - LinkJsonResponse response; - response.error = {LinkErrorCode::Cancelled, "request cancelled"}; - request.onJsonResponse(response); - } - return; - } - if (request.onResponse) { - LinkResponse response; - response.error = {LinkErrorCode::Cancelled, "request cancelled"}; - request.onResponse(response); - } -} - -template void LinkClient::taskEntry(void *arg) { - WorkerRecord *worker = static_cast(arg); - if (worker != nullptr && worker->owner != nullptr) { - worker->owner->workerLoop(worker); - } -} - -template -void LinkClient::workerLoop(WorkerRecord *worker) { -#if defined(ESP32) - while (true) { - { - LinkLock lock(_mutex); - if (lock && _state == LinkState::Stopping && _queueCount == 0) { - break; - } - } - if (_items != nullptr) { - xSemaphoreTake(_items, portMAX_DELAY); - } - { - LinkLock lock(_mutex); - if (lock && _state == LinkState::Stopping && _queueCount == 0) { - break; - } - } - size_t slotIndex = 0; - if (!popRequest(slotIndex)) { - continue; - } - processRequest(_slots[slotIndex]); - releaseSlot(slotIndex); - } - if (worker != nullptr) { - const bool createdWithCaps = worker->createdWithCaps; - { - LinkLock lock(_mutex); - if (lock) { - worker->active = false; - worker->handle = nullptr; - } - } - link_task_support::deleteCurrentTask(createdWithCaps); - } -#else - (void)worker; -#endif -} - -template -void LinkClient::processRequest(QueuedRequest &request) { - LinkState currentState = state(); - if (currentState == LinkState::Stopping) { - invokeCancelled(request); - return; - } - performHttpRequest(request); -} - -template -LinkResult LinkClient::addJsonAccept(LinkHeaders &headers) const { - if (!headers.has("Accept")) { - return headers.set("Accept", "application/json"); - } - return LinkResult::ok(); -} - -#if !defined(ESP32) -template -void LinkClient::performHttpRequest(QueuedRequest &request) { - if (request.responseMode == LinkResponseMode::Stream) { - LinkStreamResult result; - result.error = {LinkErrorCode::InternalError, "http execution requires ESP32"}; - result.httpStatus = 0; - result.totalReceived = 0; - if (request.onStreamEnd) { - request.onStreamEnd(result); - } - return; - } - if (request.parseJsonResponse) { - LinkJsonResponse response; - response.error = {LinkErrorCode::InternalError, "http execution requires ESP32"}; - if (request.onJsonResponse) { - request.onJsonResponse(response); - } - return; - } - LinkResponse response; - response.error = {LinkErrorCode::InternalError, "http execution requires ESP32"}; - if (request.onResponse) { - request.onResponse(response); - } -} -#endif - -#if defined(ESP32) -namespace link_internal_http { - -inline esp_http_client_method_t toEspMethod(LinkMethod method) { - switch (method) { - case LinkMethod::Get: - return HTTP_METHOD_GET; - case LinkMethod::Post: - return HTTP_METHOD_POST; - case LinkMethod::Put: - return HTTP_METHOD_PUT; - case LinkMethod::Patch: - return HTTP_METHOD_PATCH; - case LinkMethod::Delete: - return HTTP_METHOD_DELETE; - case LinkMethod::Head: - return HTTP_METHOD_HEAD; - } - return HTTP_METHOD_GET; -} - -inline bool isHttps(const char *url) { - return url != nullptr && std::strncmp(url, "https://", 8) == 0; -} - -inline bool getSocketError(esp_http_client_handle_t client, int &socketError) { -#if defined(ESP_IDF_VERSION) && defined(ESP_IDF_VERSION_VAL) && \ - ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0) - if (client == nullptr) { - return false; - } - socketError = esp_http_client_get_errno(client); - return socketError != 0 && socketError != -1; -#else - (void)client; - (void)socketError; - return false; -#endif -} - -inline bool hasTlsError(esp_http_client_handle_t client) { -#if defined(ESP_IDF_VERSION) && defined(ESP_IDF_VERSION_VAL) && \ - ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0) - if (client == nullptr) { - return false; - } - int tlsError = 0; - int tlsFlags = 0; - const esp_err_t tlsResult = - esp_http_client_get_and_clear_last_tls_error(client, &tlsError, &tlsFlags); - return tlsResult != ESP_OK || tlsError != 0 || tlsFlags != 0; -#else - (void)client; - return false; -#endif -} - -inline LinkError mapEspError(esp_err_t err, esp_http_client_handle_t client, const char *url) { - if (err == ESP_OK) { - return {LinkErrorCode::Ok, "ok"}; - } - if (isHttps(url) && hasTlsError(client)) { - return {LinkErrorCode::TlsFailed, "https request failed"}; - } -#if defined(ESP_ERR_TIMEOUT) - if (err == ESP_ERR_TIMEOUT) { - return {LinkErrorCode::Timeout, "http request timed out"}; - } -#endif -#if defined(ESP_ERR_HTTP_READ_TIMEOUT) - if (err == ESP_ERR_HTTP_READ_TIMEOUT || err == ESP_ERR_HTTP_EAGAIN || - err == ESP_ERR_HTTP_CONNECTING) { - return {LinkErrorCode::Timeout, "http request timed out"}; - } -#endif -#if defined(ESP_ERR_HTTP_CONNECT) - if (err == ESP_ERR_HTTP_CONNECT) { - return {LinkErrorCode::ConnectionFailed, "http connection failed"}; - } -#endif -#if defined(ESP_ERR_HTTP_WRITE_DATA) - if (err == ESP_ERR_HTTP_WRITE_DATA) { - return {LinkErrorCode::SendFailed, "http send failed"}; - } -#endif -#if defined(ESP_ERR_HTTP_FETCH_HEADER) - if (err == ESP_ERR_HTTP_FETCH_HEADER || err == ESP_ERR_HTTP_CONNECTION_CLOSED || - err == ESP_ERR_HTTP_INCOMPLETE_DATA) { - return {LinkErrorCode::ReceiveFailed, esp_err_to_name(err)}; - } -#endif - int socketError = 0; - if (getSocketError(client, socketError)) { - if (socketError == ETIMEDOUT) { - return {LinkErrorCode::Timeout, "http request timed out"}; - } - if (socketError == ECONNREFUSED || socketError == ENETUNREACH || - socketError == EHOSTUNREACH || socketError == ENOTCONN) { - return {LinkErrorCode::ConnectionFailed, "http connection failed"}; - } - if (socketError == EPIPE) { - return {LinkErrorCode::SendFailed, "http send failed"}; - } - if (socketError == ECONNRESET) { - return {LinkErrorCode::ReceiveFailed, "http receive failed"}; - } - } - return {LinkErrorCode::ReceiveFailed, esp_err_to_name(err)}; -} - -inline LinkError mapSetupError(esp_err_t err, const char *message) { - if (err == ESP_OK) - return {LinkErrorCode::Ok, "ok"}; - if (err == ESP_ERR_NO_MEM) { - return {LinkErrorCode::AllocationFailed, "http request setup allocation failed"}; - } - return {LinkErrorCode::InternalError, message}; -} - -} // namespace link_internal_http - -template -esp_err_t LinkClient::httpEventHandler(esp_http_client_event_t *event) { - if (event == nullptr || event->user_data == nullptr) { - return ESP_OK; - } - HttpEventContext *context = static_cast(event->user_data); - if (context->owner == nullptr || context->request == nullptr) { - return ESP_OK; - } - - switch (event->event_id) { - case HTTP_EVENT_ON_HEADER: - if (event->header_key != nullptr && event->header_value != nullptr) { - LinkResult result = - context->request->responseMode == LinkResponseMode::Stream - ? context->streamInfo.headers.add(event->header_key, event->header_value) - : context->response->headers.add(event->header_key, event->header_value); - if (!result) { - context->eventError = {result.code, result.message}; - return ESP_FAIL; - } - } - break; - case HTTP_EVENT_ON_DATA: - if (event->data == nullptr || event->data_len <= 0) { - break; - } - if (context->owner->state() == LinkState::Stopping) { - context->eventError = {LinkErrorCode::Cancelled, "request cancelled"}; - return ESP_FAIL; - } - if (context->request->responseMode == LinkResponseMode::Stream) { - if (!context->streamDispositionSet) { - context->streamInfo.httpStatus = esp_http_client_get_status_code(event->client); - context->streamInfo.contentLength = - esp_http_client_get_content_length(event->client); - const link_internal::LinkRedirectDecision redirect = - link_internal::linkEvaluateRedirect( - context->owner->_config, - context->request->method, - context->streamInfo.httpStatus, - context->streamInfo.headers, - context->redirectCount, - context->currentUrl - ); - context->suppressStreamCallbacks = - redirect.action != link_internal::LinkRedirectAction::None; - context->streamDispositionSet = true; - } - if (context->suppressStreamCallbacks) { - break; - } - if (!context->streamStarted) { - context->request->onStreamStart(context->streamInfo); - context->streamStarted = true; - } - LinkStreamChunk chunk; - chunk.data = static_cast(event->data); - chunk.size = static_cast(event->data_len); - chunk.totalReceived = context->totalReceived + chunk.size; - const LinkStreamAction action = context->request->onStreamChunk(chunk); - context->totalReceived = chunk.totalReceived; - if (action == LinkStreamAction::Cancel) { - context->eventError = {LinkErrorCode::Cancelled, "request cancelled"}; - return ESP_FAIL; - } - break; - } - - { - const size_t chunkSize = static_cast(event->data_len); - const size_t currentSize = context->response->body.size(); - if (chunkSize > context->owner->_config.maxResponseBodySize || - currentSize > context->owner->_config.maxResponseBodySize - chunkSize) { - context->eventError = { - LinkErrorCode::ResponseTooLarge, - "response body is too large" - }; - return ESP_FAIL; - } - if (!context->response->body - .append(static_cast(event->data), chunkSize, true)) { - context->eventError = { - LinkErrorCode::AllocationFailed, - "response body allocation failed" - }; - return ESP_FAIL; - } - } - break; - default: - break; - } - return ESP_OK; -} - -template -void LinkClient::performHttpRequest(QueuedRequest &request) { - char *currentUrl = - link_memory::duplicateString(request.url.c_str(), std::strlen(request.url.c_str())); - if (currentUrl == nullptr) { - if (request.responseMode == LinkResponseMode::Stream) { - LinkStreamResult result; - result.error = {LinkErrorCode::AllocationFailed, "url allocation failed"}; - request.onStreamEnd(result); - } else if (request.parseJsonResponse) { - LinkJsonResponse response; - response.error = {LinkErrorCode::AllocationFailed, "url allocation failed"}; - request.onJsonResponse(response); - } else { - LinkResponse response; - response.error = {LinkErrorCode::AllocationFailed, "url allocation failed"}; - request.onResponse(response); - } - return; - } - - uint8_t redirects = 0; - bool includeRequestHeaders = true; - while (true) { - LinkResponse response; - response.headers.configureLimits( - _config.maxHeaderCount, - _config.maxHeaderNameSize, - _config.maxHeaderValueSize, - _config.maxTotalHeaderSize - ); - - HttpEventContext context; - context.owner = this; - context.request = &request; - context.response = &response; - context.currentUrl = currentUrl; - context.redirectCount = redirects; - context.streamInfo.headers.configureLimits( - _config.maxHeaderCount, - _config.maxHeaderNameSize, - _config.maxHeaderValueSize, - _config.maxTotalHeaderSize - ); - - esp_http_client_config_t httpConfig = {}; - httpConfig.url = currentUrl; - httpConfig.timeout_ms = static_cast(request.timeoutMs); - httpConfig.event_handler = &LinkClient::httpEventHandler; - httpConfig.user_data = &context; - httpConfig.disable_auto_redirect = true; - httpConfig.buffer_size = static_cast(_config.streamChunkSize); -#if LINK_HAS_CRT_BUNDLE - if (link_internal_http::isHttps(currentUrl)) { - httpConfig.crt_bundle_attach = esp_crt_bundle_attach; - } -#endif - - esp_http_client_handle_t client = esp_http_client_init(&httpConfig); - if (client == nullptr) { - link_memory::release(currentUrl); - LinkError error = {LinkErrorCode::AllocationFailed, "http client allocation failed"}; - if (request.responseMode == LinkResponseMode::Stream) { - LinkStreamResult result; - result.error = error; - request.onStreamEnd(result); - } else if (request.parseJsonResponse) { - LinkJsonResponse jsonResponse; - jsonResponse.error = error; - request.onJsonResponse(jsonResponse); - } else { - response.error = error; - request.onResponse(response); - } - return; - } - - LinkError setupError = link_internal_http::mapSetupError( - esp_http_client_set_method(client, link_internal_http::toEspMethod(request.method)), - "http method setup failed" - ); - for (size_t i = 0; includeRequestHeaders && setupError.code == LinkErrorCode::Ok && - i < request.headers.size(); - ++i) { - const char *headerName = request.headers.nameAt(i); - setupError = link_internal_http::mapSetupError( - esp_http_client_set_header(client, headerName, request.headers.valueAt(i)), - "http header setup failed" - ); - } - if (setupError.code == LinkErrorCode::Ok && request.body.size() > 0) { - setupError = link_internal_http::mapSetupError( - esp_http_client_set_post_field( - client, - reinterpret_cast(request.body.data()), - static_cast(request.body.size()) - ), - "http request body setup failed" - ); - } - - const esp_err_t err = - setupError.code == LinkErrorCode::Ok ? esp_http_client_perform(client) : ESP_FAIL; - response.httpStatus = esp_http_client_get_status_code(client); - context.streamInfo.httpStatus = response.httpStatus; - context.streamInfo.contentLength = esp_http_client_get_content_length(client); - LinkError transportError = setupError.code == LinkErrorCode::Ok - ? link_internal_http::mapEspError(err, client, currentUrl) - : setupError; - esp_http_client_cleanup(client); - - response.error = - link_internal::linkPreserveOperationError(context.eventError, transportError); - - if (response.error.code == LinkErrorCode::Ok) { - const LinkHeaders &responseHeaders = request.responseMode == LinkResponseMode::Stream - ? context.streamInfo.headers - : response.headers; - const link_internal::LinkRedirectDecision redirect = - link_internal::linkEvaluateRedirect( - _config, - request.method, - response.httpStatus, - responseHeaders, - redirects, - currentUrl - ); - if (redirect.action == link_internal::LinkRedirectAction::Error) { - response.error = redirect.error; - } else if (redirect.action == link_internal::LinkRedirectAction::Follow) { - char *nextUrl = - link_memory::duplicateString(redirect.location, std::strlen(redirect.location)); - if (nextUrl == nullptr) { - response.error = { - LinkErrorCode::AllocationFailed, - "redirect url allocation failed" - }; - } else { - if (redirect.stripRequestHeaders) - includeRequestHeaders = false; - link_memory::release(currentUrl); - currentUrl = nextUrl; - redirects++; - continue; - } - } - } - - link_memory::release(currentUrl); - - if (request.responseMode == LinkResponseMode::Stream) { - if (!context.streamStarted && response.error.code == LinkErrorCode::Ok) { - request.onStreamStart(context.streamInfo); - } - LinkStreamResult streamResult; - streamResult.error = response.error; - streamResult.httpStatus = response.httpStatus; - streamResult.totalReceived = context.totalReceived; - request.onStreamEnd(streamResult); - return; - } - - if (request.parseJsonResponse) { - LinkJsonResponse jsonResponse; - jsonResponse.error = response.error; - jsonResponse.httpStatus = response.httpStatus; - LinkResult headerCopyResult = jsonResponse.headers.copyFrom(response.headers); - if (!headerCopyResult) { - jsonResponse.error = {headerCopyResult.code, headerCopyResult.message}; - } - if (jsonResponse.error.code == LinkErrorCode::Ok) { - if (response.body.size() > _config.maxSerializedJsonSize) { - jsonResponse.error = { - LinkErrorCode::JsonParseFailed, - "serialized json response is too large" - }; - } else { - DeserializationError jsonError = deserializeJson( - jsonResponse.json, - response.body.c_str(), - response.body.size() - ); - if (jsonError) { - jsonResponse.error = {LinkErrorCode::JsonParseFailed, "json parse failed"}; - } - } - } - request.onJsonResponse(jsonResponse); - return; - } - - request.onResponse(response); - return; - } -} -#endif +#include "LinkClientSupportImpl.h" +#include "LinkClientLifecycleStartImpl.h" +#include "LinkClientLifecycleRuntimeImpl.h" +#include "LinkClientHttpSessionImpl.h" +#include "LinkClientHttpEventsImpl.h" +#include "LinkClientHttpRequestImpl.h" diff --git a/src/internal/LinkClientLifecycleRuntimeImpl.h b/src/internal/LinkClientLifecycleRuntimeImpl.h new file mode 100644 index 0000000..73652f5 --- /dev/null +++ b/src/internal/LinkClientLifecycleRuntimeImpl.h @@ -0,0 +1,238 @@ +template +LinkResult LinkClient::deinitInternal(bool waitForever) { + { + LinkLock lock(_mutex); + if (!lock) { + return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); + } + if (_state == LinkState::Uninitialized) { + return LinkResult::ok(); + } + } + + markStopping(); + wakeWorkers(); + + LinkResult waitResult = waitForWorkers(waitForever); + if (!waitResult) { + return waitResult; + } + + return freeRuntimeStorage(); +} + +template bool LinkClient::isInitialized() const { + LinkLock lock(const_cast(_mutex)); + return lock && _state == LinkState::Running; +} + +template LinkState LinkClient::state() const { + LinkLock lock(const_cast(_mutex)); + if (!lock) { + return LinkState::Uninitialized; + } + return _state; +} + +template +LinkDiagnostics LinkClient::diagnostics() const { + LinkLock lock(const_cast(_mutex)); + return lock ? _diagnostics : LinkDiagnostics{}; +} + +template +LinkResult LinkClient::fetch(const Request &request) { + QueuedRequest queued; + LinkConfig configSnapshot; + uint32_t requestId = 0; + { + LinkLock lock(_mutex); + if (!lock) { + return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); + } + if (_state == LinkState::Stopping) { + return LinkResult::error(LinkErrorCode::Stopping, "link is stopping"); + } + if (_state != LinkState::Running) { + return LinkResult::error(LinkErrorCode::NotInitialized, "link is not initialized"); + } + configSnapshot = _config; + requestId = _nextRequestId++; + } + + LinkResult copyResult = queued.copyFrom(request, configSnapshot, requestId); + if (!copyResult) { + return copyResult; + } + + { + LinkLock lock(_mutex); + if (!lock) { + return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); + } + if (_state == LinkState::Stopping) { + return LinkResult::error(LinkErrorCode::Stopping, "link is stopping"); + } + if (_state != LinkState::Running) { + return LinkResult::error(LinkErrorCode::NotInitialized, "link is not initialized"); + } + if (_queueCount >= _config.queueSize) { + return LinkResult::error(LinkErrorCode::QueueFull, "link queue is full"); + } + + size_t slotIndex = _config.queueSize; + for (size_t i = 0; i < _config.queueSize; ++i) { + if (!_slotUsed[i]) { + slotIndex = i; + break; + } + } + if (slotIndex == _config.queueSize) { + return LinkResult::error(LinkErrorCode::QueueFull, "link queue is full"); + } + + _slots[slotIndex] = std::move(queued); + _slotUsed[slotIndex] = true; + _queue[_queueTail] = slotIndex; + _queueTail = (_queueTail + 1) % _config.queueSize; + _queueCount++; + _diagnostics.requestsSubmitted++; + } + +#if defined(ESP32) + if (_items != nullptr) { + xSemaphoreGive(_items); + } +#endif + return LinkResult::ok(); +} + +template +bool LinkClient::popRequest(size_t &slotIndex) { + LinkLock lock(_mutex); + if (!lock || _queueCount == 0 || _queue == nullptr) { + return false; + } + slotIndex = _queue[_queueHead]; + _queueHead = (_queueHead + 1) % _config.queueSize; + _queueCount--; + return true; +} + +template +void LinkClient::releaseSlot(size_t slotIndex) { + LinkLock lock(_mutex); + if (!lock || _slots == nullptr || _slotUsed == nullptr || slotIndex >= _config.queueSize) { + return; + } + _slots[slotIndex].reset(); + _slotUsed[slotIndex] = false; +} + +template +void LinkClient::invokeCancelled(QueuedRequest &request) { + if (request.responseMode == LinkResponseMode::Stream) { + if (request.onStreamEnd) { + LinkStreamResult result; + result.error = {LinkErrorCode::Cancelled, "request cancelled"}; + result.httpStatus = 0; + result.totalReceived = 0; + request.onStreamEnd(result); + } + return; + } + if (request.parseJsonResponse) { + if (request.onJsonResponse) { + LinkJsonResponse response; + response.error = {LinkErrorCode::Cancelled, "request cancelled"}; + request.onJsonResponse(response); + } + return; + } + if (request.onResponse) { + LinkResponse response; + response.error = {LinkErrorCode::Cancelled, "request cancelled"}; + request.onResponse(response); + } +} + +template void LinkClient::taskEntry(void *arg) { + WorkerRecord *worker = static_cast(arg); + if (worker != nullptr && worker->owner != nullptr) { + worker->owner->workerLoop(worker); + } +} + +template +void LinkClient::workerLoop(WorkerRecord *worker) { +#if defined(ESP32) + while (true) { + { + LinkLock lock(_mutex); + if (lock && _state == LinkState::Stopping && _queueCount == 0) { + break; + } + } + if (_items != nullptr) { + xSemaphoreTake(_items, portMAX_DELAY); + } + { + LinkLock lock(_mutex); + if (lock && _state == LinkState::Stopping && _queueCount == 0) { + break; + } + } + size_t slotIndex = 0; + if (!popRequest(slotIndex)) { + continue; + } + processRequest(*worker, _slots[slotIndex]); + releaseSlot(slotIndex); + } + if (worker != nullptr) { + cleanupPersistentHttpClient(*worker, HttpSessionCleanupReason::Shutdown); + const bool createdWithCaps = worker->createdWithCaps; + { + LinkLock lock(_mutex); + if (lock) { + worker->active = false; + worker->handle = nullptr; + } + } + link_task_support::deleteCurrentTask(createdWithCaps); + } +#else + (void)worker; +#endif +} + +template +void LinkClient::recordRequestCompleted() { + LinkLock lock(_mutex); + if (lock) { + _diagnostics.requestsCompleted++; + } +} + +template +void LinkClient::processRequest( + WorkerRecord &worker, QueuedRequest &request +) { + LinkState currentState = state(); + if (currentState == LinkState::Stopping) { + invokeCancelled(request); + recordRequestCompleted(); + return; + } + performHttpRequest(worker, request); + recordRequestCompleted(); +} + +template +LinkResult LinkClient::addJsonAccept(LinkHeaders &headers) const { + if (!headers.has("Accept")) { + return headers.set("Accept", "application/json"); + } + return LinkResult::ok(); +} + diff --git a/src/internal/LinkClientLifecycleStartImpl.h b/src/internal/LinkClientLifecycleStartImpl.h new file mode 100644 index 0000000..3c87fe5 --- /dev/null +++ b/src/internal/LinkClientLifecycleStartImpl.h @@ -0,0 +1,266 @@ +template +LinkResult LinkClient::validateConfig(const LinkConfig &config) const { + if (config.queueSize == 0 || config.maxConcurrentRequests == 0) { + return LinkResult::error( + LinkErrorCode::InvalidConfig, + "queue and concurrency must be nonzero" + ); + } + if (config.queueSize < config.maxConcurrentRequests) { + return LinkResult::error( + LinkErrorCode::InvalidConfig, + "queue size must be at least max concurrent requests" + ); + } + if (config.connectionMode != LinkConnectionMode::PerRequest && + config.connectionMode != LinkConnectionMode::PersistentPerWorker) { + return LinkResult::error(LinkErrorCode::InvalidConfig, "connection mode is invalid"); + } + if (!link_task_support::isValidStackSize(config.stackSizeBytes)) { + return LinkResult::error(LinkErrorCode::InvalidConfig, "worker stack size is invalid"); + } + if (config.defaultTimeoutMs == 0 || config.maxUrlSize == 0 || config.maxRequestBodySize == 0 || + config.maxResponseBodySize == 0 || config.maxSerializedJsonSize == 0 || + config.maxHeaderCount == 0 || config.maxHeaderNameSize == 0 || + config.maxHeaderValueSize == 0 || config.maxTotalHeaderSize == 0 || + config.streamChunkSize == 0) { + return LinkResult::error(LinkErrorCode::InvalidConfig, "memory limits must be nonzero"); + } + if (config.maxHeaderNameSize + config.maxHeaderValueSize > config.maxTotalHeaderSize) { + return LinkResult::error(LinkErrorCode::InvalidConfig, "header total limit is too small"); + } + UBaseType_t signalCapacity = 0; + if (!link_internal::linkWorkerSignalCapacity(config, signalCapacity)) { + return LinkResult::error( + LinkErrorCode::InvalidConfig, + "worker signal capacity is too large" + ); + } + return LinkResult::ok(); +} + +template +bool LinkClient::shouldUsePsramStack() const { + if (_config.stackType == LinkStackType::Psram) { + return true; + } + return _config.stackType == LinkStackType::Auto && link_task_support::hasExternalStackSupport(); +} + +template +LinkResult LinkClient::init(const LinkConfig &config) { + { + LinkLock lock(_mutex); + if (!lock) { + return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); + } + if (_state != LinkState::Uninitialized) { + return LinkResult::error( + LinkErrorCode::AlreadyInitialized, + "link is already initialized" + ); + } + LinkResult configResult = validateConfig(config); + if (!configResult) { + return configResult; + } + + _state = LinkState::Starting; + _config = config; + _diagnostics = LinkDiagnostics{}; + _slots = new (std::nothrow) QueuedRequest[config.queueSize]; + _slotUsed = new (std::nothrow) bool[config.queueSize]; + _queue = new (std::nothrow) size_t[config.queueSize]; + _workers = new (std::nothrow) WorkerRecord[config.maxConcurrentRequests]; + if (_slots == nullptr || _slotUsed == nullptr || _queue == nullptr || _workers == nullptr) { + delete[] _slots; + delete[] _slotUsed; + delete[] _queue; + delete[] _workers; + _slots = nullptr; + _slotUsed = nullptr; + _queue = nullptr; + _workers = nullptr; + _state = LinkState::Uninitialized; + return LinkResult::error( + LinkErrorCode::AllocationFailed, + "link storage allocation failed" + ); + } + for (size_t i = 0; i < config.queueSize; ++i) { + _slotUsed[i] = false; + _queue[i] = 0; + } + _queueHead = 0; + _queueTail = 0; + _queueCount = 0; + _nextRequestId = 1; + _stopWakeIssued = false; + } + +#if defined(ESP32) + UBaseType_t signalCapacity = 0; + if (!link_internal::linkWorkerSignalCapacity(config, signalCapacity)) { + forceDeinitBlocking(); + return LinkResult::error( + LinkErrorCode::InvalidConfig, + "worker signal capacity is too large" + ); + } + _items = xSemaphoreCreateCounting(signalCapacity, 0); + if (_items == nullptr) { + delete[] _slots; + delete[] _slotUsed; + delete[] _queue; + delete[] _workers; + _slots = nullptr; + _slotUsed = nullptr; + _queue = nullptr; + _workers = nullptr; + _state = LinkState::Uninitialized; + return LinkResult::error(LinkErrorCode::AllocationFailed, "link queue semaphore failed"); + } + + for (size_t i = 0; i < config.maxConcurrentRequests; ++i) { + _workers[i].owner = this; + _workers[i].index = i; + _workers[i].active = true; + _workers[i].http.eventContext.owner = this; + char name[16]{}; + snprintf(name, sizeof(name), "link-%u", static_cast(i)); + const BaseType_t created = link_task_support::createTask( + &LinkClient::taskEntry, + name, + config.stackSizeBytes, + &_workers[i], + config.priority, + &_workers[i].handle, + config.coreId, + shouldUsePsramStack(), + _workers[i].createdWithCaps + ); + if (created != pdPASS) { + _workers[i].active = false; + { + LinkLock lock(_mutex); + if (lock) { + _state = LinkState::Stopping; + } + } + forceDeinitBlocking(); + return LinkResult::error( + LinkErrorCode::AllocationFailed, + "worker task creation failed" + ); + } + } +#endif + + { + LinkLock lock(_mutex); + if (!lock) { + forceDeinitBlocking(); + return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); + } + _state = LinkState::Running; + } + return LinkResult::ok(); +} + +template LinkResult LinkClient::deinit() { + return deinitInternal(false); +} + +template void LinkClient::forceDeinitBlocking() { + (void)deinitInternal(true); +} + +template void LinkClient::markStopping() { + LinkLock lock(_mutex); + if (lock && _state != LinkState::Uninitialized) { + _state = LinkState::Stopping; + } +} + +template void LinkClient::wakeWorkers() { +#if defined(ESP32) + LinkLock lock(_mutex); + if (!lock || _stopWakeIssued || _items == nullptr) { + return; + } + _stopWakeIssued = true; + for (size_t i = 0; i < _config.maxConcurrentRequests; ++i) { + xSemaphoreGive(_items); + } +#endif +} + +template +LinkResult LinkClient::waitForWorkers(bool waitForever) { +#if defined(ESP32) + uint32_t timeoutMs = _config.defaultTimeoutMs + 100; + if (timeoutMs < _config.defaultTimeoutMs) { + timeoutMs = UINT32_MAX; + } + const uint32_t started = millis(); + while (true) { + bool workersRunning = false; + { + LinkLock lock(_mutex); + if (!lock) { + return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); + } + if (_workers != nullptr) { + for (size_t i = 0; i < _config.maxConcurrentRequests; ++i) { + workersRunning = workersRunning || _workers[i].active; + } + } + } + if (!workersRunning) { + return LinkResult::ok(); + } + if (!waitForever && static_cast(millis() - started) >= timeoutMs) { + return LinkResult::error(LinkErrorCode::Timeout, "timed out waiting for link workers"); + } + link_task_support::delayMs(10); + } +#else + (void)waitForever; + return LinkResult::ok(); +#endif +} + +template +LinkResult LinkClient::freeRuntimeStorage() { + { + LinkLock lock(_mutex); + if (!lock) { + return LinkResult::error(LinkErrorCode::InternalError, "link mutex lock failed"); + } + if (_state == LinkState::Uninitialized) { + return LinkResult::ok(); + } +#if defined(ESP32) + if (_items != nullptr) { + vSemaphoreDelete(_items); + _items = nullptr; + } +#endif + delete[] _slots; + delete[] _slotUsed; + delete[] _queue; + delete[] _workers; + _slots = nullptr; + _slotUsed = nullptr; + _queue = nullptr; + _workers = nullptr; + _queueHead = 0; + _queueTail = 0; + _queueCount = 0; + _stopWakeIssued = false; + _config = LinkConfig{}; + _state = LinkState::Uninitialized; + } + return LinkResult::ok(); +} + diff --git a/src/internal/LinkClientSupportImpl.h b/src/internal/LinkClientSupportImpl.h new file mode 100644 index 0000000..f8460a1 --- /dev/null +++ b/src/internal/LinkClientSupportImpl.h @@ -0,0 +1,248 @@ +#pragma once + +#include + +#if defined(ESP32) +#include +#include +#include +#if __has_include() +#include +#endif +#if __has_include() +#include +#define LINK_HAS_CRT_BUNDLE 1 +#else +#define LINK_HAS_CRT_BUNDLE 0 +#endif +#endif + +namespace link_internal { + +enum class LinkRedirectAction : uint8_t { None, Follow, Error }; + +enum class LinkPersistentReuseDecision : uint8_t { + Create, + Reuse, + OriginChanged, + IdleExpired, + RequestLimitReached, + Poisoned +}; + +struct LinkRedirectDecision { + LinkRedirectAction action = LinkRedirectAction::None; + const char *location = nullptr; + bool stripRequestHeaders = false; + LinkError error; +}; + +struct LinkUrlOrigin { + const char *host = nullptr; + size_t hostSize = 0; + uint16_t port = 0; + bool https = false; + bool valid = false; +}; + +inline char linkLowerAscii(char value) { + return value >= 'A' && value <= 'Z' ? static_cast(value - 'A' + 'a') : value; +} + +inline bool linkAsciiEqual(const char *left, size_t leftSize, const char *right, size_t rightSize) { + if (left == nullptr || right == nullptr || leftSize != rightSize) + return false; + for (size_t i = 0; i < leftSize; ++i) { + if (linkLowerAscii(left[i]) != linkLowerAscii(right[i])) + return false; + } + return true; +} + +inline LinkUrlOrigin linkParseOrigin(const char *url) { + LinkUrlOrigin origin; + if (url == nullptr) + return origin; + + const char *authority = nullptr; + if (std::strncmp(url, "https://", 8) == 0) { + origin.https = true; + origin.port = 443; + authority = url + 8; + } else if (std::strncmp(url, "http://", 7) == 0) { + origin.port = 80; + authority = url + 7; + } else { + return origin; + } + + const char *authorityEnd = authority; + while (*authorityEnd != '\0' && *authorityEnd != '/' && *authorityEnd != '?' && + *authorityEnd != '#') { + if (*authorityEnd == '@') + return origin; + authorityEnd++; + } + if (authority == authorityEnd) + return origin; + + const char *hostBegin = authority; + const char *hostEnd = authorityEnd; + const char *portBegin = nullptr; + if (*hostBegin == '[') { + hostBegin++; + hostEnd = hostBegin; + while (hostEnd < authorityEnd && *hostEnd != ']') + hostEnd++; + if (hostEnd == authorityEnd || hostEnd == hostBegin) + return origin; + const char *afterBracket = hostEnd + 1; + if (afterBracket < authorityEnd) { + if (*afterBracket != ':') + return origin; + portBegin = afterBracket + 1; + } + } else { + for (const char *cursor = authority; cursor < authorityEnd; ++cursor) { + if (*cursor == ':') { + if (portBegin != nullptr) + return origin; + hostEnd = cursor; + portBegin = cursor + 1; + } + } + } + if (hostEnd == hostBegin) + return origin; + + if (portBegin != nullptr) { + if (portBegin == authorityEnd) + return origin; + uint32_t port = 0; + for (const char *cursor = portBegin; cursor < authorityEnd; ++cursor) { + if (*cursor < '0' || *cursor > '9') + return origin; + const uint32_t digit = static_cast(*cursor - '0'); + if (port > 6553U || (port == 6553U && digit > 5U)) + return origin; + port = (port * 10U) + digit; + } + if (port == 0) + return origin; + origin.port = static_cast(port); + } + + origin.host = hostBegin; + origin.hostSize = static_cast(hostEnd - hostBegin); + origin.valid = true; + return origin; +} + +inline bool linkSameOrigin(const LinkUrlOrigin &left, const LinkUrlOrigin &right) { + return left.valid && right.valid && left.https == right.https && left.port == right.port && + linkAsciiEqual(left.host, left.hostSize, right.host, right.hostSize); +} + +inline LinkPersistentReuseDecision linkEvaluatePersistentReuse( + bool hasClient, + bool poisoned, + bool sameOrigin, + uint32_t nowMs, + uint32_t lastUsedAtMs, + uint32_t idleTimeoutMs, + uint32_t requestCount, + uint32_t maxRequestsPerHandle +) { + if (!hasClient) + return LinkPersistentReuseDecision::Create; + if (poisoned) + return LinkPersistentReuseDecision::Poisoned; + if (!sameOrigin) + return LinkPersistentReuseDecision::OriginChanged; + if (idleTimeoutMs != 0 && static_cast(nowMs - lastUsedAtMs) >= idleTimeoutMs) + return LinkPersistentReuseDecision::IdleExpired; + if (maxRequestsPerHandle != 0 && requestCount >= maxRequestsPerHandle) + return LinkPersistentReuseDecision::RequestLimitReached; + return LinkPersistentReuseDecision::Reuse; +} + +inline LinkError +linkPreserveOperationError(const LinkError &operationError, const LinkError &transportError) { + return operationError.code == LinkErrorCode::Ok ? transportError : operationError; +} + +inline bool linkIsRedirectStatus(int status) { + return status == 301 || status == 302 || status == 303 || status == 307 || status == 308; +} + +inline LinkRedirectDecision linkEvaluateRedirect( + const LinkConfig &config, + LinkMethod method, + int status, + const LinkHeaders &headers, + uint8_t redirectCount, + const char *currentUrl +) { + LinkRedirectDecision decision; + if (!config.followRedirects || method != LinkMethod::Get || !linkIsRedirectStatus(status)) { + return decision; + } + + const char *location = headers.get("Location"); + if (location == nullptr || !linkUrlLooksValid(location)) { + return decision; + } + + decision.location = location; + if (redirectCount >= config.maxRedirects) { + decision.action = LinkRedirectAction::Error; + decision.error = {LinkErrorCode::RedirectLimitReached, "redirect limit reached"}; + return decision; + } + + if (std::strlen(location) > config.maxUrlSize) { + decision.action = LinkRedirectAction::Error; + decision.error = {LinkErrorCode::UrlTooLarge, "redirect url is too large"}; + return decision; + } + + const LinkUrlOrigin currentOrigin = linkParseOrigin(currentUrl); + const LinkUrlOrigin redirectOrigin = linkParseOrigin(location); + if (!currentOrigin.valid || !redirectOrigin.valid) { + decision.action = LinkRedirectAction::Error; + decision.error = {LinkErrorCode::RedirectRejected, "redirect origin is invalid"}; + return decision; + } + if (currentOrigin.https && !redirectOrigin.https && !config.allowHttpsToHttpRedirects) { + decision.action = LinkRedirectAction::Error; + decision.error = {LinkErrorCode::RedirectRejected, "https to http redirect rejected"}; + return decision; + } + const bool sameOrigin = linkSameOrigin(currentOrigin, redirectOrigin); + if (!sameOrigin && !config.allowCrossOriginRedirects) { + decision.action = LinkRedirectAction::Error; + decision.error = {LinkErrorCode::RedirectRejected, "cross-origin redirect rejected"}; + return decision; + } + + decision.action = LinkRedirectAction::Follow; + decision.stripRequestHeaders = !sameOrigin; + return decision; +} + +inline bool linkWorkerSignalCapacity(const LinkConfig &config, UBaseType_t &out) { + constexpr UBaseType_t maximum = std::numeric_limits::max(); + if (config.queueSize > maximum || config.maxConcurrentRequests > maximum) { + return false; + } + const UBaseType_t queueSize = static_cast(config.queueSize); + const UBaseType_t workers = static_cast(config.maxConcurrentRequests); + if (queueSize > maximum - workers) { + return false; + } + out = queueSize + workers; + return true; +} + +} // namespace link_internal + diff --git a/tests/host/test_persistent.cpp b/tests/host/test_persistent.cpp new file mode 100644 index 0000000..c0563ec --- /dev/null +++ b/tests/host/test_persistent.cpp @@ -0,0 +1,85 @@ +#include + +#include + +namespace { + +void testDefaultConnectionMode() { + LinkConfig config; + assert(config.connectionMode == LinkConnectionMode::PerRequest); + assert(config.persistentIdleTimeoutMs == 5U * 60U * 1000U); + assert(config.persistentMaxRequestsPerHandle == 0); +} + +void testPersistentReuseDecisions() { + using Decision = link_internal::LinkPersistentReuseDecision; + + assert( + link_internal::linkEvaluatePersistentReuse(false, false, false, 0, 0, 0, 0, 0) == + Decision::Create + ); + assert( + link_internal::linkEvaluatePersistentReuse(true, false, true, 100, 50, 1000, 1, 0) == + Decision::Reuse + ); + assert( + link_internal::linkEvaluatePersistentReuse(true, true, true, 100, 50, 1000, 1, 0) == + Decision::Poisoned + ); + assert( + link_internal::linkEvaluatePersistentReuse(true, false, false, 100, 50, 1000, 1, 0) == + Decision::OriginChanged + ); + assert( + link_internal::linkEvaluatePersistentReuse(true, false, true, 1050, 50, 1000, 1, 0) == + Decision::IdleExpired + ); + assert( + link_internal::linkEvaluatePersistentReuse(true, false, true, 100, 50, 0, 5, 5) == + Decision::RequestLimitReached + ); + assert( + link_internal::linkEvaluatePersistentReuse( + true, + false, + true, + 25, + UINT32_MAX - 25, + 50, + 0, + 0 + ) == Decision::IdleExpired + ); +} + +void testPersistentOriginMatching() { + const link_internal::LinkUrlOrigin first = + link_internal::linkParseOrigin("https://EXAMPLE.com:443/a"); + const link_internal::LinkUrlOrigin second = + link_internal::linkParseOrigin("https://example.com/b"); + const link_internal::LinkUrlOrigin changedPort = + link_internal::linkParseOrigin("https://example.com:444/b"); + const link_internal::LinkUrlOrigin changedScheme = + link_internal::linkParseOrigin("http://example.com/b"); + const link_internal::LinkUrlOrigin ipv6Default = + link_internal::linkParseOrigin("https://[2001:db8::1]/a"); + const link_internal::LinkUrlOrigin ipv6Explicit = + link_internal::linkParseOrigin("https://[2001:DB8::1]:443/b"); + const link_internal::LinkUrlOrigin invalid = + link_internal::linkParseOrigin("https://example.com:70000/a"); + + assert(link_internal::linkSameOrigin(first, second)); + assert(!link_internal::linkSameOrigin(first, changedPort)); + assert(!link_internal::linkSameOrigin(first, changedScheme)); + assert(link_internal::linkSameOrigin(ipv6Default, ipv6Explicit)); + assert(!invalid.valid); +} + +} // namespace + +int main() { + testDefaultConnectionMode(); + testPersistentReuseDecisions(); + testPersistentOriginMatching(); + return 0; +} From f885cac6d1fe98475901def83f1fc0a1537bba6d Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:18:46 +0200 Subject: [PATCH 2/5] Add temporary branch formatter --- .../workflows/autoformat-persistent-http.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/autoformat-persistent-http.yml diff --git a/.github/workflows/autoformat-persistent-http.yml b/.github/workflows/autoformat-persistent-http.yml new file mode 100644 index 0000000..71405a4 --- /dev/null +++ b/.github/workflows/autoformat-persistent-http.yml @@ -0,0 +1,39 @@ +name: Autoformat persistent HTTP branch + +on: + push: + branches: + - feature/persistent-http-client + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - name: Checkout branch + uses: actions/checkout@v4 + with: + ref: feature/persistent-http-client + + - name: Install clang-format + run: sudo apt-get update && sudo apt-get install -y clang-format + + - name: Format sources + run: | + find src tests examples -type f \( -name '*.h' -o -name '*.cpp' -o -name '*.ino' \) -print0 \ + | xargs -0 clang-format -i + + - name: Commit formatting + run: | + if git diff --quiet; then + echo "No formatting changes required" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src tests examples + git commit -m "Format persistent HTTP implementation" + git push From d9d71a7c40aa010b74633973f87cfe50df8def06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:19:03 +0000 Subject: [PATCH 3/5] Format persistent HTTP implementation --- src/Link.h | 20 ++++++++----------- src/internal/LinkClientHttpEventsImpl.h | 16 +++++++-------- src/internal/LinkClientHttpSessionImpl.h | 1 - src/internal/LinkClientImpl.h | 8 ++++---- src/internal/LinkClientLifecycleRuntimeImpl.h | 5 +---- src/internal/LinkClientLifecycleStartImpl.h | 1 - src/internal/LinkClientSupportImpl.h | 1 - 7 files changed, 20 insertions(+), 32 deletions(-) diff --git a/src/Link.h b/src/Link.h index e6b0305..6fe81e4 100644 --- a/src/Link.h +++ b/src/Link.h @@ -498,7 +498,8 @@ template class LinkClient { return headerResult; } if (!request.onResponse.assign(std::forward(callback))) { - return LinkResult::error(LinkErrorCode::CallbackTooLarge, + return LinkResult::error( + LinkErrorCode::CallbackTooLarge, "response callback is too large" ); } @@ -514,7 +515,7 @@ template class LinkClient { template LinkResult post( const char *url, const LinkHeaders &headers, const LinkBodyView &body, Callback &&callback - ) { + ) { Request request; request.method = LinkMethod::Post; request.url = url; @@ -566,7 +567,7 @@ template class LinkClient { template LinkResult postJson( const char *url, const LinkHeaders &headers, const JsonDocument &json, Callback &&callback - ) { + ) { Request request; request.method = LinkMethod::Post; request.url = url; @@ -581,8 +582,7 @@ template class LinkClient { return acceptResult; } if (!request.headers.has("Content-Type")) { - LinkResult contentTypeResult = - request.headers.set("Content-Type", "application/json"); + LinkResult contentTypeResult = request.headers.set("Content-Type", "application/json"); if (!contentTypeResult) { return contentTypeResult; } @@ -694,14 +694,10 @@ template class LinkClient { const char *currentUrl, uint8_t redirectCount ); - esp_http_client_handle_t createHttpClient( - HttpEventContext &context, const char *url, uint32_t timeoutMs - ); + esp_http_client_handle_t + createHttpClient(HttpEventContext &context, const char *url, uint32_t timeoutMs); LinkResult preparePersistentHttpClient( - WorkerRecord &worker, - const char *url, - uint32_t timeoutMs, - esp_http_client_handle_t &client + WorkerRecord &worker, const char *url, uint32_t timeoutMs, esp_http_client_handle_t &client ); void cleanupPersistentHttpClient(WorkerRecord &worker, HttpSessionCleanupReason reason); void cleanupHttpClient(esp_http_client_handle_t client); diff --git a/src/internal/LinkClientHttpEventsImpl.h b/src/internal/LinkClientHttpEventsImpl.h index fcecef2..be4989a 100644 --- a/src/internal/LinkClientHttpEventsImpl.h +++ b/src/internal/LinkClientHttpEventsImpl.h @@ -1,10 +1,7 @@ #if defined(ESP32) template LinkResult LinkClient::preparePersistentHttpClient( - WorkerRecord &worker, - const char *url, - uint32_t timeoutMs, - esp_http_client_handle_t &client + WorkerRecord &worker, const char *url, uint32_t timeoutMs, esp_http_client_handle_t &client ) { WorkerHttpSession &session = worker.http; const link_internal::LinkUrlOrigin origin = link_internal::linkParseOrigin(url); @@ -112,10 +109,12 @@ bool LinkClient::scrubHttpClientRequest( const size_t headerNameSize = std::strlen(headerName); for (size_t previousIndex = 0; previousIndex < i; ++previousIndex) { const char *previousName = headers.nameAt(previousIndex); - if (previousName != nullptr && - link_internal::linkAsciiEqual( - headerName, headerNameSize, previousName, std::strlen(previousName) - )) { + if (previousName != nullptr && link_internal::linkAsciiEqual( + headerName, + headerNameSize, + previousName, + std::strlen(previousName) + )) { alreadyDeleted = true; break; } @@ -241,5 +240,4 @@ esp_err_t LinkClient::httpEventHandler(esp_http_client_even return ESP_OK; } - #endif diff --git a/src/internal/LinkClientHttpSessionImpl.h b/src/internal/LinkClientHttpSessionImpl.h index 779a41c..8c65727 100644 --- a/src/internal/LinkClientHttpSessionImpl.h +++ b/src/internal/LinkClientHttpSessionImpl.h @@ -285,5 +285,4 @@ void LinkClient::cleanupPersistentHttpClient( } } - #endif diff --git a/src/internal/LinkClientImpl.h b/src/internal/LinkClientImpl.h index b8b8706..ae49fe5 100644 --- a/src/internal/LinkClientImpl.h +++ b/src/internal/LinkClientImpl.h @@ -1,8 +1,8 @@ #pragma once -#include "LinkClientSupportImpl.h" -#include "LinkClientLifecycleStartImpl.h" -#include "LinkClientLifecycleRuntimeImpl.h" -#include "LinkClientHttpSessionImpl.h" #include "LinkClientHttpEventsImpl.h" #include "LinkClientHttpRequestImpl.h" +#include "LinkClientHttpSessionImpl.h" +#include "LinkClientLifecycleRuntimeImpl.h" +#include "LinkClientLifecycleStartImpl.h" +#include "LinkClientSupportImpl.h" diff --git a/src/internal/LinkClientLifecycleRuntimeImpl.h b/src/internal/LinkClientLifecycleRuntimeImpl.h index 73652f5..ef6d8dd 100644 --- a/src/internal/LinkClientLifecycleRuntimeImpl.h +++ b/src/internal/LinkClientLifecycleRuntimeImpl.h @@ -215,9 +215,7 @@ void LinkClient::recordRequestCompleted() { } template -void LinkClient::processRequest( - WorkerRecord &worker, QueuedRequest &request -) { +void LinkClient::processRequest(WorkerRecord &worker, QueuedRequest &request) { LinkState currentState = state(); if (currentState == LinkState::Stopping) { invokeCancelled(request); @@ -235,4 +233,3 @@ LinkResult LinkClient::addJsonAccept(LinkHeaders &headers) } return LinkResult::ok(); } - diff --git a/src/internal/LinkClientLifecycleStartImpl.h b/src/internal/LinkClientLifecycleStartImpl.h index 3c87fe5..abf6b4d 100644 --- a/src/internal/LinkClientLifecycleStartImpl.h +++ b/src/internal/LinkClientLifecycleStartImpl.h @@ -263,4 +263,3 @@ LinkResult LinkClient::freeRuntimeStorage() { } return LinkResult::ok(); } - diff --git a/src/internal/LinkClientSupportImpl.h b/src/internal/LinkClientSupportImpl.h index f8460a1..721d5e8 100644 --- a/src/internal/LinkClientSupportImpl.h +++ b/src/internal/LinkClientSupportImpl.h @@ -245,4 +245,3 @@ inline bool linkWorkerSignalCapacity(const LinkConfig &config, UBaseType_t &out) } } // namespace link_internal - From 3387825c17cee1677eb0b03124db09a870d32430 Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:20:15 +0200 Subject: [PATCH 4/5] Remove temporary branch formatter --- .../workflows/autoformat-persistent-http.yml | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .github/workflows/autoformat-persistent-http.yml diff --git a/.github/workflows/autoformat-persistent-http.yml b/.github/workflows/autoformat-persistent-http.yml deleted file mode 100644 index 71405a4..0000000 --- a/.github/workflows/autoformat-persistent-http.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Autoformat persistent HTTP branch - -on: - push: - branches: - - feature/persistent-http-client - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - name: Checkout branch - uses: actions/checkout@v4 - with: - ref: feature/persistent-http-client - - - name: Install clang-format - run: sudo apt-get update && sudo apt-get install -y clang-format - - - name: Format sources - run: | - find src tests examples -type f \( -name '*.h' -o -name '*.cpp' -o -name '*.ino' \) -print0 \ - | xargs -0 clang-format -i - - - name: Commit formatting - run: | - if git diff --quiet; then - echo "No formatting changes required" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src tests examples - git commit -m "Format persistent HTTP implementation" - git push From 4af044ebf384309f4de6a158905225736971f561 Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:45:42 +0200 Subject: [PATCH 5/5] Fix persistent HTTP implementation include order --- src/internal/LinkClientImpl.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/internal/LinkClientImpl.h b/src/internal/LinkClientImpl.h index ae49fe5..9fb97b9 100644 --- a/src/internal/LinkClientImpl.h +++ b/src/internal/LinkClientImpl.h @@ -1,8 +1,11 @@ #pragma once +// These implementation fragments have declaration dependencies; keep this order stable. +// clang-format off +#include "LinkClientSupportImpl.h" +#include "LinkClientLifecycleStartImpl.h" +#include "LinkClientLifecycleRuntimeImpl.h" +#include "LinkClientHttpSessionImpl.h" #include "LinkClientHttpEventsImpl.h" #include "LinkClientHttpRequestImpl.h" -#include "LinkClientHttpSessionImpl.h" -#include "LinkClientLifecycleRuntimeImpl.h" -#include "LinkClientLifecycleStartImpl.h" -#include "LinkClientSupportImpl.h" +// clang-format on