Skip to content

py/modatexit: Add atexit module for deinit functionality. #16063

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

andrewleech
Copy link
Contributor

@andrewleech andrewleech commented Oct 23, 2024

Summary

Edit: I pivoted this MR to instead implement atexit instead. Maybe it should have been a new PR.... Regardless I'll update this with more details later.

Orig:

This PR proposes a pattern for adding a deinit function to any builtin module (including user c modules).

Similar to what I was trying to achieve in #13011 I've run into an issue safely developing a user c module such that it works correctly over soft-reset. In particular, this module needs to know when a soft-reset occurs so that it can re-initialise its global / MP_STATE_VM variables.

These builtin modules already have the ability to register an __init__ function which gets run on import. This PR mirrors this with a __del__ function which (if defined) will be run during soft reset.

I'm also inclined to think this mechanism could be used for a range of other builtin c modules which currently need to be manually listed in each ports main.c to deinit as needed. Using this automatic method could ease maintenance of ther ports.

Similarly, there's a lot of duplication in the soft-reset loop of most ports already; is there a reason the gc_sweep_all(); and mp_module_builtin_deinit(); shouldn't be included in the mp_deinit() function?

Lastly, should this mechanism possibly be extended to include all modules MP_STATE_VM(mp_loaded_modules_dict) to allow any module (inc python and dynamic c) to define a __del__ function that's run on soft-reset?

Testing

I've been testing this with my work to make lvgl work cleanly as a user c module. If this mechanism seems reasonable, I'll also add this functionality to the c example module and try to figure some way to add a test to the coverage tests,

Trade-offs and Alternatives

I initially tried to use the existing gc finaliser mechanisms demonstrated in #13011 however, similar to in the PR, I found it quite non-deterministic. It also required the use of a C class registered as a singleton which the module didn't otherwise have or need. I failed to find a way that ensured this singleton class would be actually deleted with the del function run reliable during the soft restart, it just would not run for me.

I added this as a matching function to the existing builtin init functionality, hoewver looking at cpython the atexit module is a more standard way to handle this kind of thing. I see there's already a sys.atexit micropython extension but it's only a single global function and it's only implemented on the windows port. Adding full atexit functionality that works with a linked list or similar would be more compatible, but would take more flash (to add a module / extend sys.atexit) and ram (holding the linked list) and run-time (to register the atexit functions).

Copy link

github-actions bot commented Oct 23, 2024

Code size report:

   bare-arm:    +0 +0.000% 
minimal x86:  +147 +0.078% 
   unix x64:  +744 +0.087% standard[incl +128(data) +32(bss)]
      stm32:  +272 +0.069% PYBV10[incl +4(bss)]
     mimxrt:  +312 +0.084% TEENSY40
        rp2:  +284 +0.031% RPI_PICO_W[incl +4(bss)]
       samd:  +272 +0.101% ADAFRUIT_ITSYBITSY_M4_EXPRESS[incl +4(bss)]
  qemu rv32:  +316 +0.070% VIRT_RV32[incl +4(bss)]

Copy link
Contributor

@dlech dlech left a comment

Choose a reason for hiding this comment

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

This sounds more like atexit() than __del__. Could we implement it that way instead?

Once nice thing about atexit() is that the call order is deterministic, which could be important in some cases.

py/objmodule.c Outdated
@@ -170,6 +170,21 @@ static const mp_module_delegation_entry_t mp_builtin_module_delegation_table[] =
};
#endif

