Skip to content

fix(mesh): don't assert on malloc() failure in MemoryDynamic::alloc() - #11197

Merged
thebentern merged 4 commits into
meshtastic:developfrom
meshmy:fix/memorydynamic-oom-assert-hang
Jul 25, 2026
Merged

fix(mesh): don't assert on malloc() failure in MemoryDynamic::alloc()#11197
thebentern merged 4 commits into
meshtastic:developfrom
meshmy:fix/memorydynamic-oom-assert-hang

Conversation

@ndoo

@ndoo ndoo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Problem

MemoryDynamic<T>::alloc() (src/mesh/MemoryPool.h) does:

T *p = (T *)malloc(sizeof(T));
assert(p);

#10948/#10951 null-checked allocCopy()/allocZeroed() call sites so callers skip the send/reply/notification on allocation failure instead of dereferencing null. That hardening is unreachable on real OOM here: assert(p) fires one level down, inside alloc(), before any caller sees a return value. The static MemoryPool<T, MaxSize>::alloc() in the same file returns nullptr and logs a warning on exhaustion instead.

On ARCH_STM32WL/BOARD_HAS_PSRAM (packetPool is heap-backed via MemoryDynamic there — see src/mesh/Router.cpp:48-57), assert() failures route through a platform handler. On STM32WL that handler is an infinite while(true); loop (src/platform/stm32wl/main-stm32wl.cpp), not an abort or reset, so a real malloc() failure hangs the calling thread instead of returning nullptr.

Reproduced on wio-e5 hardware: a burst of incoming DMs drained free heap toward zero (LOG_HEAP showing ~1.7-1.8KB free right before the hang), then the device locked up.

Fix

Audited every packetPool allocation call site in the codebase (the only pool MemoryDynamic backs) for null-safety; the two above were the only unguarded ones.

Test plan

  • pio run -e wio-e5 — exercises the MemoryPool.h change.
  • pio run -e tbeam0_7 — exercises the MQTT/UDP-multicast paths (excluded from stm32wl builds).
  • Not verified on physical hardware that this prevents the hang under real OOM (no wio-e5 attached to the machine this was developed on). Draft pending that confirmation.

Related: #11196.

🤝 Attestations

  • I have tested that my proposed changes behave as described.
  • I have tested that my proposed changes do not cause any obvious regressions on the following devices:
    • Heltec (Lora32) V3
    • LilyGo T-Deck
    • LilyGo T-Beam
    • RAK WisBlock 4631
    • Seeed Studio T-1000E tracker card
    • Other (please specify below): wio-e5, tbeam0_7 — build-verified only, not yet flashed to hardware

CodeRabbit review summary

This PR makes heap-backed packet allocation recoverable under out-of-memory conditions:

  • MemoryDynamic<T>::alloc() now logs a warning and returns nullptr when malloc() fails, instead of asserting. Successful allocations retain their existing memory-audit accounting.
  • UDP multicast reception and MQTT downlink handling now drop the incoming message if their UniquePacketPoolPacket allocation fails, rather than dereferencing a null unique pointer.
  • SimRadio::startReceive() now allocates the receive packet before setting isReceiving and before scheduling or handling reception. This prevents a failed initial allocation from leaving the simulated radio marked as busy.

The allocation behavior is relevant to the dynamic packetPool configurations used by Portduino, STM32WL, and PSRAM-enabled boards. The author reports successful builds for wio-e5, tbeam0_7, and native-macos; physical OOM validation remains pending.

No public API declarations changed.

Summary by CodeRabbit

  • Bug Fixes
    • Improved stability when memory allocation fails by safely handling heap exhaustion.
    • Prevented crashes when packet allocation is unavailable during multicast and MQTT message processing.
    • Added warning logging for memory allocation failures.
    • Strengthened simulated radio receive startup to safely return early if packet allocation fails.

