Skip to content

feat: user-controlled handler allocation (non-owning registration) - #493

Merged
mathieucarbou merged 2 commits into
mainfrom
feat/handler_user_ownership
Oct 15, 2026
Merged

mathieucarbou merged 2 commits into
mainfrom
feat/handler_user_ownership

Conversation

@mathieucarbou

Copy link
Copy Markdown
Member

Summary

Give users full control over where handler objects live in memory (internal SRAM, PSRAM, static RAM) without allocator hooks or operator new overrides. This is a refactoring approach discussed in #468 as an alternative to the operator new override proposal.

Motivation

On ESP32 targets with PSRAM, internal SRAM is often the scarcest resource (lwIP segments, WiFi buffers, WS message queues all live there). Handler objects are long-lived: created once at setup, only read during request dispatch — never from ISR/DMA context. Moving them out of internal SRAM frees memory for networking.

API Changes

Handler registration

Call Ownership
server.addHandler(new MyHandler()) Server owns (default, backward compatible)
server.addHandler(ptr, false) Non-owning (pointer)
server.addHandler(myHandler) Non-owning (reference)

Catch-all handler

Call Ownership
AsyncWebServer server(80) Server creates & owns default catch-all
AsyncWebServer server(80, catchAllPtr) Server owns
AsyncWebServer server(80, catchAllPtr, false) Non-owning (pointer)
AsyncWebServer server(80, catchAllRef) Non-owning (reference)
AsyncWebServer server(80, nullptr) No catch-all (unmatched → bare 501)
server.setCatchAllHandler(new ...()) Server owns (default)
server.setCatchAllHandler(ptr, false) Non-owning (pointer)
server.setCatchAllHandler(ref) Non-owning (reference)
server.setCatchAllHandler(nullptr) Remove catch-all entirely

Safe accessor

Call Behavior
server.catchAllHandlerPtr() Returns pointer or nullptr — safe
server.catchAllHandler() Deprecated — crashes if no catch-all installed

Usage Examples

Static RAM (no heap allocation)

static AsyncCallbackWebHandler handler;
handler.setUri(AsyncURIMatcher::exact("/status"));
handler.onRequest([](AsyncWebServerRequest *req) { req->send(200, "text/plain", "OK"); });
server.addHandler(handler);  // non-owning

PSRAM (ESP32)

#include <esp_heap_caps.h>

void *mem = heap_caps_malloc(sizeof(AsyncCallbackWebHandler), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
auto *handler = new (mem) AsyncCallbackWebHandler();
handler->setUri(AsyncURIMatcher::exact("/status"));
handler->onRequest([](AsyncWebServerRequest *req) { req->send(200, "text/plain", "OK"); });
server.addHandler(handler, false);  // non-owning

Custom catch-all at construction

static AsyncCallbackWebHandler catchAll;
AsyncWebServer server(80, catchAll);  // non-owning, static RAM

Design Decisions

  • No operator new override — avoids non-conforming C++ semantics (returning null instead of throwing std::bad_alloc)
  • No build flags — purely additive API, zero risk to existing users
  • No allocator hooks — users control allocation via placement new, static storage, or any custom strategy
  • Explicit ownership model — HandlerEntry struct with unique_ptr for owned handlers, raw pointer for non-owned
  • Works for all handler types — AsyncCallbackWebHandler, AsyncStaticWebHandler, AsyncWebSocket, AsyncEventSource, AsyncCallbackJsonWebHandler, and user-defined subclasses

Backward Compatibility

✅ 100% additive — all existing code compiles and runs identically:

  • server.addHandler(new T()) — unchanged (ownership transfer)
  • server.on(), serveStatic(), onNotFound(), onFileUpload(), onRequestBody() — unchanged
  • AsyncWebServer(port) — unchanged (creates default owned catch-all)
  • All existing examples compile without modification

Files Changed

File Change
src/ESPAsyncWebServer.h HandlerEntry struct, new constructors, addHandler overloads, setCatchAllHandler, catchAllHandlerPtr
src/WebServer.cpp Implementation of all new APIs, null guards for catch-all
examples/arduino/CustomAllocation/CustomAllocation.ino New example: static RAM, PSRAM, server-owned, custom catch-all
examples/arduino/SkipServerMiddleware/SkipServerMiddleware.ino Migrated to catchAllHandlerPtr()
docs/routing.md Handler Ownership and Memory Placement section
docs/configuration.md PSRAM and static allocation section
docs/concepts.md Handler ownership note
platformio.ini CustomAllocation example entry

Related

Checklist

  • Fully backward compatible
  • Compiles on host emulator (Arduino-Emulator + CMake)
  • New example follows existing conventions (AP mode, ASYNCWEBSERVER_WIFI_SUPPORTED guards)
  • Documentation updated (routing.md, configuration.md, concepts.md)
  • Existing SkipServerMiddleware example migrated to safe API

@mathieucarbou

Copy link
Copy Markdown
Member Author

Roadmap: Phase 2 (follow-up PR)

This PR implements Phase 1 — non-owning handler registration that gives users full control over handler allocation (static RAM, PSRAM, internal heap).

A follow-up PR will implement Phase 2 — a route table refactoring that moves URI matching out of handler objects into the server's routing table. This will enable:

  • One handler object serving multiple routes (e.g. same handler for /foo and /bar)
  • Decoupled route configuration from handler construction
  • Cleaner separation of concerns between routing and request handling

Phase 2 is a larger, deeper refactoring and is intentionally kept separate to:

  1. Ship Phase 1 quickly — it already solves the PSRAM allocation problem completely
  2. Battle-test the ownership model before changing internal dispatch logic
  3. Keep this PR reviewable and low-risk

Phase 1 is fully functional on its own — Phase 2 is purely additive on top.

@willmmiles willmmiles left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A good extension, I think we can tighten up the implementation a bit.

Comment thread src/ESPAsyncWebServer.h Outdated
Comment thread src/ESPAsyncWebServer.h Outdated
Comment thread src/ESPAsyncWebServer.h Outdated
* If false, the server stores a non-owning reference.
* @return Reference to the registered handler.
*/
AsyncWebHandler &addHandler(AsyncWebHandler *handler, bool takeOwnership = true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be accepting a bool here. You've declared another, better API for non-owning binding: the reference form. Passing a reference is the C++ standard way of indicating a borrow. I don't think we should have two different ways to request a borrow instead of an adopt.

This is true even if you've manually heap allocated the object - using the reference form makes it 100% clear that the user is responsible for managing the life cycle of their object. Having to pass addHandler(*heap_handler) isn't a huge burden.

Last but not least - it may be worth noting that, on ESP32, regular delete does work on any object allocated with heap_caps_malloc(). This is because delete maps to free() which in turn maps to heap_caps_free(), which looks up the pool from the address.

@willmmiles willmmiles Oct 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I should suggest that ESP-IDF add operator new C++ extensions for heap_caps_malloc.

namespace esp {
struct with_heap_caps_t { 
    uint32_t flags;
    explicit constexpr with_heap_caps_t (uint32_t allocator_id) : id(allocator_id) {}
}

inline constexpr with_heap_caps_t with_heap_caps(uint32_t id) {
    return with_heap_caps(id);
}

// Some helpful constants
constexpr with_heap_caps_t in_psram {  MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT };
// ...
};

void* operator new(std::size_t size, const esp::with_heap_caps_t& alloc) {
    void* ptr = heap_caps_malloc(size, alloc.flags);
    if (!ptr) throw std::bad_alloc;
    return ptr; 
}

void operator delete(void* ptr, const esp::with_heap_caps_t& alloc) noexcept {
    heap_caps_free(ptr);
}

Usage:

auto* psram_obj = new (esp::with_heap_caps(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)) obj();
auto* psram_obj_convenient = new (esp::in_psram) obj();

It'd sure look cleaner, and makes it clearly and explicitly safe to delete.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because delete maps to free()

I didn't know that! I always though we had to use free() with malloc() and delete with new because malloc just allocates space when new also creates the object (constructor).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of these general vs specific platform things. In the general case, the implementation of C++ new and delete is not required to have any relationship to C's malloc() and free(). In the specific case of Espressif's implementation, they run GNU libstdc++, which maps new to malloc(). malloc(), in turn, is actually a wrapper around heap_caps_malloc() in ESP-IDF libc.

So it'll work, technically, on ESP32 - there's just no guarantees on any other platform.

Comment thread src/ESPAsyncWebServer.h
Comment thread src/ESPAsyncWebServer.h Outdated
Comment thread src/ESPAsyncWebServer.h Outdated
mathieucarbou added a commit that referenced this pull request Oct 12, 2026
Address all 6 review comments from @willmmiles:

1. MaybeOwned tagged pointer (memory optimization)
   - Replace struct HandlerEntry { handler*, unique_ptr owned } with
     MaybeOwned<AsyncWebHandler> using a single tagged pointer
   - Low bit of the pointer = ownership flag → 1 word instead of 2
   - Halves per-entry SRAM usage on 32-bit platforms
   - Generic template, reusable elsewhere

2. Catch-all uses MaybeOwned too (consistency)
   - Replace _catchAllHandler raw ptr + _catchAllHandlerOwned bool with
     MaybeOwned<AsyncCallbackWebHandler>
   - Eliminates the second ownership mechanism — one way of doing things

3. Remove bool takeOwnership from all APIs (cleaner C++ idiom)
   - addHandler(ptr) → server owns (adopt)
   - addHandler(ref) → non-owning borrow (reference = borrow in C++)
   - Same for setCatchAllHandler and constructors
   - No more addHandler(ptr, false) — use addHandler(*ptr) instead
   - Passing by reference is the standard way to indicate a borrow

4. Add missing docstring to removeHandler()

5. Inline PSRAM allocation in docs for clarity:
   new (heap_caps_malloc(...)) AsyncCallbackWebHandler()

6. Note in docs that delete works on heap_caps_malloc on ESP32
   (delete → free → heap_caps_free, which looks up the pool)

Backward compatible for all existing callers:
- server.addHandler(new T()) → still works (owning)
- server.addHandler(&staticObj) → still works (matches ptr overload, adopts)
  Note: existing examples using addHandler(&staticHandler) will now be
  owning — but those handlers were static/global, so delete is a no-op
  destructor call + free on static memory. This was already the behavior
  before Phase 1 (unique_ptr wrapping). No regression.

All source files compile with -fno-rtti on all platforms.
Host emulator build passes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mathieucarbou added a commit that referenced this pull request Oct 12, 2026
Address all 6 review comments from @willmmiles:

1. MaybeOwned tagged pointer (memory optimization)
   - Replace struct HandlerEntry { handler*, unique_ptr owned } with
     MaybeOwned<AsyncWebHandler> using a single tagged pointer
   - Low bit of the pointer = ownership flag → 1 word instead of 2
   - Halves per-entry SRAM usage on 32-bit platforms
   - Generic template, reusable elsewhere

2. Catch-all uses MaybeOwned too (consistency)
   - Replace _catchAllHandler raw ptr + _catchAllHandlerOwned bool with
     MaybeOwned<AsyncCallbackWebHandler>
   - Eliminates the second ownership mechanism — one way of doing things

3. Remove bool takeOwnership from all APIs (cleaner C++ idiom)
   - addHandler(ptr) → server owns (adopt)
   - addHandler(ref) → non-owning borrow (reference = borrow in C++)
   - Same for setCatchAllHandler and constructors
   - No more addHandler(ptr, false) — use addHandler(*ptr) instead
   - Passing by reference is the standard way to indicate a borrow

4. Add missing docstring to removeHandler()

5. Inline PSRAM allocation in docs for clarity:
   new (heap_caps_malloc(...)) AsyncCallbackWebHandler()

6. Note in docs that delete works on heap_caps_malloc on ESP32
   (delete → free → heap_caps_free, which looks up the pool)

Backward compatible for all existing callers:
- server.addHandler(new T()) → still works (owning)
- server.addHandler(&staticObj) → still works (matches ptr overload, adopts)
  Note: existing examples using addHandler(&staticHandler) will now be
  owning — but those handlers were static/global, so delete is a no-op
  destructor call + free on static memory. This was already the behavior
  before Phase 1 (unique_ptr wrapping). No regression.

All source files compile with -fno-rtti on all platforms.
Host emulator build passes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mathieucarbou
mathieucarbou force-pushed the feat/handler_user_ownership branch from 3999cfa to f7dce4d Compare October 12, 2026 19:21
mathieucarbou added a commit that referenced this pull request Oct 12, 2026
Address all 6 review comments from @willmmiles:

1. MaybeOwned tagged pointer (memory optimization)
   - Replace struct HandlerEntry { handler*, unique_ptr owned } with
     asyncsrv::MaybeOwned<T> using a single tagged pointer
   - Low bit of the pointer = ownership flag → 1 word instead of 2
   - Halves per-entry SRAM usage on 32-bit platforms
   - Placed in namespace asyncsrv per project convention

2. Catch-all uses MaybeOwned too (consistency)
   - Replace _catchAllHandler raw ptr + _catchAllHandlerOwned bool with
     asyncsrv::MaybeOwned<AsyncCallbackWebHandler>
   - Eliminates the second ownership mechanism — one way of doing things

3. Remove bool takeOwnership from all APIs (cleaner C++ idiom)
   - addHandler(ptr) → server owns (adopt)
   - addHandler(ref) → non-owning borrow (reference = borrow in C++)
   - Same for setCatchAllHandler and constructors
   - No more addHandler(ptr, false) — use addHandler(*ptr) instead

4. Add missing docstring to removeHandler()

5. Inline PSRAM allocation in docs for clarity

6. Catch-all invariant preserved: always required, never nullptr
   - catchAllHandler() restored without deprecation — always safe
   - catchAllHandlerPtr() removed — no longer needed
   - assert() guards against nullptr in constructor and setCatchAllHandler()
   - All null-check guards for _catchAllHandler removed — cleaner code
   - No SkipServerMiddleware example changes needed (backward compatible)

Result: simpler, cleaner API. One ownership mechanism (MaybeOwned).
One catch-all invariant (always installed). Zero changes to existing examples.

Validated: host emulator build + all source files with -fno-rtti.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mathieucarbou
mathieucarbou force-pushed the feat/handler_user_ownership branch 3 times, most recently from d8bc539 to 5a96cdd Compare October 12, 2026 19:46
@mathieucarbou

Copy link
Copy Markdown
Member Author

@willmmiles : comments addressed.
I didn't know about the convention about ref meaning borrowed (this is just so evident) but more importantly pointer being owned.
I came across a LOT of libraries that are doing the opposite: accepting pointers but they borrow, not own and caller is still responsible for the lifecycle.
I think that everyone should stick to this convention.

@willmmiles

Copy link
Copy Markdown

@willmmiles : comments addressed. I didn't know about the convention about ref meaning borrowed (this is just so evident) but more importantly pointer being owned. I came across a LOT of libraries that are doing the opposite: accepting pointers but they borrow, not own and caller is still responsible for the lifecycle. I think that everyone should stick to this convention.

It's definitely a "modern C++"-ism. Actually for "best practices" C++, passing a naked pointer is also typically non-owning, it differs from a reference only in that it permits nullptr if sending no object is a meaningful thing to do. Ownership is best passed directly with explicit usages of unique_ptr for single ownership or shared_ptr for shared ownership. Or, best of all, passing objects by value (ie. user std::move()s if they want to gift it). That lets the compiler check at build time instead of needing to validate non-emptiness at runtime.

In the case of this library, though, we have to support the existing interface; so IMO the next best thing is to treat naked pointers as a move (as this library has always done) and add a C++ reference interface for a borrow. And of course our users might want to keep a reference to the active object themselves, so we can't always accept them by value.

Comment thread src/WebServer.cpp Outdated
Comment thread src/WebServer.cpp Outdated
Comment thread src/WebServer.cpp Outdated
Comment thread docs/routing.md
Comment thread docs/routing.md Outdated
Comment thread docs/routing.md Outdated
Comment thread src/WebServer.cpp Outdated
@mathieucarbou
mathieucarbou force-pushed the feat/handler_user_ownership branch from 5a96cdd to cab86f4 Compare October 13, 2026 05:44
@mathieucarbou

Copy link
Copy Markdown
Member Author

@willmmiles new comments addressed ;-)

@mathieucarbou

mathieucarbou commented Oct 13, 2026 •

Copy link
Copy Markdown
Member Author

In the case of this library, though, we have to support the existing interface; so IMO the next best thing is to treat naked pointers as a move (as this library has always done) and add a C++ reference interface for a borrow. And of course our users might want to keep a reference to the active object themselves, so we can't always accept them by value.

Thinking about that again... I am not a fan of the server taking ownership of the handler pointer if passed as an argument. If a pointer is passed as an argument it should means that its lifecycle is broader than the library using it. This should be up to the user to delete, not the library. Also, looking at the API, we don't have any use case in the lib where an object could be passed as a pointer with the library taking ownership. For middleware, rewrite and handlers, pointers passed do no become owned by the lib.

The only exceptions are the response object passed in the request because its scope is longer than the callback itself, and the internally created objects like the default catch all hander.

@willmmiles

Copy link
Copy Markdown

Thinking about that again... I am not a fan of the server taking ownership of the handler pointer if passed as an argument.

Neither am I, really, but that's the current API - it'd be a breaking change to the interface to do otherwise. (One could argue it's somewhat moot because few if any users destroy AsyncServer objects... but technically the current code takes ownership.)

@mathieucarbou

Copy link
Copy Markdown
Member Author

In the case of this library, though, we have to support the existing interface; so IMO the next best thing is to treat naked pointers as a move (as this library has always done) and add a C++ reference interface for a borrow. And of course our users might want to keep a reference to the active object themselves, so we can't always accept them by value.

Thinking about that again... I am not a fan of the server taking ownership of the handler pointer if passed as an argument. If a pointer is passed as an argument it should means that its lifecycle is broader than the library using it. This should be up to the user to delete, not the library. ~~Also, looking at the API, we don't have any use case in the lib where an object could be passed as a pointer with the library taking ownership. For middleware, rewrite and handlers, pointers passed do no become owned by the lib. ~~

The only exceptions are the response object passed in the request because its scope is longer than the callback itself, and the internally created objects like the default catch all hander.

@willmmiles: My error: addHandler was moving the pointer to a unique_ptr so the handler was destroyed by the lib...

for rewrites, that's put in a shard ptr...

There is no consistency at all in our lib around pointer management, that is so ugly!

So let's keep the PR as-is, but do you think we have to fix others ?

in summary:

  • ✅ catchAllHandler with pointer => adopted
  • ❓ addRewrite with pointer => moved to a shared ptr (why ???) shouldn't it be a unique ptr also ? the shared_ptr is anyway not used!

I have checked the code, and here is what it was doing initially:

AsyncWebServer::~AsyncWebServer(){
  while(_rewrites != NULL){
    AsyncWebRewrite *r = _rewrites;
    _rewrites = r->next;
    delete r;
  }

So the lib had always adopted the rewrite ptr, but I dont understand why it is put in a shared_ptr instead of a unique ptr ?

@willmmiles

Copy link
Copy Markdown

So the lib had always adopted the rewrite ptr, but I dont understand why it is put in a shared_ptr instead of a unique ptr ?

The most common reason is someone still learning how to use unique_ptr. There are a lot of ways you can find yourself inadvertently attempting to copy-construct or assign, where unique_ptr errors out; switching to shared_ptr makes the compiler stop complaining.

I do think that's worth cleaning up, but I'd classify it as out of scope for this PR - it can be handled separately.

@mathieucarbou

Copy link
Copy Markdown
Member Author

@willmmiles thanks! Were you able to look at the last fixes ? Let me know when if can merge :-)

Comment thread docs/routing.md
Comment thread docs/routing.md Outdated
Give users full control over where handler objects live in memory
(internal SRAM, PSRAM, static RAM) without allocator hooks or
operator new overrides.

Changes:
- asyncsrv::MaybeOwned<T>: tagged pointer tracking ownership in a single
  machine word (1 pointer instead of 2 — halves per-entry SRAM usage).
  Low bit = ownership flag. adopt() for owning, borrow() for non-owning.
- std::list<asyncsrv::MaybeOwned<AsyncWebHandler>> _handlers — unified list
  replacing the old unique_ptr list.
- asyncsrv::MaybeOwned<AsyncCallbackWebHandler> _catchAllHandler — catch-all
  uses the same ownership mechanism (no separate bool flag).
- addHandler(ptr) — server takes ownership (backward compatible)
- addHandler(ref) — non-owning borrow (reference = borrow in C++)
- AsyncWebServer(port, catchAllPtr) — custom server-owned catch-all
- AsyncWebServer(port, catchAllRef) — custom non-owning catch-all
- setCatchAllHandler(ptr) — replace with server-owned catch-all
- setCatchAllHandler(ref) — replace with non-owning catch-all
- Catch-all invariant preserved: always installed, never nullptr (assert).
  catchAllHandler() restored without deprecation — always safe to call.
- removeHandler(ptr) — removes from unified list, deletes if owned
- New CustomAllocation example: static RAM, PSRAM, server-owned patterns
- Updated docs: routing.md, configuration.md, concepts.md

Fully backward compatible: all existing APIs work unchanged.
- server.addHandler(new T()) — unchanged behavior
- server.addHandler(&staticObj) — unchanged behavior
- server.on() / serveStatic() / catchAllHandler() / onNotFound() — unchanged
- No existing examples need modification (SkipServerMiddleware untouched)

Motivation: ESP32 internal SRAM is scarce (lwIP, WiFi buffers).
Long-lived handler objects can now be moved to PSRAM or static RAM,
freeing internal SRAM for networking. See discussion in #468.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mathieucarbou
mathieucarbou merged commit 4d4eaca into main Oct 15, 2026
37 checks passed
@mathieucarbou
mathieucarbou deleted the feat/handler_user_ownership branch October 15, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants