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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/persistent-http.yml
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions docs/persistent-http.md
Original file line number Diff line number Diff line change
@@ -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.
102 changes: 85 additions & 17 deletions src/Link.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -458,6 +479,7 @@ template <size_t CallbackStorageSize> class LinkClient {

bool isInitialized() const;
LinkState state() const;
LinkDiagnostics diagnostics() const;

LinkResult fetch(const Request &request);

Expand Down Expand Up @@ -560,9 +582,9 @@ template <size_t CallbackStorageSize> 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>(callback))) {
Expand Down Expand Up @@ -615,14 +637,6 @@ template <size_t CallbackStorageSize> class LinkClient {
private:
using QueuedRequest = link_internal::QueuedLinkRequest<CallbackStorageSize>;

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;
Expand All @@ -638,15 +652,71 @@ template <size_t CallbackStorageSize> 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);
void workerLoop(WorkerRecord *worker);
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;
Expand All @@ -656,15 +726,13 @@ template <size_t CallbackStorageSize> 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;
Expand Down
Loading
Loading