#if MICROPY_MODULE_BUILTIN_INIT
void mp_module_builtin_deinit() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
void mp_module_builtin_deinit() {
void mp_module_builtin_deinit(void) {

py/objmodule.c Outdated
mp_obj_t dest[2];
mp_load_method_maybe(mp_builtin_module_map.table[i].value, MP_QSTR___del__, dest);
if (dest[0] != MP_OBJ_NULL) {
mp_call_method_n_kw(0, 0, dest);
Copy link
Contributor

Choose a reason for hiding this comment

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

What if this raises an exception?

@andrewleech
Copy link
Contributor Author

This sounds more like atexit() than del. Could we implement it that way instead?

Yeah as I was initially writing up the trade-offs/alternatives section above I was thinking an atexit approach probably would be much better.

I've been thinking about it more, my feeling is it would be quite low overhead to simply extend the existing system.atexit function to store all provided functions in a linked list, rather than being the single slot that gets replaced currently. Then enable that by rom level and integrate into all ports.

If anyone needs it as a separate atexit module for compatibility that could be done as a wrapper in micropython-lib

I'm not sure that there's really any great need to also implement the remove function, just registering functions is probably enough to start with

@stinos
Copy link
Contributor

stinos commented Oct 24, 2024

I've been thinking about it more, my feeling is it would be quite low overhead to simply extend the existing system.atexit function to store all provided functions in a linked list, rather than being the single slot that gets replaced currently. Then enable that by rom level and integrate into all ports.

+1 it's more standard and easier to discover.

py/modsys.c Outdated
struct _m_atexit_node_t *next;
mp_obj_t fn;
} m_atexit_node_t;

// atexit(callback): Callback is called when sys.exit is called.
Copy link
Contributor

@stinos stinos Oct 25, 2024

Choose a reason for hiding this comment

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

Can you update the comment that this is LIFO ?

py/modsys.c Outdated
if (nlr_push(&nlr) == 0) {
mp_call_function_0(MP_STATE_VM(sys_exitfunc)->fn);
} else {
// Uncaught exception: print it out.
Copy link
Contributor

Choose a reason for hiding this comment

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

In CPython a SystemExit would still cause an actual exit, is it worth adding that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These are currently only being run during an exit / soft-restart so I wouldn't think the handling should change?
Though maybe the exception shouldn't be printed out if it's a SystemExit

Copy link
Contributor

@stinos stinos Oct 25, 2024

Choose a reason for hiding this comment

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

so I wouldn't think the handling should change?

It is actually changing: for the unix port an exception called during the atexit callback would result in exit code 1 (because of an 'FATAL: uncaught NLR', maybe not ideal, but at least it's a non-zero exit code and that is what matters). But with this implementation the exception would be swallowed (and printed). That should not change imo and this should remain CPython-compatible in the sense that an exception during atexit is treated as an exception. Note CPython does this:

If an exception is raised during execution of the exit handlers, a traceback is printed (unless SystemExit is raised) and the exception information is saved. After all exit handlers have had a chance to run, the last exception to be raised is re-raised.

But given your question, for other ports I assume raising an exception here would actually change behavior?

Though maybe the exception shouldn't be printed out if it's a SystemExit

That's indeed what CPython does.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, that detail helps thanks! The filter on whether to print the exception is easy enough to implement certainly. Maybe then just overriding the exit code in the exception handler here is enough to broadly match cpython, as opposed to keeping a copy of the exception details to pass them onto any default handler.

Copy link
Contributor

@stinos stinos Oct 29, 2024

Choose a reason for hiding this comment

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

Maybe then just overriding the exit code in the exception handler here is enough to broadly match cpython

Probably, but in the end it would be nicer to fix this properly:

as opposed to keeping a copy of the exception details to pass them onto any default handler.

Ideally the only thing which would be needed is to store nlr.ret_val then if not NULL raise it again after having called all handlers. Should not be an issue code-size-wise. Passing it onto the default handler would then happen automatically because the main code knows how to do that; however, this requires that mp_sys_atexit_execute is being called within execute_from_lexer (or whatever the counterpart is named for the various ports).

This is not the case for the unix port now, nor for the other ports. I don't know why though, and all in all this is maybe not very logical/expected; e.g. for the stm32 port it runs after deinitializing pretty much all of the hardware: is there a specific reason you did it that way? Would it not be more sound if there's not many restrictions to what can run in the atexit handler i.e. can still use hardware if it chooses to etc?

Also if this atexit handler runs within the normal execution phase, you can probably use an normal mp_obj_list_t or similar instead of manually implementing a linked list, which should also reduce code size?

@@ -753,10 +754,7 @@ MP_NOINLINE int main_(int argc, char **argv) {
#endif

#if MICROPY_PY_SYS_ATEXIT
// Beware, the sys.settrace callback should be disabled before running sys.atexit.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This comment was added in 310b3d1

Does anyone know why this would be the case and if it's still relevant?

@andrewleech
Copy link
Contributor Author

I'm going to try reworking to the a cpython compatibly atexit module and check the size difference, see how much overhead this adds at least. It'd be preferable to actually match cpython api rather than this sys extension.

@andrewleech andrewleech force-pushed the module_deinit branch 2 times, most recently from 6404d46 to 0bd99fd Compare November 24, 2024 00:44
@andrewleech andrewleech changed the title py/objmodule: Add builtin deinit functionality. py/modatexit: Add atexit module for deinit functionality. Nov 24, 2024
@dpgeorge dpgeorge added the py-core Relates to py/ directory in source label Feb 8, 2025
@projectgus projectgus self-requested a review February 25, 2025 04:43
@andrewleech andrewleech force-pushed the module_deinit branch 6 times, most recently from 066a498 to 22e87fb Compare March 8, 2025 20:27
@andrewleech andrewleech force-pushed the module_deinit branch 4 times, most recently from 477a563 to 2415ef8 Compare March 12, 2025 00:15
Copy link

codecov bot commented Mar 12, 2025

Codecov Report

Attention: Patch coverage is 92.00000% with 2 lines in your changes missing coverage. Please review.

Project coverage is 98.53%. Comparing base (f315a37) to head (4bd2fa2).

Files with missing lines Patch % Lines
py/modatexit.c 91.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #16063      +/-   ##
==========================================
- Coverage   98.54%   98.53%   -0.01%     
==========================================
  Files         169      170       +1     
  Lines       21877    21902      +25     
==========================================
+ Hits        21558    21581      +23     
- Misses        319      321       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@andrewleech
Copy link
Contributor Author

Try raise SystemExit at the end of the unit test.
Capture the mingw ci script "print-failures job" in separate issue, why it sometimes re-runs tests, other times traceback
Look at

Run python run-tests.py --print-failures
--- "D:/a/micropython/micropython/tests/results\\misc_atexit.py.exp"	2025-03-24 15:45:14.18[6](https://github.com/micropython/micropython/actions/runs/14039499915/job/39305801012?pr=16063#step:11:7)485700 +0000
+++ "D:/a/micropython/micropython/tests/results\\misc_atexit.py.out"	2025-03-24 15:45:14.186485[7](https://github.com/micropython/micropython/actions/runs/14039499915/job/39305801012?pr=16063#step:11:8)00 +0000
@@ -1,3 +1,5 @@
-printed before exit
-atexit: registered last with access to local var
-atexit: registered first
+Traceback (most recent call last):
+  File "D:/a/micropython/micropython/tests/misc/atexit.py", line 4, in <module>
+  File "D:/a/micropython/micropython/tests/misc/atexit.py", line [12](https://github.com/micropython/micropython/actions/runs/14039499915/job/39305801012?pr=16063#step:11:13), in <module>
+AttributeError: 'module' object has no attribute 'register'
+CRASH
\ No newline at end of file

FAILURE D:/a/micropython/micropython/tests/results\misc_atexit.py

pi-anl and others added 8 commits March 26, 2025 15:33
Signed-off-by: Andrew Leech <andrew@alelec.net>
Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
Also improve consistency with the matching functionality in the unix port.

Signed-off-by: Andrew Leech <andrew@alelec.net>
Signed-off-by: Andrew Leech <andrew@alelec.net>
Signed-off-by: Andrew Leech <andrew@alelec.net>
Signed-off-by: Andrew Leech <andrew@alelec.net>
There is no common deinit routine to add support in.

Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
Signed-off-by: Andrew Leech <andrew@alelec.net>
Copy link
Contributor

@projectgus projectgus left a comment

Choose a reason for hiding this comment

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

This implementation looks generally good to me, nice and clean. And potentially very useful!

I have a couple of inline questions. Also I'm a little surprised at the amount of code size impact on some ports given how little code seems to be compiled in (assuming register-only). But maybe that's actually inline with expectations.

@@ -756,6 +757,11 @@ MP_NOINLINE int main_(int argc, char **argv) {
}
}

#if MICROPY_PY_ATEXIT
int atexit_code = mp_atexit_execute();
ret = (atexit_code != 0) ? atexit_code : ret;
Copy link
Contributor

Choose a reason for hiding this comment

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

CPython doesn't seem to update the process exit code based on anything which happens in an atexit handler (I checked by first reading the source and then writing a silly example):

import atexit

def ohno():
    raise SystemExit(99)

atexit.register(ohno)

raise RuntimeError("fail")
❯ python atexit.py
Traceback (most recent call last):
  File "/home/gus/ry/george/tmp/atexit.py", line 8, in <module>
    raise RuntimeError("fail")
RuntimeError: fail
Exception ignored in atexit callback <function ohno at 0x7e1cfef93600>:
Traceback (most recent call last):
  File "/home/gus/ry/george/tmp/atexit.py", line 4, in ohno
    raise SystemExit(99)
SystemExit: 99
❯ echo $status
1

(Similarly if you comment the raise RuntimeError() line, the exit code becomes 0.)

Removing some of the machinery for this might(?) save a small amount of code size on bare metal ports as well, if we're lucky...?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's an interesting point, yeah I assumed we should capture any failures here but for code size and compatibility it's worth trying without

@@ -175,6 +189,11 @@ static int parse_compile_execute(const void *source, mp_parse_input_kind_t input
mp_hal_stdout_tx_strn("\x04", 1);
}

#if MICROPY_PY_ATEXIT
int atexit_code = mp_atexit_execute();
Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't tested this, but placing this here in parse_compile_execute() seems like it might have some unexpected consequences? For example, if you put an atexit.register() call in boot.py then will the handler be run after executing boot.py and before executing main.py?

(I think this draws attention to a shortcoming in the current architecture, which is that AFAIK there's no non-hardware-specific soft reset hook function that all ports can call for things which need cleanup in the soft reset path.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes I've realised similar issues with this location and been testing / looking at other ways to add this cleanly.

I've actually been fighting the urge to undertake a broad refactor & consolidation of soft-reset / shutdown handling in ports to create the "correct" place to run the handler code! I'd rather not block this change behind such a large refactor though.

Perhaps for now I should go back to my earlier plan to just add the execute call to an appropriate place in each main.c
Then they'll all get cleaned up anyway if I get to the refactor.

Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps for now I should go back to my earlier plan to just add the execute call to an appropriate place in each main.c Then they'll all get cleaned up anyway if I get to the refactor.

That sounds like a good approach to me. FWIW, I have also felt this urge... but better to do it as a pure refactor, for sure.

@andrewleech
Copy link
Contributor Author

little surprised at the amount of code size impact

I think this will be similar to my recent PR exposing RingIO, adding python interfaces does seem to be surprisingly large in many cases.
I've been meaning to do some tests to come up with some numbers here on what the minimum size is for a module/class/function these days to help guide discussions on the "cost" of exposing C functionality to python and what might be best interface to use etc.

@andrewleech
Copy link
Contributor Author

@projectgus / @dpgeorge I'm also running into an issue with trying to run the unit test on qemu port, which also applies to running it on any live hardware that connects with pyboard.py behind the scenes;

Basically pyboard.py uses the raw mode and paste to drop the test code onto the target and runs it that way, capturing just the output from the code run in this raw mode session.

I recently added the raise SystemExit at the end of the test to ensure a soft-reboot is triggered, which is working, however the first thing done on the target as part of this soft reset is exiting raw mode which ends the test result capture for run_tests.py so it never receives the print outputs from the atexit functions.

Is there any other tests I can reference that connect over a soft-restart / keeps the raw connection open to capture all the output during this time?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
py-core Relates to py/ directory in source
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants