Files
firmware/src/modules/GeofenceModule.h
T
Ben MeadorsandThomas Göttgens ff9a2b1b86 fix(http): give a TLS session the contiguous heap it actually needs (#6960) (#11838)
* fix(http): give a TLS session the contiguous heap it actually needs (#6960)

An HTTPS connection to a no-PSRAM ESP32-S3 fails its handshake with
MBEDTLS_ERR_SSL_ALLOC_FAILED and the client gets a TCP reset. Four things
were in the way, and the first two are the bug.

mbedtls_ssl_setup() makes two independent calloc() calls, one inbound
record buffer and one outbound, each needing its own contiguous block of
16717 bytes. The admission gate compared ESP.getFreeHeap(), which is a sum
over every free block and says nothing about the largest one, so on a
fragmented heap it waved the connection through and let the handshake be
the thing that discovered there was no room.

Size the two record buffers separately. Inbound stays at the RFC maximum
because browsers and our own MQTT and OTA clients receive full-size
records; outbound only ever carries what this device sends, 4 kB at a time
at most. That is 12288 bytes of contiguous heap back per session, which is
the difference between one usable HTTPS connection and two. These are the
ESP-IDF defaults - Espressif's arduino-lib-builder is what turns the
asymmetric split off. It replaces CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN,
which no longer applies: that symbol is `depends on
!MBEDTLS_ASYMMETRIC_CONTENT_LEN`, and mbedTLS dropped it in 3.0.

Then ask the allocator the question mbedTLS is about to ask it, rather
than a different one. The probe holds both record buffers at once, as
setup() does, through the same heap that CONFIG_MBEDTLS_*_MEM_ALLOC pins
mbedTLS to - a plain malloc would be served from PSRAM that mbedTLS never
touches. Not heap_caps_get_largest_free_block(), which walks every block
under the allocator lock and tripped the interrupt watchdog in #11666.
Closed connections are reaped before the measurement, so the library
cannot free a slot behind it and accept into it unmeasured, and so the
probe sees the memory a finished session just returned. The answer is
advisory: the heap can still turn between the probe and setup(), which
makes the failure rare rather than routine.

A smaller outgoing record makes an existing bug much easier to hit.
mbedtls_ssl_write() accepts at most one record per call and returns a
short count for the rest, and neither the server library nor Print::print()
loops on that, so any response body larger than one record was quietly cut
off - /json/nodes has been truncating at 16 kB already. Loop at the bulk
write sites. Looping on the returned count also covers a client that
negotiates a smaller maximum fragment length, which a fixed-size chunker
would not.

/json/nodes also built its whole body in one std::string. A full node DB
is 200 entries of ~240 bytes, and std::string grows by doubling, so it
asked for a 64 kB contiguous block while still holding a 32 kB one, on a
board with ~50 kB of heap, through operator new, which aborts rather than
throws here. Stream it a node at a time instead.

Finally, stop every node in the fleet paying for geofencing. The crossing
table reserved all 256 slots at construction, 4 kB, though a node with no
geofenced waypoints never tracks a crossing at all. Allocate on the first
one instead, stepping the capacity explicitly so there is a size to probe
for, and hand the block back once the last waypoint is gone. The probe
matters because this allocation has moved off the pristine boot heap onto
a live one, where a reserve() that cannot find the block would abort;
a refusal now degrades into the bounded-drop path that already exists.

heltec-v3 builds clean from scratch with the IDF recompiled, and comes out
20,656 bytes smaller in flash and 172 bytes smaller in static RAM. Full
native suite is green at 1414 cases.

* style(http,geofence): cut the added comments to the repo's one-or-two-line rule

The rationale belongs in the commit message and the PR, not in block comments
above every new symbol. No behaviour change; the binary is byte-identical.

* fix(geofence): index the capacity probe write to clear cppcheck uninitdata

* fix(http): stop the /json/nodes body at the first failed write

* fix(geofence): grow crossing state with realloc so a refused allocation drops instead of aborting

* refactor(http): probe through esp_mbedtls_mem_calloc and fold the free-slot scan into reaping

* refactor(geofence): allocate the crossing table once on first use and free it on store changes

* fix(http): probe through mbedtls_calloc so the calls keep C linkage

* fix(http): name the check that refused a TLS session, and sum the right heap

Both failure modes logged the same line, so a refusal on the free-heap
threshold read as fragmentation. The probe returns a verdict now and the
warning names the check that failed.

That threshold also read ESP.getFreeHeap(), the internal heap, while the probe
below it goes through mbedtls_calloc() - PSRAM wherever EXTERNAL_MEM_ALLOC
sends mbedTLS, which [device-ui_base] sets. There is no free-size query on the
allocator, and the handshake's remaining small blocks want a sum rather than a
block to probe for, so the sum asks heap_caps for the same heap.

* fix(http): probe the TLS heap only when a client is waiting for a slot

The probe holds ~21 kB across two callocs and ran whenever a slot was free,
which on a board with nobody connecting is every 50 ms while the web server is
active and every second when it is idle - on exactly the low-heap boards this
branch is for.

It now runs only when a zero-timeout select() on the listen socket says a
client is already in the backlog, which is the question loop() asks a few
microseconds later. With nothing knocking the server drives its open
connections and accepts nothing, so a connection is never accepted past an
unmeasured gate; one that lands between the two select()s waits a tick.

* perf(http): flush /json/nodes a couple of kB at a time

One writeAll() per node is one mbedtls_ssl_write() per node, about 200 of them
for a full node DB. Filling a 2 kB buffer first cuts that by roughly 9x and
still bounds the allocation, which is why the whole-body string went away in
the first place.

* fix(geofence): keep the table-full warning for a full table

A refused malloc shared the one-shot table-full warning, so a momentary heap
dip spent the message that means the 256-entry cap was reached, and the real
thing then never printed. They are separate now, and the allocation failure
throttles rather than latching, because the next position may well find the
4 kB the table needs.

* fix(http): close a waiting client the TLS heap check refuses

* fix(http): hold spiLock only for filesystem calls in the browse and delete handlers

* refactor(http): leave the browse and delete spiLock scoping to #11870

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-09-16 16:52:00 +00:00

78 lines
2.9 KiB
C++

#pragma once
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_WAYPOINT
#include "Observer.h"
#include "WaypointStore.h"
#include "mesh/MeshTypes.h"
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <cstdint>
#include <cstdlib>
#include <memory>
struct GeofenceNotificationEvent {
uint32_t waypointId = 0;
char nodeName[sizeof(meshtastic_User::long_name)] = {};
bool entered = false;
char geofenceName[sizeof(meshtastic_Waypoint::name)] = {};
};
// Tracks other nodes crossing waypoint geofences and emits enter/exit notifications on the waypoint creator's device.
class GeofenceModule : public Observable<const GeofenceNotificationEvent *>
{
public:
GeofenceModule();
enum class Crossing { None, Enter, Exit };
static bool insideRadius(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters);
static bool insideBox(int32_t ptLat_i, int32_t ptLon_i, const meshtastic_BoundingBox &box);
static bool insideAny(int32_t ptLat_i, int32_t ptLon_i, int32_t ctrLat_i, int32_t ctrLon_i, uint32_t radiusMeters,
bool hasBox, const meshtastic_BoundingBox &box);
static bool inside(const meshtastic_Waypoint &wp, int32_t ptLat_i, int32_t ptLon_i);
static bool hasGeofence(const meshtastic_Waypoint &wp);
// Box-only geofences do not require a waypoint center because their corners are absolute.
static bool shouldTrack(const meshtastic_Waypoint &wp, uint8_t notificationPreferences, uint32_t now);
// The first sighting establishes a baseline without notifying.
static Crossing classify(bool firstSighting, bool wasInside, bool isInside, bool notifyOnEnter, bool notifyOnExit);
void evaluatePosition(NodeNum node, const meshtastic_Position &p);
private:
struct CrossingState {
uint64_t key;
bool inside;
};
// malloc()ed, so it must be released with free(), not delete[].
struct CFreeDeleter {
void operator()(CrossingState *p) const noexcept { free(p); }
};
static uint64_t crossingKey(uint32_t waypointId, NodeNum node) { return ((uint64_t)waypointId << 32) | node; }
CrossingState *findCrossingState(uint64_t key);
bool ensureCrossingCapacity();
void notify(const meshtastic_Waypoint &wp, NodeNum node, bool entered);
int onWaypointStoreChanged(const WaypointStore *store);
// Bounded (waypointId, nodeNum) state; new pairs are skipped until an old waypoint frees space.
// Unallocated until the first crossing, so a node with no geofenced waypoints never pays for it.
std::unique_ptr<CrossingState[], CFreeDeleter> crossingInside;
size_t crossingCount = 0;
CallbackObserver<GeofenceModule, const WaypointStore *> waypointStoreObserver =
CallbackObserver<GeofenceModule, const WaypointStore *>(this, &GeofenceModule::onWaypointStoreChanged);
};
extern GeofenceModule *geofenceModule;
#endif