Skip to content

hal: don't free a funct entry the running thread may still use (delf/unloadrt crash) - #4630

Open
yurc wants to merge 3 commits into
LinuxCNC:masterfrom
SyncTwin:synctwin/hal-delf-quiescence-master
Open

yurc wants to merge 3 commits into
LinuxCNC:masterfrom
SyncTwin:synctwin/hal-delf-quiescence-master

Conversation

@yurc

@yurc yurc commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Symptom

delf of a function from a running thread, followed by unloadrt of its component, kills rtapi_app (halcmd: recv_result 1 failed: Connection reset by peer on unloadrt) or leaves the thread spinning. With a function that takes a noticeable share of the period it happens within the first few cycles.

Cause

The realtime thread walks thread->funct_list without the HAL mutex (thread_task()).

  • hal_del_funct_from_thread() and free_funct_struct() (unloadrt) unlink the entry with list_remove_entry(), which points the removed entry's next/prev at itself (src/hal/hal_lib.c:2757). A thread standing on that entry keeps calling it.
  • The entry is returned to the free list at once (free_funct_entry_struct()), and unloadrt then dlclose()s the module (src/rtapi/uspace_rtapi_main.cc:630) while the thread may still execute its code.

Fix

  • funct_entry_unlink(): take the entry out of the list but keep the entry's own links, so a thread standing on it continues to the rest of the list.
  • thread_wait_quiescent(): with the mutex held, wait until the thread completed two more passes before the entry is freed. The pass counter is the existing beatcnt, now published with a release store after the list walk.
  • Threads not running: no change.
  • Thread does not complete a pass within 1000 periods: the entry is leaked instead of recycled, an error is printed, delf returns -ETIMEDOUT.

Waiting alone is not enough: with the wait but the old list_remove_entry(), every run timed out because the thread looped on the self-linked entry.

Test

tests/hal-delf-live: a component whose function busy-waits 20 µs in a 100 µs thread; loadrt → addf → delf → unloadrt repeated DELF_ITER times (default 200) with threads started, then checks that t.threadbeat still advances. Only halcmd/halcompile commands are used.

Results

Built like the rip-and-test CI job (ubuntu:24.04, --with-realtime=uspace), only this test, 10 runs × 200 cycles each:

tree failed runs
master 056f2fd + test commit 10/10 (rtapi_app dies on unloadrt, cycles 1–7)
master 056f2fd + test + fix 0/10 (2000 cycles, thread beating afterwards)
2.9 fe72abb + test (variant without threadbeat) 10/10 (unloadrt failed, cycles 1–5)
DELF_ITER=200 scripts/rip-environment runtests -v tests/hal-delf-live

Branch

Opened against master. 2.9 has the same bug (reproduced above), but the fix uses the pass counter that only exists since f9ea091 (threadbeat); a 2.9 backport needs a new field in hal_thread_t. I can prepare it if you want it in 2.9.

Why we need it

We swap HAL components in and out while the servo thread keeps running (one component's function is replaced in the same rtapi_app that runs motion and the EtherCAT master, without stopping the machine). That is where we hit this.

Limits

  • uspace only; not built or run on RTAI.
  • Runs were in a container without SCHED_FIFO (non-RT uspace). The race does not depend on RT scheduling, but timing will differ on an RT kernel.
  • init_funct_list (initf) is not touched.

yurc added 2 commits October 4, 2026 11:09
Repeatedly loadrt -> addf -> delf -> unloadrt a component whose
function busy-waits 20 us in a 100 us thread that keeps running.
DELF_ITER (default 200) sets the number of cycles.
The realtime thread walks its funct_list without the HAL mutex.
hal_del_funct_from_thread() and free_funct_struct() (unloadrt) unlinked
the entry with list_remove_entry(), which points the entry's links at
itself, and returned it to the free list at once. A thread standing on
the entry then loops on it or follows a recycled link, and the
following unloadrt dlclose()s code the thread may still run: rtapi_app
dies or the thread hangs.

Unlink the entry keeping its own links, then wait until the thread has
completed two more passes (beatcnt, published with a release store)
before the entry is freed. If the thread does not complete a pass
within 1000 periods the entry is leaked and delf returns -ETIMEDOUT.
@grandixximo

grandixximo commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Interesting use case, just out of curiosity, what are you doing where you need swapping components with the thread running?
Ps:
test.sh fails shellcheck warning

@yurc

yurc commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

2.9 backport, for reference: branch synctwin/hal-delf-quiescence-2.9 (2 commits on 2.9 fe72abb20: test 7cef18d9b, fix 8dee91099).

Differences from this PR:

  • 2.9 has no pass counter, so hal_thread_t gets a private unsigned int beatcnt (no pin), incremented with a release store at the end of each pass in thread_task(). Unlink/wait/leak logic in hal_del_funct_from_thread() and free_funct_struct() is the same as here.
  • Since a shared-memory struct changes, HAL_VER is bumped 0x10 -> 0x11 as the comment in hal_priv.h asks. Note 0x11 was also used on master for a while (a1c1347), so you may prefer a different value. hal_priv.h is not an installed header, so out-of-tree components are not affected.
  • Test: no threadbeat pin in 2.9, so the test runs siggen.0.update in the same thread and checks its output still changes; uint param -> u32 (2.9 halcompile).

Results, same setup as above (ubuntu:24.04 container, uspace without SCHED_FIFO, --enable-werror, only this test, DELF_ITER=200, 10 runs each):

  • 2.9 without the fix: 10/10 fail (unloadrt failed)
  • 2.9 with the fix: 0/10 fail

If you'd rather have this in 2.9 as well, I can open a separate PR against 2.9.

@grandixximo

Copy link
Copy Markdown
Contributor

@yurc did you see my text?

@yurc

yurc commented Oct 4, 2026 via email

Copy link
Copy Markdown
Contributor Author

@grandixximo

Copy link
Copy Markdown
Contributor

from no reply, to two in a row, nice ;-)

@grandixximo

Copy link
Copy Markdown
Contributor

what's your handover sequence, do you halt motion blocks before delf, or does new comp take over nets first?

@grandixximo
grandixximo requested a review from BsAtHome October 4, 2026 11:43
@yurc

yurc commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the approval!

We do break-before-make, and only from a stopped node. The new component never takes over nets while the old one is still on them.

  1. The swap happens only when the node is in PackML Stopped. A program swap during Execute is refused, and stopping the node first drops the enable request, so the drives go out of OP.
  2. Unload: halt any in-flight motion-block player (MC_Halt on its axes) → node enable pin to 0 → delf the node's function from the servo thread → unloadrt the node comp → unload its userspace tag bridge → drop the node's nets (nets outlive unloadrt and would otherwise keep the mc-axis pins claimed).
  3. Load: loadrt the new comp → net → addf → enable.

The base layer stays up the whole time: EtherCAT/cia402, the mc_axis motion blocks, the servo thread. So the swap is fast and the bus never leaves OP. Only the node's own function goes through delf, and that is exactly where the self-looped funct entry bit us.

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.

2 participants