Repository navigation
feat: user-controlled handler allocation (non-owning registration) - #493
Conversation
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:
Phase 2 is a larger, deeper refactoring and is intentionally kept separate to:
Phase 1 is fully functional on its own — Phase 2 is purely additive on top. |
willmmiles
left a comment
There was a problem hiding this comment.
A good extension, I think we can tighten up the implementation a bit.
| * If false, the server stores a non-owning reference. | ||
| * @return Reference to the registered handler. | ||
| */ | ||
| AsyncWebHandler &addHandler(AsyncWebHandler *handler, bool takeOwnership = true); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This is because
deletemaps tofree()
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).
There was a problem hiding this comment.
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.
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>
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>
3999cfa to
f7dce4d
Compare
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>
d8bc539 to
5a96cdd
Compare
|
@willmmiles : comments addressed. |
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 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. |
5a96cdd to
cab86f4
Compare
|
@willmmiles new comments addressed ;-) |
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.
|
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.) |
@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:
I have checked the code, and here is what it was doing initially: 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 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. |
|
@willmmiles thanks! Were you able to look at the last fixes ? Let me know when if can merge :-) |
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>
cab86f4 to
9b832bc
Compare
Summary
Give users full control over where handler objects live in memory (internal SRAM, PSRAM, static RAM) without allocator hooks or
operator newoverrides. This is a refactoring approach discussed in #468 as an alternative to theoperator newoverride 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
server.addHandler(new MyHandler())server.addHandler(ptr, false)server.addHandler(myHandler)Catch-all handler
AsyncWebServer server(80)AsyncWebServer server(80, catchAllPtr)AsyncWebServer server(80, catchAllPtr, false)AsyncWebServer server(80, catchAllRef)AsyncWebServer server(80, nullptr)server.setCatchAllHandler(new ...())server.setCatchAllHandler(ptr, false)server.setCatchAllHandler(ref)server.setCatchAllHandler(nullptr)Safe accessor
server.catchAllHandlerPtr()nullptr— safeserver.catchAllHandler()Usage Examples
Static RAM (no heap allocation)
PSRAM (ESP32)
Custom catch-all at construction
Design Decisions
operator newoverride — avoids non-conforming C++ semantics (returning null instead of throwingstd::bad_alloc)HandlerEntrystruct withunique_ptrfor owned handlers, raw pointer for non-ownedAsyncCallbackWebHandler,AsyncStaticWebHandler,AsyncWebSocket,AsyncEventSource,AsyncCallbackJsonWebHandler, and user-defined subclassesBackward Compatibility
✅ 100% additive — all existing code compiles and runs identically:
server.addHandler(new T())— unchanged (ownership transfer)server.on(),serveStatic(),onNotFound(),onFileUpload(),onRequestBody()— unchangedAsyncWebServer(port)— unchanged (creates default owned catch-all)Files Changed
src/ESPAsyncWebServer.hsrc/WebServer.cppexamples/arduino/CustomAllocation/CustomAllocation.inoexamples/arduino/SkipServerMiddleware/SkipServerMiddleware.inodocs/routing.mddocs/configuration.mddocs/concepts.mdplatformio.iniRelated
ASYNCWEBSERVER_USE_PSRAMbuild flag)Checklist