libc/atexit: honor registrations made during exit processing - #20129
Open
Junbo-Zheng wants to merge 1 commit into
Open
libc/atexit: honor registrations made during exit processing#20129Junbo-Zheng wants to merge 1 commit into
Junbo-Zheng wants to merge 1 commit into
Conversation
atexit_call_exitfuncs() cached its loop bound on entry
(for (idx = aehead->nfuncs - 1; idx >= 0; idx--)), while
atexit_register() appends new entries at funcs[nfuncs] and bumps nfuncs.
Any function registered by an exit handler via atexit() / on_exit() /
__cxa_atexit() lands above the cached bound and is never invoked, even
though the registration returns OK.
This contradicts the exit(3) documentation that NuttX mirrors verbatim
in its own exit() docstring (libs/libc/stdlib/lib_exit.c):
It is possible for one of these functions to use atexit(3) or
on_exit(3) to register an additional function to be executed
during exit processing; the new registration is added to the
front of the list of functions that remain to be called.
The same restructure closes a second defect: atexit_call_exitfuncs()
read and cleared the task-group-shared ta_exit list without holding
ta_lock, while atexit_register() takes it ("The following must be
atomic"). Entries are now claimed under the lock and the handler is
invoked with the lock released, so a handler re-entering
atexit_register() cannot deadlock (also safe with the non-recursive
nxmutex used here).
Evidence: exit(3) man page, DESCRIPTION -
https://man7.org/linux/man-pages/man3/exit.3.html
NuttX mirrors this passage verbatim in its own exit() docstring --
https://github.com/apache/nuttx/blob/5a209a853ec0dac623a2d5dfa81dea546b22820d/libs/libc/stdlib/lib_exit.c#L65-L70
Before:
```
A handler that registers another function during exit processing
gets a success return from atexit(), but the new function is never
invoked - it lands above the loop bound cached on entry.
```
After:
```
A registration made during exit processing runs before the older
remaining handlers (order A -> B -> C below), matching the exit(3)
guarantee, and the list is consumed under ta_lock.
```
Testing:
Simulated (sim:nsh, CONFIG_LIBC_MAX_EXITFUNS=8).
Build and run:
```
cmake -B build -DBOARD_CONFIG=sim:nsh -GNinja
cmake -S . -B build # after setting CONFIG_LIBC_MAX_EXITFUNS=8
# in build/.config (sim:nsh default is 1)
cmake --build build -j$(nproc)
(echo hello; echo poweroff) | ./build/nuttx
```
"hello" runs the test at the NSH prompt; poweroff terminates the sim.
The test was carried by apps/examples/hello/hello_main.c (scratch only,
not part of this commit); its diff:
```
--- a/examples/hello/hello_main.c
+++ b/examples/hello/hello_main.c
@@ -24,6 +24,7 @@
#include <nuttx/config.h>
#include <stdio.h>
+#include <stdlib.h>
/****************************************************************************
* Public Functions
@@ -33,8 +34,29 @@
* hello_main
****************************************************************************/
+static void handler_b(void)
+{
+ printf("ATEXIT-TEST: handler B called (registered during exit)\n");
+}
+
+static void handler_a(void)
+{
+ int ret;
+
+ printf("ATEXIT-TEST: handler A called\n");
+ ret = atexit(handler_b);
+ printf("ATEXIT-TEST: atexit(handler_b) inside A returned %d\n", ret);
+}
+
+static void handler_c(void)
+{
+ printf("ATEXIT-TEST: handler C called\n");
+}
+
int main(int argc, FAR char *argv[])
{
printf("Hello, World!!\n");
+ atexit(handler_c); /* older entry, must run LAST */
+ atexit(handler_a); /* registers handler_b during exit */
return 0;
}
```
Before the fix:
```
Hello, World!!
ATEXIT-TEST: handler A called
ATEXIT-TEST: atexit(handler_b) inside A returned 0
ATEXIT-TEST: handler C called
```
(handler B is never invoked although its registration returned 0)
After the fix:
```
Hello, World!!
ATEXIT-TEST: handler A called
ATEXIT-TEST: atexit(handler_b) inside A returned 0
ATEXIT-TEST: handler B called (registered during exit)
ATEXIT-TEST: handler C called
```
Assisted-by: Claude Code (GLM-5.3) <claude@anthropic.com>
Signed-off-by: Junbo Zheng <zhengjunbo1@xiaomi.com>
Junbo-Zheng
requested review from
jerpelea,
pussuw and
xiaoxiang781216
as code owners
September 13, 2026 12:38
xiaoxiang781216
approved these changes
Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
atexit_call_exitfuncs()evaluated its loop bound once on entry (for (idx = aehead->nfuncs - 1; idx >= 0; idx--)), whileatexit_register()appends new entries atfuncs[nfuncs]and never decrementsnfuncs. Any function registered by an exit handler viaatexit()/on_exit()/__cxa_atexit()therefore lands above the cached bound and is never invoked -- even though the registration returns OK. This is the standard C++ code path where a function-local static's destructor is registered by__cxa_atexitat construction time, and that construction first runs during exit processing.This contradicts the exit(3) documentation that NuttX mirrors verbatim in its own exit() docstring (libs/libc/stdlib/lib_exit.c):
The fix restructures the consumption loop into
while (aehead->nfuncs > 0): the newest entry is claimed underta_lock, cleared, andnfuncs--frees the slot (a registration made during exit lands exactly there and runs on the next iteration, before the older remaining entries -- the documented semantics); the handler is invoked with the lock released.The same restructure closes a second defect:
atexit_call_exitfuncs()previously read and cleared the task-group-sharedta_exitlist without holdingta_lock, whileatexit_register()takes it ("The following must be atomic"). Claiming entries under the lock closes that data race; calling handlers with the lock released keeps a handler re-enteringatexit_register()deadlock-free on the non-recursivenxmutex.Evidence: exit(3) man page, DESCRIPTION -- https://man7.org/linux/man-pages/man3/exit.3.html
NuttX mirrors this passage verbatim in its own exit() docstring --
nuttx/libs/libc/stdlib/lib_exit.c
Lines 65 to 70 in 5a209a8
Impact
__cxa_atexitfrom a C++ destructor chain) are now actually invoked, in the documented order (newest first, ahead of older remaining handlers). Previously they were silently dropped whileatexit()reported success.ta_lock). No API change.Testing
Simulated (sim:nsh host build on Ubuntu x86-64,
CONFIG_LIBC_MAX_EXITFUNS=8-- the sim default is 1).Build and run:
"hello" runs the test at the NSH prompt; poweroff terminates the sim.
The test was carried by apps/examples/hello/hello_main.c (scratch
only, not part of this PR); its diff:
Before the fix:
(handler B is never invoked although its registration returned 0)
After the fix:
Signed-off-by: Junbo Zheng zhengjunbo1@xiaomi.com