MemoryDynamic<T>::alloc() called assert(p) right after malloc(), instead
of returning nullptr like the static MemoryPool<T,N>::alloc() already
does on exhaustion. All of packetPool's callers were already hardened to
null-check allocCopy()/allocZeroed() (meshtastic#10948, meshtastic#10951), but that path is
unreachable on real OOM here: assert() fires first, one level down,
before the caller ever gets a chance to check anything.

On STM32WL (packetPool is MemoryDynamic there - not enough static RAM
for the fixed pool), assert() failures are wrapped to an infinite
`while(true);` loop rather than aborting or resetting, so the thread
just hangs forever instead of returning nullptr. Reproduced on wio-e5
hardware under a burst of incoming DMs, with free heap draining toward
zero right before the hang.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Allocation failures now log or return safely in the memory pool, UDP multicast and MQTT receive handlers, and simulated radio receive setup.

Changes

Memory allocation handling

Layer / File(s) Summary
Handle allocation failure
src/mesh/MemoryPool.h
MemoryDynamic<T>::alloc() logs heap exhaustion and returns nullptr when malloc() fails.
Guard receive-path allocations
src/mesh/udp/UdpMulticastHandler.h, src/mqtt/MQTT.cpp, src/platform/portduino/SimRadio.cpp
Receive handlers and simulated radio setup return early on failed packet allocation before dereferencing packets or updating receive state.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Possibly related PRs

Suggested labels: bugfix

Suggested reviewers: thebentern

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: handling malloc failure in MemoryDynamic::alloc().
Description check ✅ Passed The description covers the problem, fix, test plan, and attestations, matching the template well.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ndoo

ndoo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Flash this PR in the Web Flasher

firmware commit boards expires

Warning

This is an automated, unreviewed CI test build. Back up your device configuration
before flashing, and only flash devices you are able to recover.

Supported boards built by this PR (31)
Device Board Platform
Crowpanel Adv 3.5 TFT elecrow-adv-35-tft esp32-s3
Heltec HT62 heltec-ht62-esp32c3-sx1262 esp32-c3
Heltec Mesh Node 096 heltec-mesh-node-t096 nrf52840
Heltec Mesh Node T1 heltec-mesh-node-t1 nrf52840
Heltec Mesh Node T114 heltec-mesh-node-t114 nrf52840
Heltec V3 heltec-v3 esp32-s3
Heltec V4 heltec-v4 esp32-s3
Meshnology W10 meshnology_w10 esp32-s3
Meshnology W12 meshnology_w12 esp32-s3
Raspberry Pi Pico pico rp2040
Raspberry Pi Pico W picow rp2040
RAK WisMesh Pocket V3 rak_wismesh_pocket nrf52840
RAK WisMesh Pod rak_wismesh_pod nrf52840
RAK WisMesh Repeater Mini V2 rak_wismesh_repeater_mini nrf52840
RAK WisMesh Tag rak_wismeshtag nrf52840
RAK WisBlock 11200 rak11200 esp32
RAK WisBlock 11310 rak11310 rp2040
RAK3312 rak3312 esp32-s3
RAK WisBlock 4631 rak4631 nrf52840
Seeed SenseCAP Mesh-Tracker-X1 seeed_mesh_tracker_X1 nrf52840
Seeed Wio Tracker L1 seeed_wio_tracker_L1 nrf52840
Seeed Xiao NRF52840 Kit seeed_xiao_nrf52840_kit nrf52840
Seeed Xiao ESP32-S3 seeed-xiao-s3 esp32-s3
Station G2 station-g2 esp32-s3
Station G3 station-g3 esp32-s3
LILYGO T-Deck t-deck-tft esp32-s3
LILYGO T-Echo t-echo nrf52840
LILYGO T-Echo Plus t-echo-plus nrf52840
LILYGO T-Impulse Plus t-impulse-plus nrf52840
LilyGo T3-C6 tlora-c6 esp32-c6
Seeed SenseCAP T1000-E tracker-t1000-e nrf52840

Build artifacts expire on 2026-08-23. Updated for c48d573.

meshtastic#10948/meshtastic#10951 null-checked every raw-pointer packetPool.allocCopy()/
allocZeroed() call site, but missed the two spots using the
UniquePacketPoolPacket (unique_ptr) wrapper: UdpMulticastHandler::onReceive()
and MQTT::onReceive() both dereferenced the allocation result unconditionally.

Previously unreachable in practice: MemoryDynamic::alloc() asserted before
ever returning null, so nothing downstream saw it. Fixed alongside that
assert removal (this branch) since these two are now reachable with a
genuine null - same fix as everywhere else, just an unchecked pointer that
turns up under OOM.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>
@ndoo

ndoo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Audited every `packetPool` allocation call site in the codebase for null-safety before/after this change (the only pool this touches - it's the sole `MemoryDynamic` instantiation). Found and fixed two spots that were missed by the earlier #10948/#10951 hardening because they use the `UniquePacketPoolPacket` (unique_ptr) wrapper instead of a raw pointer, so they didn't match the grep patterns those PRs likely used:

  • `src/mesh/udp/UdpMulticastHandler.h` (`onReceive`) - dereferenced `packetPool.allocUniqueCopy()`'s result unconditionally.
  • `src/mqtt/MQTT.cpp` (`onReceive`, the downlink path) - same pattern with `allocUniqueZeroed()`.

Both were harmless before this PR only because `MemoryDynamic::alloc()`'s `assert(p)` made a null result unreachable (it would hang the whole allocating thread first). With that assert removed, these two would have been genuine null-pointer-dereference regressions on any `MemoryDynamic`-backed platform (portduino, STM32WL, BOARD_HAS_PSRAM) under real heap exhaustion. Fixed in the follow-up commit, build-verified on `wio-e5` and (for the MQTT/UDP-multicast code paths specifically) `tbeam0_7`.

Every other `packetPool.allocCopy()`/`allocZeroed()` call site I found was already correctly null-checked (either directly, or via a guarded read of the stored result downstream, e.g. the Telemetry modules' `lastMeasurementPacket`).

@ndoo

ndoo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

and you have to add the summary again in the PR body as I have updated it (which removed your review)

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="meshtastic/firmware"
pr="11197"

echo "=== PR metadata and current body ==="
gh pr view "$pr" --repo "$repo" --json number,title,body,baseRefName,headRefName,files

echo
echo "=== PR diff ==="
gh pr diff "$pr" --repo "$repo" --patch

echo
echo "=== MemoryPool declarations and allocation helpers ==="
ast-grep outline src/mesh/MemoryPool.h --items all --view expanded
rg -n -A45 -B12 'alloc\(|allocUnique|MemoryDynamic|MemoryPool' src/mesh/MemoryPool.h

echo
echo "=== UDP multicast allocation and use context ==="
ast-grep outline src/mesh/udp/UdpMulticastHandler.h --items all --view expanded
rg -n -A55 -B15 'allocUniqueCopy|onReceive' src/mesh/udp/UdpMulticastHandler.h

echo
echo "=== MQTT downlink allocation and use context ==="
ast-grep outline src/mqtt/MQTT.cpp --items all --view expanded
rg -n -A70 -B15 'allocUniqueZeroed|onReceive' src/mqtt/MQTT.cpp

Length of output: 32723


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="meshtastic/firmware"
pr="11197"

echo "=== MemoryDynamic instantiations and packetPool construction ==="
rg -n -A10 -B10 --glob '*.{h,hpp,cpp,cxx,ino}' \
  'MemoryDynamic\s*<|packetPool\s*(=|\(|\{)' src

echo
echo "=== packetPool allocation call sites ==="
rg -n -A4 -B4 --glob '*.{h,hpp,cpp,cxx,ino}' \
  '\bpacketPool\s*\.\s*alloc(?:Copy|Zeroed|UniqueCopy|UniqueZeroed)\s*\(' src

echo
echo "=== Appending CodeRabbit summary while preserving the author body ==="
body_file="$(mktemp)"
trap 'rm -f "$body_file"' EXIT

gh pr view "$pr" --repo "$repo" --json body --jq .body > "$body_file"

if ! grep -q '^## CodeRabbit summary$' "$body_file"; then
  cat >> "$body_file" <<'EOF'

## CodeRabbit summary

`MemoryDynamic<T>::alloc()` no longer asserts after `malloc(sizeof(T))`. On allocation failure, it logs a heap-exhaustion warning and returns `nullptr`, matching the failure contract already used by `MemoryPool<T, MaxSize>::alloc()` and allowing caller-side null checks to handle OOM safely.

The PR also guards the two `UniquePacketPoolPacket` allocation paths that would otherwise dereference a null allocation:
- `UdpMulticastHandler::onReceive()` drops an incoming UDP multicast packet when `packetPool.allocUniqueCopy()` fails.
- MQTT downlink handling drops the message when `packetPool.allocUniqueZeroed()` fails.

Successful allocations retain the existing memory-audit accounting and ownership-transfer behavior. No public API declarations changed.
EOF
fi

gh pr edit "$pr" --repo "$repo" --body-file "$body_file"

echo
echo "=== Confirmed PR body summary section ==="
gh pr view "$pr" --repo "$repo" --json body --jq '.body | split("## CodeRabbit summary") | .[-1]'

Length of output: 32573


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== SimRadio allocation-result control flow ==="
ast-grep outline src/platform/portduino/SimRadio.cpp --items all --view expanded
sed -n '300,385p' src/platform/portduino/SimRadio.cpp

echo
echo "=== Router::send allocation-result control flow ==="
sed -n '450,525p' src/mesh/Router.cpp

echo
echo "=== Router::handleReceived allocation-result control flow ==="
sed -n '1200,1265p' src/mesh/Router.cpp

echo
echo "=== All direct packetPool allocations assigned without an immediately visible guard ==="
rg -n -A25 -B3 --glob '*.{h,hpp,cpp,cxx,ino}' \
  '(?:auto|meshtastic_MeshPacket\s*\*)\s+\w+\s*=\s*packetPool\s*\.\s*alloc(?:Copy|Zeroed)\s*\(' src

Length of output: 19377


@ndoo I found one remaining OOM-path issue:

  • src/platform/portduino/SimRadio.cpp, Lines 327 and 332: startReceive() sets isReceiving = true before assigning receivingPacket = packetPool.allocCopy(*p). With MemoryDynamic::alloc() now returning nullptr, a failed allocation reaches handleReceiveInterrupt(), which returns immediately when receivingPacket == nullptr without resetting isReceiving. The simulated radio can therefore remain permanently marked as receiving after OOM.

    Clear isReceiving when the allocation fails (and avoid scheduling/handling a receive with no packet), for both collision-emulation branches.

The two newly changed unique-pointer call sites are correctly guarded, and the other inspected packetPool allocation paths tolerate null results.

I could not restore the PR-body summary: the workflow blocked gh pr edit, and verification confirms the body remains unchanged. Please add this section manually:

## CodeRabbit summary

`MemoryDynamic<T>::alloc()` no longer asserts after `malloc(sizeof(T))`. On allocation failure, it logs a heap-exhaustion warning and returns `nullptr`, matching the failure contract already used by `MemoryPool<T, MaxSize>::alloc()` and allowing caller-side null checks to handle OOM safely.

The PR also guards the two `UniquePacketPoolPacket` allocation paths that would otherwise dereference a null allocation:
- `UdpMulticastHandler::onReceive()` drops an incoming UDP multicast packet when `packetPool.allocUniqueCopy()` fails.
- MQTT downlink handling drops the message when `packetPool.allocUniqueZeroed()` fails.

Successful allocations retain the existing memory-audit accounting and ownership-transfer behavior. No public API declarations changed.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

startReceive() set isReceiving = true before allocCopy(), so a failed
allocation (now reachable with a real nullptr instead of hanging in
assert()) left the simulated radio permanently marked as receiving:
handleReceiveInterrupt() returns immediately on a null receivingPacket,
before ever reaching the code that would clear isReceiving.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>
@ndoo

ndoo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 9c317f4 — `startReceive()` no longer sets `isReceiving = true` until after confirming `packetPool.allocCopy()` succeeded, for both the collision-emulation and non-emulation branches. Verified with `pio run -e native-macos` (portduino, which also uses the `MemoryDynamic` packetPool and links in SimRadio.cpp/MQTT.cpp/UdpMulticastHandler.h).

@ndoo

ndoo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

Give me a new summary too since there's more changes

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="meshtastic/firmware"
pr="11197"

echo "=== PR metadata, current body, and changed files ==="
gh pr view "$pr" --repo "$repo" --json number,title,body,baseRefName,headRefName,commits,files

echo
echo "=== Current PR diff ==="
gh pr diff "$pr" --repo "$repo" --patch

echo
echo "=== Changed-file structure ==="
for file in \
  src/mesh/MemoryPool.h \
  src/mesh/udp/UdpMulticastHandler.h \
  src/mqtt/MQTT.cpp \
  src/platform/portduino/SimRadio.cpp
do
  if [ -f "$file" ]; then
    echo "--- $file ---"
    ast-grep outline "$file" --items all --view expanded
  fi
done

echo
echo "=== Allocation and OOM-control-flow contexts ==="
rg -n -A55 -B15 \
  'MemoryDynamic|alloc\(|allocUniqueCopy|allocUniqueZeroed|startReceive|isReceiving|receivingPacket|handleReceiveInterrupt' \
  src/mesh/MemoryPool.h \
  src/mesh/udp/UdpMulticastHandler.h \
  src/mqtt/MQTT.cpp \
  src/platform/portduino/SimRadio.cpp

Length of output: 63577


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="meshtastic/firmware"
pr="11197"

echo "=== Full receive-state lifecycle in SimRadio ==="
sed -n '145,205p' src/platform/portduino/SimRadio.cpp
sed -n '300,381p' src/platform/portduino/SimRadio.cpp

echo
echo "=== All isReceiving writes and receive-handler call sites ==="
rg -n -A8 -B8 \
  '\bisReceiving\s*=|handleReceiveInterrupt\s*\(' \
  src/platform/portduino/SimRadio.cpp

echo
echo "=== Base-branch context for the receive handler ==="
git show "origin/develop:src/platform/portduino/SimRadio.cpp" | sed -n '300,381p'

echo
echo "=== MemoryDynamic-backed packetPool selection ==="
rg -n -A16 -B8 \
  'MemoryDynamic\s*<|packetPool' \
  src/mesh/Router.cpp

Length of output: 29202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="meshtastic/firmware"
pr="11197"
body_file="$(mktemp)"
trap 'rm -f "$body_file"' EXIT

gh pr view "$pr" --repo "$repo" --json body --jq '.body' > "$body_file"

python3 - "$body_file" <<'PY'
from pathlib import Path
import sys

path = Path(sys.argv[1])
body = path.read_text()
heading = "## CodeRabbit review summary"

summary = """## CodeRabbit review summary

This PR makes heap-backed packet allocation recoverable under out-of-memory conditions:

- `MemoryDynamic<T>::alloc()` now logs a warning and returns `nullptr` when `malloc()` fails, instead of asserting. Successful allocations retain their existing memory-audit accounting.
- UDP multicast reception and MQTT downlink handling now drop the incoming message if their `UniquePacketPoolPacket` allocation fails, rather than dereferencing a null unique pointer.
- `SimRadio::startReceive()` now allocates the receive packet before setting `isReceiving` and before scheduling or handling reception. This prevents a failed initial allocation from leaving the simulated radio marked as busy.

The allocation behavior is relevant to the dynamic `packetPool` configurations used by Portduino, STM32WL, and PSRAM-enabled boards. The author reports successful builds for `wio-e5`, `tbeam0_7`, and `native-macos`; physical OOM validation remains pending.

No public API declarations changed.
"""

if heading in body:
    start = body.index(heading)
    # Preserve content preceding this maintained summary, replacing its old text.
    body = body[:start].rstrip() + "\n\n" + summary
else:
    body = body.rstrip() + "\n\n" + summary

path.write_text(body)
PY

gh pr edit "$pr" --repo "$repo" --body-file "$body_file"

echo "=== Confirmed updated summary ==="
gh pr view "$pr" --repo "$repo" --json body --jq '.body | split("## CodeRabbit review summary") | .[-1]'

Length of output: 5338


@ndoo Reviewed the latest changes. The SimRadio::startReceive() fix correctly delays isReceiving = true until packetPool.allocCopy() succeeds in both branches. I found no new actionable issues in the current diff.

I attempted to add the expanded summary to the PR body, but gh pr edit is blocked by the workflow restriction; verification confirmed the body was not changed. Please add this section manually:

## CodeRabbit review summary

This PR makes heap-backed packet allocation recoverable under out-of-memory conditions:

- `MemoryDynamic<T>::alloc()` now logs a warning and returns `nullptr` when `malloc()` fails, instead of asserting. Successful allocations retain their existing memory-audit accounting.
- UDP multicast reception and MQTT downlink handling now drop the incoming message if their `UniquePacketPoolPacket` allocation fails, rather than dereferencing a null unique pointer.
- `SimRadio::startReceive()` now allocates the receive packet before setting `isReceiving` and before scheduling or handling reception. This prevents a failed initial allocation from leaving the simulated radio marked as busy.

The allocation behavior is relevant to the dynamic `packetPool` configurations used by Portduino, STM32WL, and PSRAM-enabled boards. The author reports successful builds for `wio-e5`, `tbeam0_7`, and `native-macos`; physical OOM validation remains pending.

No public API declarations changed.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ndoo

ndoo commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai added your summary verbatim.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@ndoo Thanks for adding it verbatim—much appreciated. 🐇

@ndoo
ndoo marked this pull request as ready for review July 24, 2026 17:53
@thebentern thebentern added the bugfix Pull request that fixes bugs label Jul 25, 2026
@thebentern
thebentern added this pull request to the merge queue Jul 25, 2026
Merged via the queue into meshtastic:develop with commit abf217c Jul 25, 2026
99 of 100 checks passed
madeofstown pushed a commit to madeofstown/meshtastic-firmware that referenced this pull request Jul 26, 2026
…meshtastic#11197)

* fix(mesh): don't assert on malloc() failure in MemoryDynamic::alloc()

MemoryDynamic<T>::alloc() called assert(p) right after malloc(), instead
of returning nullptr like the static MemoryPool<T,N>::alloc() already
does on exhaustion. All of packetPool's callers were already hardened to
null-check allocCopy()/allocZeroed() (meshtastic#10948, meshtastic#10951), but that path is
unreachable on real OOM here: assert() fires first, one level down,
before the caller ever gets a chance to check anything.

On STM32WL (packetPool is MemoryDynamic there - not enough static RAM
for the fixed pool), assert() failures are wrapped to an infinite
`while(true);` loop rather than aborting or resetting, so the thread
just hangs forever instead of returning nullptr. Reproduced on wio-e5
hardware under a burst of incoming DMs, with free heap draining toward
zero right before the hang.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* fix: null-check the two allocUnique*() call sites missed by prior audits

meshtastic#10948/meshtastic#10951 null-checked every raw-pointer packetPool.allocCopy()/
allocZeroed() call site, but missed the two spots using the
UniquePacketPoolPacket (unique_ptr) wrapper: UdpMulticastHandler::onReceive()
and MQTT::onReceive() both dereferenced the allocation result unconditionally.

Previously unreachable in practice: MemoryDynamic::alloc() asserted before
ever returning null, so nothing downstream saw it. Fixed alongside that
assert removal (this branch) since these two are now reachable with a
genuine null - same fix as everywhere else, just an unchecked pointer that
turns up under OOM.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* fix(SimRadio): don't leave isReceiving stuck true on allocation failure

startReceive() set isReceiving = true before allocCopy(), so a failed
allocation (now reachable with a real nullptr instead of hanging in
assert()) left the simulated radio permanently marked as receiving:
handleReceiveInterrupt() returns immediately on a null receivingPacket,
before ever reaching the code that would clear isReceiving.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
@ndoo
ndoo deleted the fix/memorydynamic-oom-assert-hang branch August 28, 2026 18:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Pull request that fixes bugs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants