Skip to content

[#676] Keep subscribed stand-alone messages alive (Python refcount bridge)#1432

Open
robotrocketscience wants to merge 13 commits into
AVSLab:developfrom
robotrocketscience:feature/bsk-676--message-keepalive-refcount
Open

[#676] Keep subscribed stand-alone messages alive (Python refcount bridge)#1432
robotrocketscience wants to merge 13 commits into
AVSLab:developfrom
robotrocketscience:feature/bsk-676--message-keepalive-refcount

Conversation

@robotrocketscience

Copy link
Copy Markdown
Contributor

[#676] Keep subscribed stand-alone messages alive (Python refcount bridge)

Draft — requesting design feedback before marking ready. @juan-g-bonilla @sassy-asjp — this implements the callback-bridge approach discussed in #676; I'd value your review of the C++ rule-of-five and the GIL handling before this goes further.

Problem

Fixes #676. When Python subscribes a reader to a stand-alone message
(reader.subscribeTo(msg)), the C++ side stores only raw pointers into the message's
payload/header. If the Python msg goes out of scope, it is garbage-collected and the
subscriber reads freed memory — silent undefined behavior. This is the root cause behind the
recurring .disown/keep-a-reference workarounds (e.g. #1107).

Approach

An opaque C-callback bridge, matching @juan-g-bonilla's suggestion in the thread:

  • ReadFunctor<T> gains void* sourceHandle plus acquire/release function pointers
    (plain C types). messaging.h stays free of <Python.h> — a no-Python C/C++ build sees
    null callbacks and pays nothing.
  • The Py_INCREF/Py_DECREF logic lives only in the SWIG layer (newMessaging.ih), in two
    GIL-safe callbacks installed on the functor by the Python subscribeTo wrapper after the
    C++ subscribe completes.
  • ReadFunctor gets a hand-written rule-of-five (destructor releases; copy acquires;
    move transfers; copy-assign releases-then-acquires with self- and pointer-equality guards).
    This is required because a ReadFunctor is usually a C++ module member, so SWIG
    %extend destructors never run for it — only a real C++ destructor does. Moves are
    noexcept so std::vector<ReadFunctor> reallocation moves rather than copies.
  • GIL safety: each callback guards on Py_IsInitialized() (avoids the hang-until-exit at
    interpreter finalization) and brackets the refcount op with PyGILState_Ensure/Release.

Why not the SWIG built-ins

%feature("ref"/"unref"), thisown, %newobject all control who deletes the C++ object
the inverse of what's needed here — and don't fire for C++-instantiated members.

Scope

  • Fixed: C++ ReadFunctor readers subscribing to both C++ Message<T> and C Msg_C
    sources (covers the issue's reproduction and the common module case).
  • Deferred: the Msg_C-as-subscriber case (a C module's dataInMsg). Msg_C is a plain
    C struct with no destructor and no reader/writer distinction, so there is no safe hook to
    drop the reference. This needs its own design (side-table + a SWIG __del__ hook) and is
    left as a follow-up. The existing Removal of .disown in scenarioRoboticArm.py led to angles not being updated #1107 Python-side workarounds remain in place for that case.

Testing

Built and tested on Linux / gcc 16 (CachyOS). New regression test
src/architecture/messaging/_UnitTest/test_standaloneMsgKeepAlive.py subscribes a C++ reader
to a stand-alone message created in a helper scope, forces gc.collect(), runs the sim, and
asserts the data survived (this read freed/garbage memory pre-fix); plus a test that
unsubscribe() releases the keep-alive cleanly.

Verified green (≈214 tests, 0 failures):

Suite Result
test_standaloneMsgKeepAlive (new) 2 passed
messaging/_UnitTest (recorders, unsubscribe, C/C++ paths) 106 passed
module templates 11 passed
spacecraft (effector readers) 8 passed
formationBarycenter / eclipse / simpleNav 13 passed
power modules (std::vector<ReadFunctor> reallocation) 74 passed

SWIG macro note (for reviewers)

The keep-alive comments inside the INSTANTIATE_TEMPLATES macro deliberately avoid literal
# and % characters — inside a SWIG %define macro these are the stringize/directive
operators and corrupt the generated wrappers.

Files

  • src/architecture/messaging/messaging.hReadFunctor members + rule-of-five (Python-free).
  • src/architecture/messaging/newMessaging.ih — GIL-safe callbacks + _install_keepalive +
    subscribeTo hook (both source paths).
  • src/architecture/messaging/_UnitTest/test_standaloneMsgKeepAlive.py — new test.
  • docs/source/Support/bskKnownIssues.rst + release-note snippet.

@schaubh

schaubh commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@robotrocketscience , is this draft PR still needed given the status of #1433 ?

@robotrocketscience

Copy link
Copy Markdown
Contributor Author

This isn't a duplicate of #1433. The two PRs fix opposite subscriber directions:

They edit different subscribeTo paths, so merging #1442 does not fix the C++-reader case in #676.

But I think holding this PR is the right call, for reasons beyond the overlap:

  1. If Transition to smart pointers #282 (smart pointers) is coming soon, a shared_ptr source removes the need for both keep-alive PRs, because the subscription would hold part of the source's lifetime directly. Adding rule-of-five code to messaging.h now would just get removed by Transition to smart pointers #282.
  2. [#676] Keep subscribed stand-alone messages alive (Python refcount bridge) #1432 edits the same generated subscribeTo() template as your Fix plugin message subscriptions in generated bindings #1457, so the two will conflict and need to be reconciled with the BSK-SDK work regardless.
  3. Decouple payload equality headers from messaging.h to fix rebuild cascade #1454 is reducing what lives in messaging.h. [#676] Keep subscribed stand-alone messages alive (Python refcount bridge) #1432 adds to it and brings back the rebuild cascade.

#676 is still a real bug on develop. The hand-retain workarounds in #1431, #918, and #1107 keep coming back, so it does need a fix at some point. My thinking is: keep #1432 as a draft so the reproduction test stays around, and decide based on the #282 timeline. If smart pointers aren't near-term, I'll combine #1432 and #1442 into one keep-alive PR built on whatever #1457 settles for the bindings, instead of carrying two. What are your thoughts?

@schaubh

schaubh commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for the feedback @robotrocketscience. Let's keep both PRs for now and see how the general message wrapping work develops.

@schaubh

schaubh commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Ok, I'll pull this PR as well and do a review of it.

robotrocketscience and others added 13 commits July 11, 2026 13:51
A subscribed ReadFunctor stores only raw pointers into the source message's
payload/header. If the Python source goes out of scope it is garbage collected
and the reader dereferences freed memory (issue AVSLab#676).

Add an opaque keep-alive bridge to ReadFunctor: a void* owner token plus
acquire/release C function pointers, set only from the SWIG layer. messaging.h
stays free of <Python.h> so the C/C++ core still builds without Python headers;
a no-Python build sees null callbacks and pays nothing.

Because a ReadFunctor is normally a C++ module member (where SWIG %extend
destructors never run), the lifetime bookkeeping is done in a hand-written
rule-of-five: the destructor releases, copy acquires, move transfers, and
copy-assign releases-then-acquires with self- and pointer-equality guards.
Moves are noexcept so std::vector<ReadFunctor> reallocation moves rather than
copies. unsubscribe() now also drops the keep-alive reference.
After a C++ reader subscribes (to either a C++ Message or a C Msg_C source),
the Python subscribeTo wrapper now installs the keep-alive on the reader,
incrementing the source object's refcount so it survives until the reader is
destroyed. The acquire/release callbacks are GIL-safe: they guard on
Py_IsInitialized() (avoiding the hang-until-exit at interpreter finalization)
and bracket the refcount op with PyGILState_Ensure/Release.

All Python refcounting lives here in the SWIG layer, keeping messaging.h
Python-free.
Subscribes a C++ reader to a stand-alone message created inside a helper
function scope, forces gc.collect(), then runs the sim and asserts the data
survived. Without the keep-alive this read freed/garbage memory. Also tests
that unsubscribe() releases the keep-alive without crashing.
setSource() unconditionally early-returned when the incoming source matched
the one already held, but _install_keepalive had already taken a Py_INCREF that
the early return then dropped on the floor. Re-subscribing a reader to the same
message therefore leaked one reference per call, which unsubscribe/destruction
(a single release) never balanced. Release the redundant reference in the
no-op branch so the per-call refcount stays balanced.
Update the synthetic CustomMsgReader used by the SWIG interface generator
tests to provide the _install_keepalive() hook expected by generated
subscribeTo() methods.

Track the installed source in the fixture and assert that subscribeTo()
passes the subscribed message to the keepalive hook. This keeps the test
double aligned with the real SWIG %extend implementation and fixes
test_generated_read_functor_without_c_interface_uses_cpp_message.

Also remove a stray merge-conflict marker from bskKnownIssues.rst.

Tests:
- test_generateSWIGModules.py::test_generated_read_functor_without_c_interface_uses_cpp_message
- test_standaloneMsgKeepAlive.py

Result: 3 passed
Route the Python-facing Message.recorder() helper through a SWIG bridge
that installs the source message handle on the recorder-owned ReadFunctor.
This prevents a stand-alone C++ message from being garbage collected while
its recorder still points into the message payload and header.

Delegate source ownership to the existing ReadFunctor keep-alive machinery
so Recorder copies acquire independent references and destruction releases
them correctly. Hide the internal Recorder::setSource() callback bridge
from the generated Python API.

Add a weak-reference regression test that verifies the source message:

- survives garbage collection while its recorder exists
- remains readable through Recorder.UpdateState()
- is released after the recorder is destroyed

Update the BSK-676 known-issue entry and release-note snippet to state that
the automatic keep-alive covers both C++ ReadFunctor subscriptions and
Message.recorder(). C-module input readers remain outside the current
scope.

Tests:
- 115 architecture/messaging unit tests passed
- targeted CModuleTemplateMsgPayload SWIG rebuild passed
…ation

Add a shared runtime-safety check before the ReadFunctor keep-alive
callbacks acquire the GIL. The check verifies that Python is initialized
and, when the active C API exposes it, that interpreter finalization has
not begun.

Gate Py_IsFinalizing() on both Python 3.13+ headers and an compatible
limited-API level. This preserves Basilisk's Python 3.9 stable-ABI support,
where Py_IsFinalizing() is intentionally unavailable, while protecting
non-limited and ABI-3.13+ builds from unsafe PyGILState_Ensure() calls
during shutdown.

Route the initial source increment through the GIL-safe acquire callback
as well. SWIG may release the GIL around _install_keepalive(), so a bare
Py_INCREF there was not guaranteed to be safe.

Add generator-level regression checks for the finalization guard, stable
ABI condition, and guarded source acquisition.

Tests:
- 116 architecture/messaging unit tests passed
- CModuleTemplateMsgPayload SWIG binding rebuilt successfully
Python Message.addSubscriber() returned a C++ ReadFunctor without installing the SWIG keep-alive callbacks. When the source message was created in a local scope, it could be garbage collected while the returned reader still held pointers to its payload and header.

Route the Python method through a SWIG helper that acquires the source PyObject and transfers that ownership token to the reader. Add a weak-reference regression test that verifies the source survives while the reader exists and is released with the reader.
The existing Py_IsFinalizing() check is compiled out when Basilisk targets its default Python 3.9 Stable ABI, even on Python 3.13 and newer. A late ReadFunctor release could therefore call PyGILState_Ensure() after interpreter finalization had begun.

Register a Python atexit callback in every generated message module to set an atomic shutdown flag before finalization. Keep the direct Py_IsFinalizing() guard when the active C API exposes it, and extend the generator regression test to cover both shutdown checks.
ReadFunctor released its Python owner while the ownership token and callback were still installed. Py_DECREF can run a weak-reference callback that re-enters unsubscribe(), allowing the same token to be released twice.

Snapshot and clear the ownership state before invoking the release callback so reentrant cleanup observes an already-released reader. Also remove the incorrect noexcept declarations from move operations because moving BSKLogger or the payload is not guaranteed not to throw; retaining noexcept could terminate the process instead of propagating an exception.

Add a regression test whose source weak-reference callback re-enters unsubscribe() and verifies one callback, one release, and a cleanly unlinked reader.
Note that C++ reader subscriptions now keep their source alive automatically,
and that C-module input messages (Msg_C readers) are not yet covered and still
require retaining the source in a persistent scope (tracked as a follow-up).
Explicitly ignore the ReadFunctor move constructor and copy/move
assignment operators in generated Python interfaces.

Python has no meaningful call shape for these C++ ownership operations.
SWIG consequently discarded them while emitting warning 509 for the move
constructor and warning 362 for the assignment operators once per message
payload, flooding clean-build output.

Keeping these operations available to C++ while excluding them at the SWIG
boundary removes the warnings without globally disabling diagnostic classes
or changing ReadFunctor ownership behavior.

Add a generator regression test verifying that all three ignore directives
are included in generated message interfaces.
Exclude ReadFunctor::setSource() from generated Python bindings.

This method accepts a raw owner token and reference-management callbacks and
is intended solely for the internal SWIG keep-alive bridge. Exposing it as a
public Python method allowed callers to clear the source owner while leaving
the reader linked to its payload and header. The source could then be garbage
collected, recreating the use-after-free fixed by issue AVSLab#676.

Keep setSource() available to the SWIG extension implementation while hiding
it from the generated Python API. Extend the generator regression test to
verify that the ignore directive is emitted.
@schaubh schaubh force-pushed the feature/bsk-676--message-keepalive-refcount branch from 01eb9d5 to 864e5f6 Compare July 12, 2026 02:23
@schaubh schaubh self-assigned this Jul 12, 2026
@schaubh schaubh self-requested a review July 12, 2026 02:24
@schaubh schaubh added enhancement New feature or request build Build system or compilation enhancement labels Jul 12, 2026
@schaubh schaubh added this to Basilisk Jul 12, 2026
@schaubh schaubh moved this to 👀 In review in Basilisk Jul 12, 2026
@schaubh schaubh marked this pull request as ready for review July 12, 2026 02:25
@schaubh schaubh requested a review from a team as a code owner July 12, 2026 02:25

@schaubh schaubh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I pulled this branch, rebased on develop, addressed a range of review feedback that came up. You can see my commits. Thanks for your thoughts on this fix. All tests pass now.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 864e5f6a6f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/architecture/messaging/messaging.h
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build Build system or compilation enhancement enhancement New feature or request

Projects

Status: 👀 In review

Development

Successfully merging this pull request may close these issues.

Subscribed messages which go out of scope get garbage collected and cause undefined behaviour

2 participants