Skip to content

Fix mdns object lifetimes: don't hold pointers we don't own - #11181

Open
dhalbert wants to merge 1 commit into
adafruit:mainfrom
dhalbert:mdns-lifetime-fixes
Open

Fix mdns object lifetimes: don't hold pointers we don't own#11181
dhalbert wants to merge 1 commit into
adafruit:mainfrom
dhalbert:mdns-lifetime-fixes

Conversation

@dhalbert

@dhalbert dhalbert commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

🤖 Generated with Claude Code

This originally started as a simple fix for #10197 to copy out the IDF-held values on espressif, as was done in raspberrypi, but Claude pointed out several other problems that were all addressed here. - @dhalbert

mdns.RemoteService and mdns.Server both stored borrowed pointers whose owners could die first. This copies the data out instead.

RemoteService held IDF-owned memory (#10197)

RemoteService held an mdns_result_t allocated on the IDF heap. GC finalisers run in heap-address order, and the Server is allocated before the RemoteServices that find() returns, so at VM teardown the Server's __del__ ran first: mdns_free() deletes _mdns_service_semaphore and NULLs it, and the subsequent mdns_query_results_free() then called xSemaphoreTake(NULL, portMAX_DELAY) and hard faulted.

There is no general way to order two finalisers — gc_sweep_run_finalisers() is a single pass in block-address order with no dependency information — so rather than work around the ordering, this removes the dependency. find() copies the fields we expose into the object and frees the IDF results immediately, mirroring what the raspberrypi port already did. RemoteService then needs no finaliser at all.

Server held GC-heap strings across VM resets (#10048)

mdns_server_obj_t stored the hostname and instance_name pointers handed to it by mp_obj_str_get_str(). Those point into the GC heap, which is recycled on VM reset while the web workflow's static mdns_server_obj_t lives on, so /cp/version.json served whatever landed in that memory next — often fragments of REPL input. Both are fixed-size copies now.

raspberrypi published freed TXT records

advertise_service() stashed borrowed txt_records pointers, but lwip stores only srv_txt_cb plus the object and dereferences them later at packet-build time. A collection between advertising and being queried published freed memory onto the wire. This packs owned copies into a single buffer.

TXT records can only arrive through the Python binding, so the GC heap is necessarily available and the object shares its lifetime; assign_txt_records() carries a comment explaining what would break that assumption. This path dates to #8262.

Reject TXT records that can't be honoured

Previously more than 32 records were silently truncated on raspberrypi, and espressif accepted and discarded them entirely. Now raspberrypi raises ValueError: txt_records length must be <= 32 and espressif raises NotImplementedError: txt_records. Both reuse existing translatable strings, so locale/ is unchanged.

Also fixes the advertise_service() docstring signature, which omitted txt_records even though the parameter list documented it.

Testing

Verified on hardware, Metro ESP32-S3 and Pico 2 W:

Check Board Result
#10197find() then Ctrl-D S3 + Pico 2 W no crash
#10048 — hostname/instance_name across VM reset, heap churn, REPL loop S3 + Pico 2 W intact in version.json
TXT records survive gc.collect() before being queried Pico 2 W all 7 correct via avahi-browse
33 records rejected, 32 accepted Pico 2 W ValueError
TXT records rejected S3 NotImplementedError

@dhalbert

dhalbert commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

@thzinc — the TXT record changes here touch the code you added in #8262, so you may want to take a look.

EDIT: This comment was written by Claude before I could review it. @thzinc Don't worry about the changes, they fix problems, just want to keep you informed. - @dhalbert

Two things:

  • advertise_service() was storing the caller's txt_records pointers, which point into GC-heap strings. lwip keeps only srv_txt_cb plus the Server object and dereferences those pointers later at packet-build time, so a collection between advertising and being queried could publish freed memory. This packs owned copies into a single buffer instead.
  • Passing more than 32 records now raises ValueError rather than silently truncating to 32. If you're publishing a large HomeKit TXT set, that's the change most likely to affect you.

@dhalbert
dhalbert requested a review from tannewt August 6, 2026 15:03

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pretty good overall. Just a weird string copy thing


// Copy a NUL-terminated string into a fixed-size buffer, truncating if it
// doesn't fit. src may be NULL, which yields an empty string.
static void copy_string(char *dest, const char *src, size_t dest_len) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doesn't this exist already? strncpy or some such

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

oh yes, I meant to fix that, but there were about a dozen balls in the air.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed by using strlcpy, which exists in newlib but we never happened to use. The Espressif board still needs a tiny helper strlcpy_or_empty because the source string might be NULL.


// Copy a NUL-terminated string into a fixed-size buffer, truncating if it
// doesn't fit. src may be NULL, which yields an empty string.
static void copy_string(char *dest, const char *src, size_t dest_len) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Here too

Comment on lines +336 to +337
// Dropping the old storage is enough; the GC reclaims it. Freeing it here
// could pull it out from under an in-flight srv_txt_cb.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Dropping still means the GC could free it early. I think it'd be better to be precise.

mdns.RemoteService and mdns.Server both stored borrowed pointers whose
owners could die first. Copy the data out instead.

RemoteService held an IDF mdns_result_t allocated on the IDF heap. GC
finalisers run in heap-address order, and the Server is allocated before
the RemoteServices that find() returns, so at VM teardown the Server's
__del__ ran first: mdns_free() deletes _mdns_service_semaphore, and the
subsequent mdns_query_results_free() then called xSemaphoreTake(NULL) and
hard faulted. Copy the fields we expose into the object at find() time
and free the IDF results immediately, mirroring what raspberrypi already
did. RemoteService then needs no finaliser at all, so the ordering
problem stops existing rather than being worked around.

mdns_server_obj_t stored the hostname and instance_name pointers handed
to it by mp_obj_str_get_str(). Those point into the GC heap, which is
recycled on VM reset while the web workflow's static mdns_server_obj_t
lives on, so /cp/version.json served whatever landed in that memory next
-- often fragments of REPL input. Store fixed-size copies instead.

On raspberrypi, advertise_service() stashed borrowed txt_records
pointers that lwip dereferences later from srv_txt_cb() at packet-build
time, so a collection between advertising and being queried published
freed memory. Pack owned copies into a single buffer. TXT records can
only arrive through the Python binding, so the GC heap is necessarily
available; assign_txt_records() carries a warning explaining what would
break that assumption.

Also make both ports reject TXT records they can't honour instead of
silently truncating: more than 32 raises ValueError on raspberrypi, and
espressif raises NotImplementedError rather than accepting and
discarding them. Fix the advertise_service docstring signature, which
omitted txt_records even though the parameter list documented it.
@dhalbert
dhalbert force-pushed the mdns-lifetime-fixes branch from d59be34 to 8886852 Compare August 6, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hard Fault after successful mDNS Query and program exit Bad hostname after going to the REPL when using mdns

2 participants