Skip to content

Detach the poll thread when deattach_interrupt runs on it — don't strand its stack#10

Merged
gamelaster merged 1 commit into
pine64:mainfrom
Nursedude:fix-stranded-poll-thread-stacks
Jul 19, 2026
Merged

Detach the poll thread when deattach_interrupt runs on it — don't strand its stack#10
gamelaster merged 1 commit into
pine64:mainfrom
Nursedude:fix-stranded-poll-thread-stacks

Conversation

@Nursedude

Copy link
Copy Markdown
Contributor

Problem

pinedio_deattach_interrupt() skips pthread_join() when it runs on the poll thread itself — the af9bc27 guard, which correctly fixed the self-join deadlock. But skipping reclamation entirely strands the thread: it exits joinable, nothing can ever join it (the pthread_create() in the next pinedio_attach_interrupt() overwrites the only handle), and glibc can neither free nor reuse a joinable thread's stack until join — so every detach-from-callback permanently leaks the thread's ~8 MB stack mapping.

This is exactly the pattern meshtastic firmware hits: its RadioLibInterface ISR calls disableInterrupt() as the first statement of the interrupt callback, which runs on this library's poll thread → deattach-from-self on every radio interrupt → one stranded 8 MB stack per interrupt. Measured in meshtastic/firmware#10468: ~9–12 stacks/min, hundreds of GB of VSZ within days, on every CH341-attached radio (Pi 4 and Pi 5, Debian and Ubuntu builds alike); SPI-attached radios (gpiod path) are unaffected. Full strace/gdb evidence chain in that issue:

  • 180 s trace: 35 clone3, 30 fresh 8,454,144-byte MAP_STACK mmaps, 0 munmaps, all workers exited cleanly
  • gdb at pthread_create: single static control block, handle overwritten per spawn
  • the minority of spawns where deattach happened to run on the main thread joined fine and reused cached stacks — only the self-call branch leaks

Fix

One line plus a comment: in the self-call branch, pthread_detach(pthread_self()). A detached thread's stack is reclaimed by glibc at exit, and the cannot-join-self deadlock the guard was added for stays fixed. The main-thread path is unchanged (still joins).

Testing status

The mechanism is pinned by live tracing on affected hardware (linked issue); I have not yet run a rebuilt binary with this patch. I operate a fleet with three leaking CH341 units (Pi 4 + Pi 5) and three SPI controls and am happy to run a patched build and report VSZ/mmap counts before/after if that helps land this.

Aside, out of scope here: the error path in pinedio_pin_poll_thread() (in_error = true → self-exit) can strand one stack the same way until pinedio_deinit() joins it; a follow-up could apply the same treatment there.

…and its stack

pinedio_deattach_interrupt() skips pthread_join() when called from the
poll thread itself (the af9bc27 self-join-deadlock fix). But skipping
reclamation entirely strands the thread: it exits joinable, nothing can
ever join it (the next attach_interrupt() overwrites the only handle),
and glibc can never free or reuse a joinable thread's stack — ~8 MB of
address space leaked per detach-from-callback.

Callers that detach the interrupt from inside the callback on every
radio interrupt (meshtastic firmware's RadioLibInterface ISR does) leak
at interrupt rate: meshtastic/firmware#10468 measures ~9-12 stacks/min,
hundreds of GB of VSZ within days, on every CH341-attached radio.

pthread_detach(pthread_self()) in that branch lets glibc reclaim the
stack when the thread exits, while the cannot-join-self deadlock stays
fixed. The main-thread deattach path is unchanged (still joins).
@Nursedude

Copy link
Copy Markdown
Contributor Author

Runtime validation on affected hardware — the patch eliminates the leak.

Rebuilt meshtastic firmware 2.7.24 (472b14c, the exact version my fleet runs) with variants/native/portduino.ini's libch341-spi-userspace pin swapped to this PR's commit, deployed on the leaking Pi 4 + CH341 (MESHTOAD) box from meshtastic/firmware#10468, radio live and receiving throughout:

sample 8 MB stack+guard pairs VmSize VmRSS live threads
t0 (+30 s) 394 MB 17 MB 6
t+11 min 8 812 MB 18 MB 7
t+29 min 7 (pool shrank) 804 MB (flat) 19 MB 6

Unpatched, same box, same version: ~9–12 fresh stack pairs per minute, monotonic — 594 pairs / +4.9 GB VSZ at 66 minutes. Patched: a stable pool of ~7–8 (live threads + glibc's detached-stack cache), zero net growth across samples, stacks now demonstrably reused.

(The one-time VmSize step between t0 and t+11 is 7 × 64 MB PROT_NONE glibc malloc-arena reservations — core-count-capped, never committed, unrelated to this bug.)

Also deployed to two Pi 5 + CH341 boxes (Debian trixie rebuild of the same recipe); multi-day soak running — I'll report if anything regresses. From my side this PR is field-validated.

Nursedude added a commit to Nursedude/libch341-spi-userspace that referenced this pull request Jul 10, 2026
…exiting

Deeper fix for the thread-stack leak class (see PR pine64#10 for the minimal
one-liner). Creating and destroying a pthread per attach/detach cycle is
churn no embedded target should pay — on 32-bit router-class platforms
(OpenWrt mips/arm) the strand variant exhausts the address space in
hours, and even leak-free churn costs a stack mmap + clone per radio
interrupt.

The poll thread is now created once per pinedio_init() and PARKS on a
condition variable when the last interrupt detaches, resuming on the
next attach. There is never a mid-life thread to join, so detaching
from within the interrupt callback (which runs on this very thread) is
inherently safe — the self-join hazard af9bc27 guarded against is gone
by construction. The thread terminates exactly once, in
pinedio_deinit() (join, or detach-self in the pathological
deinit-from-callback case).

Behavior change, deliberate: a pinedio_get_input() failure now latches
in_error and keeps polling at the normal cadence (self-healing on a
later good read) instead of silently killing the poll thread until the
next full deinit/init.
@Nursedude

Copy link
Copy Markdown
Contributor Author

t+54 min follow-up: VmSize is byte-identical to the t+29 sample (804,060 kB — zero growth in 25 minutes of active RX), and the two Pi 5 units deployed later are flat at ~327 MB after 30 min each. Soak continues; nothing further unless something regresses.

Nursedude added a commit to Nursedude/openwrt that referenced this pull request Jul 11, 2026
…serspace#10)

Pin libch341-spi-userspace to the fix-stranded-poll-thread-stacks fork
commit (cff5a9f0) until pine64 PR meshtastic#10 merges upstream. Fixes the
firmware#10468 pthread-stack leak on the CH341/USB path, which would hit
the vm.max_map_count ceiling in ~2-3 days on a USB-radio router.

Fork build: matrix trimmed to aarch64_cortex-a53 (OpenWrt One), deploy
job removed (no secrets); artifacts served from the workflow run.
@jp-bennett

Copy link
Copy Markdown
Collaborator

Oooh, the fix is interesting. I'll take a look.

@gamelaster

Copy link
Copy Markdown
Member

looks OK to me. @jp-bennett did you had a chance to test this? Thanks

@jp-bennett

Copy link
Copy Markdown
Collaborator

Have not been able to test yet, but the concept seems sound

@gamelaster
gamelaster merged commit b0694ec into pine64:main Jul 19, 2026
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.

3 participants