27 KiB
Qt/PySide6 Object Lifetime Guidelines
Contents
- Core lifetime principle
- Prevent memory leaks
- Be extremely careful with caches
- Weak references are not a universal fix
- Prevent premature garbage collection
- Qt parent ownership must be intentional
- Closing is not always destruction
- Signals and slots
- Timers
- Event filters
- Long-lived controllers must not own transient UI
- Plugin and registry design
- Dialog and message-box lifetime
- Packaged and compiled builds
- Required lifetime review
- Lifetime diagnostics
- Stress-test transient UI
- Do not mask lifetime bugs
- Preserve PySide6 wrapper safety
- Code review expectation
- Acceptance criteria
Purpose
For long-running Qt/PySide6 applications, object lifetime management is a critical correctness requirement.
Any change involving QObject, QWidget, QDialog, QAction, QMenu, QTimer, signals/slots, controllers, registries, caches, or dynamically created UI objects must be reviewed for lifetime behavior.
Treat memory leaks, stale object retention, dangling Qt wrappers, and premature garbage collection as serious bugs.
1. Core Lifetime Principle
Every QObject-derived object should have an intentional owner, lifetime, and destruction strategy. Every signal/callback edge that can extend that lifetime must also have an intentional retention and cleanup strategy.
A packaged PySide6 feature can involve three overlapping lifetime systems:
- Python wrappers, callables, closures, and reference ownership;
- Qt/C++ parent ownership, signal dispatch, deferred deletion, and native destruction;
- compiler/runtime compatibility retention, including Nuitka's protection of selected compiled callbacks.
Correct parentage in one system does not prove that the other two match the intended logical lifetime.
Reconstruct that model from the checked-out source, tests, packaging configuration, and verified runtime behavior before applying this document. These guidelines are maintained architectural memory, not an authority that can make stale claims true. When evidence contradicts a rule, follow the evidence, correct the narrowest affected guidance, and keep version-sensitive observations explicitly scoped.
For each dynamically created object, determine which category it belongs to.
Long-lived objects
Examples:
- main windows;
- persistent pages/views;
- application-wide controllers;
- shared managers/services.
These are expected to live for most or all of the process lifetime. They may have stable ownership through application globals, a long-lived parent QObject, or a controller/service owner.
Do not repeatedly recreate long-lived objects unnecessarily.
Reusable windows/dialogs
These may be opened and closed multiple times while intentionally reusing the same instance.
They require:
- an explicit strong Python owner;
- a clear hide/show lifecycle;
- reset/update logic when reopened.
Do not accidentally destroy a reusable dialog after every close. Do not keep multiple duplicate instances if only one is intended.
Transient windows/dialogs
These should be created for one interaction and destroyed afterward. Examples include temporary editors, confirmation dialogs, information dialogs, and one-shot configuration dialogs.
Transient objects should not remain strongly referenced after closing. Where appropriate:
- use
WA_DeleteOnClose; - call the correct base close implementation;
- remove owning references after destruction;
- stop timers and remove event filters;
- ensure long-lived services do not retain callbacks to them.
Do not blindly apply WA_DeleteOnClose to every dialog. Use it only when the intended lifecycle is truly transient.
2. Prevent Memory Leaks
When modifying or adding Qt UI code, always consider whether the object can remain alive after it should have been destroyed.
Pay special attention to strong references created by:
- module-level lists, dictionaries, and sets;
- application globals;
- controllers;
- plugin/capability registries;
- factories;
- object pools;
- translation registries;
- signal/slot connections;
- event filters;
- timers;
QActionandQMenuownership;- closures;
- bound methods;
- partial functions;
- lambda captures;
- caches.
A closed dialog should not remain reachable indefinitely unless reuse is intentional.
3. Be Extremely Careful With Caches
Do not use unbounded caches on instance methods of transient UI objects.
A dangerous example:
class Editor:
@functools.lru_cache(None)
def groupBoxSequence(self):
...
The cache key contains self, which can retain the entire editor instance and its Qt child tree indefinitely.
For transient QWidget, QDialog, and QObject instances, prefer:
- normal instance attributes;
- immutable cached metadata;
- class-level/static caches that do not contain live instances;
- weak-reference-based structures where appropriate.
Before adding @lru_cache, @cache, or module-level caches, verify that no transient QObject instance can become part of a cache key or cached value.
4. Weak References Are Not a Universal Fix
When using weakref, verify that:
- weak-reference callbacks do not capture the object strongly;
- bound methods do not accidentally keep the instance alive;
- dead weak references are cleaned up;
- iteration over weak pools handles destroyed objects safely;
- another unrelated container is not still holding a strong reference.
A weak-reference registry cannot fix a leak if another object still owns the widget strongly.
5. Prevent Premature Garbage Collection
Lifetime bugs can happen in the opposite direction as well. Never assume that calling .show() automatically gives a top-level window a safe Python lifetime.
Dangerous example:
def show_editor(self):
editor = ServerEditor()
editor.show()
After show_editor() returns, the local variable may be the final Python reference. Depending on Qt/PySide6 ownership semantics, the wrapper may then be garbage-collected and the window may immediately disappear, be destroyed unexpectedly, behave inconsistently, or cause wrapper/native-object errors later.
For non-modal top-level widgets/windows, ensure a durable owner exists. Suitable strategies include:
- storing the window as an instance attribute;
- maintaining a managed collection of open windows;
- assigning an appropriate
QObjectparent; - removing stored references when the object emits
destroyed; - using
exec()for genuinely modal dialogs.
Do not make every window global merely to solve this problem. Choose ownership according to the intended lifecycle.
6. Qt Parent Ownership Must Be Intentional
Qt parent/child ownership is not equivalent to application lifecycle correctness. A transient dialog parented to a long-lived widget may remain alive even after being closed.
For every dynamically created widget, ask:
- Who is the
QObjectparent? - How long does the parent live?
- Does closing destroy the child or only hide it?
- Should the child survive for reuse?
- Does Python also retain a reference?
Do not automatically parent every transient dialog to a main window or another application-lifetime widget.
7. Closing Is Not Always Destruction
Calling .close() does not necessarily mean an object is destroyed.
Review behavior involving:
closeEvent;accept;reject;done;hide;deleteLater;WA_DeleteOnClose.
Custom lifecycle overrides must call their base implementation when required. An incorrect closeEvent override can suppress normal QDialog lifecycle signals such as finished, accepted, and rejected.
Do not silently replace Qt lifecycle behavior unless intentional.
8. Signals and Slots
Signals and slots can create subtle lifetime relationships. When a transient widget connects to a long-lived object, review whether that connection affects lifetime or can invoke a destroyed receiver.
Examples of long-lived senders include controllers, shared managers/services, application globals, plugin managers, and persistent timers.
Use Qt's automatic QObject disconnection where sufficient. When it is not sufficient:
- disconnect explicitly;
- remove callbacks;
- stop timers;
- remove event filters;
- use weak-reference-safe callback patterns.
Avoid unnecessary manual disconnect boilerplate when Qt already manages the connection safely.
Nuitka/PySide6 compiled bound-method retention
Native PySide6 and a Nuitka-compiled application do not necessarily have the same
Python-callable retention graph. In the locally selected and inspected toolchain
(Nuitka 4.1.3, PySide6 6.8.3), Nuitka's standard PySide6 package configuration patches
SignalInstance.connect() and QTimer.singleShot(). When the callback is a compiled
bound method, the generated post-import code protects it in a process-global list named
_protected and may also expose its underlying function on the receiver class. The
compiled runtime does not necessarily publish that list as a PySide6 module
attribute. This protection keeps the bound receiver strongly reachable. Repeated
transient receivers can therefore grow for the whole packaged-process lifetime even
when native CPython destroys them.
The same protection pattern exists in current upstream Nuitka source. Related PySide6 workaround behavior is documented for earlier Nuitka/PySide6 combinations, but do not assume an exact introduction version without checking the selected release. Always inspect the package configuration installed in the environment being shipped.
The following is prohibited for a transient or repeatedly created receiver:
sender.signal.connect(transientReceiver.handleSignal)
QTimer.singleShot(0, transientReceiver.finishWork)
Replacing the slot with a lambda or partial is not safe if it strongly captures the
receiver:
sender.signal.connect(lambda: transientReceiver.handleSignal())
In Furious, use the canonical weak dispatcher:
connectWeakly(
sender.signal,
transientReceiver,
'handleSignal',
sender=sender,
)
connectWeakly() has this current contract:
- the connected dispatcher is a plain callable object, not the receiver's bound method;
- receiver and optional sender are stored only through
weakref.ref; - the method is resolved by its string name only when the signal is emitted;
shiboken6.isValid()is checked before accessing aQObjectwrapper;forwardSender=Trueexplicitly passes the sender instead of depending onQObject.sender();_ownsQObject(receiver, sender)walks from the sender through itsparent()chain;- when that walk shows that the sender is not the receiver or its descendant,
sender=makes either endpoint's destruction release dispatch and both cleanup hooks; - that cleanup captures only opaque connection handles and calls
QtCore.QObject.disconnect(connection); it deliberately does not capture either QObject or the sender'sSignalInstance, which may wrap an already-deleted sender; - releasing both cleanup hooks prevents short-lived senders from accumulating callbacks on a surviving receiver; verify both destruction orders over repeated cycles;
- the method name is static and must remain valid for the receiver's lifetime.
Pass the sender whenever it is outside the receiver's QObject subtree. Omitting it can
leave safe no-op dispatchers attached to a long-lived sender even though the weak
receiver itself is gone. When the sender is the receiver or a child/descendant, Qt tree
destruction already removes the connection and the extra destroyed-receiver hook is
unnecessary. Supplying sender= still keeps only a weak sender reference.
singleShotWeakly(milliseconds, receiver, 'methodName') uses the same named weak
dispatcher without a sender. If the receiver wrapper is gone or its native QObject is
invalid when the timer fires, delivery becomes a no-op.
Direct signal connections are not categorically wrong. They are appropriate when the sender and receiver have a bounded, intentionally shared lifetime—for example, a persistent page and its child controls/timers, or process-lifetime controllers whose connection is created once. A transient or repeatedly created receiver must not be passed as a compiled bound method in packaged-sensitive code. Closures and partials require inspection of their captures and their sender's lifetime; a plain function that captures only immutable operation data is different from one that captures a transient widget or bound method.
9. Timers
Every QTimer should have an intentional owner and stop policy.
For transient widgets:
- do not leave timers active after close;
- parent timers appropriately;
- stop them when their owning feature is destroyed if needed.
A timer connected to a bound method of a transient object can indirectly contribute to lifetime problems.
Furious uses parented instance timers for persistent/shared-lifetime controls and
workers. For deferred work on transient or repeated receivers, use
singleShotWeakly() rather than passing the compiled bound method to
QTimer.singleShot(). A parentless timer is acceptable only when a durable non-Qt
owner retains it and its explicit disposal path stops and deletes it.
10. Event Filters
Whenever calling installEventFilter(...), verify the corresponding lifetime relationship.
A long-lived filtered object can retain or invoke an event filter unexpectedly. Remove event filters explicitly when appropriate.
Current examples include application/main-window policy and temporary theme snapshot
overlays. The former removes the filter during application cleanup; the latter removes
it when animation completes, is interrupted, or the watched window changes state.
Keep both sides' destruction order and shiboken6.isValid() checks explicit.
11. Long-lived Controllers Must Not Own Transient UI
Application-wide controllers/services should generally own state, orchestration, and reusable services.
They should generally not strongly own transient dialogs, temporary editors, page-local widgets, or short-lived message boxes.
UI objects may observe controller state. Controllers should not become accidental lifetime owners of transient UI.
Long-running resources belong to the service that schedules them, not to a transient page callback. Furious's current profile-test and subscription services parent their manager-side QObjects/pools to durable owners, cross worker results back to the owning Qt thread, and reject stale generations/identities. Their shutdown is idempotent, but not uniformly bounded: subscription preparation drains cooperatively and waits synchronously after its diagnostic timeout. Preserve ownership until workers finish; do not confuse a diagnostic timeout with a termination deadline.
12. Plugin and Registry Design
Plugin registries and capability registries should normally store:
- classes;
- factories;
- immutable metadata;
- configuration;
- descriptors.
Prefer not to store live QWidget or QObject instances unless the architecture explicitly requires persistent instances. Editor registries should generally register editor classes/factories rather than instantiated editors.
Furious's plugin registry deliberately owns process-lifetime plugin/capability instances and descriptors. A separate navigation manager owns successfully created persistent plugin pages, which are also parented into the main navigation tree. Failed factory results are deleted; the registry itself must not retain created editor or dialog instances.
13. Dialog and Message-box Lifetime
Transient message boxes and confirmation dialogs require special care.
For modal dialogs using exec(), local ownership may be sufficient because execution blocks until completion.
For non-blocking dialogs using .show() or .open():
- retain a strong reference while visible;
- release it at the lifecycle boundary appropriate to the dialog type.
Never rely on an unreferenced local variable for an asynchronous dialog. Also verify repeated dialog creation does not cause memory growth.
finished is not native destruction
For a one-shot dialog using WA_DeleteOnClose, finished reports that the interaction
ended; it does not prove that Qt has destroyed the native object. Native deletion is
deferred. If an asynchronous registry releases the final Python reference at
finished, the wrapper can disappear before Qt completes deletion, or a stale wrapper
can survive after the native object is gone.
Use this sequence for transient delete-on-close dialogs:
- create a unique opaque lifetime token;
- insert
token -> dialoginto the open-dialog registry before callingopen(); - allow
accept,reject, or window close to emitfinished; - keep the strong registry entry while
WA_DeleteOnCloseschedules native deletion; - observe
destroyed; - remove the token on the next event-loop turn.
Callbacks that schedule registry cleanup must capture only the opaque token, never the
dialog. An identifier derived from id(dialog) is weaker because object IDs can be
reused. Operation-specific context may be released at finished after its callbacks
run, provided the lifetime registry still retains the dialog itself through
destruction.
Reusable dialogs follow a different policy. A reusable dialog is normally hidden at
finished, not deleted, so its temporary open-dialog registry entry may be released at
finished while its deliberate owner continues to retain it. Do not add
WA_DeleteOnClose merely to make cleanup uniform.
Furious implements these policies through AppQDialog, AppQTransientDialog, and
AppQMessageBox. Their asynchronous open() paths share the AppQDialog lifetime
registry; message-box presentation behavior must delegate ownership to that base path
rather than introduce a parallel registry.
AppQMainWindow uses a distinct visible-window registry. show() inserts an opaque
token-to-window entry so an otherwise unowned top-level wrapper remains alive;
accepted close removes it and destroyed provides fallback cleanup. This registry is
not the deliberate owner of a reusable window: TextEditorWindow, for example, is
retained by its application owner across ordinary close/show cycles and explicitly
destroyed at owner shutdown. A one-shot top-level using WA_DeleteOnClose must be
reviewed against any callbacks or work that must survive the accepted close; do not
blindly copy the dialog finished rule or assume the visible-window registry replaces
an application owner.
14. Packaged and Compiled Builds Require Extra Caution
Object lifetime behavior that appears acceptable under native Python can expose problems more clearly in packaged or compiled builds.
When lifetime issues are suspected, test both where practical:
- native Python execution;
- the project's packaged/compiled executable.
Do not assume operating-system task-manager memory alone proves a leak. Distinguish between actual live-object growth, Python allocator high-water marks, Qt/native memory caching, and retained native resources.
The strongest evidence of a real leak is continued growth in live object/resource counts after repeated create/close cycles.
For Nuitka/PySide6 signal-retention work, include a compiled diagnostic that records
the size of Nuitka's _protected callback list before and after repeated cycles when
the compiled runtime exposes that internal diagnostic. It is not a production API and
may be hidden even though the post-import protection is active. Combine it with
QObject.destroyed, weak references, open-dialog registry size, operation-context
counts, and shiboken6.isValid() checks. Zero protected-list growth alone is not proof
of correct destruction, a missing counter is not zero growth, and stable process memory
alone is not proof of no leak.
Treat the exact retention workaround as version-sensitive. The repository currently
ships several PySide6 versions across platform artifacts, while the inspected local
development combination is PySide6 6.8.3 with Nuitka 4.1.3. Re-inspect the selected
Nuitka package configuration and run the representative compiled probe when a release
toolchain changes; do not generalize one combination's private _protected visibility
or behavior to every native or packaged build.
15. Required Lifetime Review for UI Changes
Whenever a change creates or modifies dynamically managed Qt objects, explicitly review:
- Who creates the object?
- Who owns the Python reference?
- Who is the
QObjectparent? - How long should it live?
- How is it closed?
- How is it destroyed?
- Can any cache retain it?
- Can any controller/registry retain it?
- Does it have active timers?
- Does it install event filters?
- Does it connect to long-lived signals?
- Can it disappear because the final Python reference is lost?
- Which Qt thread owns it and performs final deletion?
- If it is asynchronous, what are its cancellation, supersession, and stale-result rules?
- Do replies, workers, pools, threads, queued callbacks, animations, and effects have one terminal cleanup path?
Do not consider the implementation complete until these questions have clear answers.
16. Lifetime Diagnostics
When investigating suspected lifetime issues, use targeted diagnostics where useful:
weakref.ref;weakref.finalize;QObject.destroyed;- live instance counters;
gc.get_referrers;gc.get_objects;tracemalloc;- repeated open/close stress tests.
Temporary diagnostics should be removed or minimized after the issue is understood. Do not keep large debug frameworks in production merely to compensate for unclear ownership.
17. Stress-test Transient UI
For reusable/transient UI infrastructure, prefer repeated lifecycle tests rather than testing only one open/close operation.
Representative procedure:
- Record baseline live-object counts.
- Open the dialog/editor.
- Exercise
accept,reject, and window-close paths where applicable. - Repeat 20–100 times.
- Verify objects intended to die are destroyed.
- Verify weak references clear.
- Verify relevant pools/registries return to baseline.
- Verify memory reaches a stable plateau rather than growing linearly.
For shared editor infrastructure, test multiple editor/dialog types rather than only one.
Run the same representative probe natively and as a standalone Nuitka build when the code uses PySide6 signals or asynchronous transient dialogs. Vary protocol/editor order so one family cannot hide a shared-registry or cached-callback defect. Required results are:
- every expected
destroyedsignal fires; - weak wrappers, open-dialog registries, and operation contexts return to zero;
- no wrapper becomes invalid before the close path completes;
- the registry still holds each transient dialog when
finishedis dispatched; - Nuitka's protected callback collection has zero growth when observable; otherwise, the compiled probe has zero retained wrappers and the selected package configuration confirms that the dispatcher is not an eligible protected bound method;
- no per-cycle
gc.collect()is needed to obtain those results.
18. Do Not Mask Lifetime Bugs
The following are not acceptable as standalone fixes:
- calling
gc.collect()after every dialog closes; - making every window global;
- retaining every created dialog forever;
- applying
WA_DeleteOnCloseindiscriminately; - adding broad exception handlers around deleted-object errors;
- suppressing Qt warnings;
- hiding leaking widgets instead of destroying them.
Fix the ownership model instead.
19. Preserve PySide6 Wrapper Safety
Be careful about situations where the Python wrapper exists but the C++ QObject has already been deleted, or the C++ QObject survives while its Python wrapper is unexpectedly gone.
Avoid accessing objects after Qt destruction. Where needed:
- clear references on
destroyed; - avoid stale cached bound methods;
- avoid storing wrappers in long-lived registries.
20. Code Review Expectation
When reviewing existing code or implementing a refactor, treat lifetime correctness as part of normal code quality—not only something to inspect after a leak is reported.
If suspicious ownership is encountered while working on an unrelated feature, investigate and correct it when reasonably within scope. At minimum, do not introduce new lifetime ambiguity.
Reject a change when it:
- passes a transient or repeatedly created receiver's bound method directly to a
PySide6 signal or
QTimer.singleShot()in packaged-sensitive code; - replaces that connection with a lambda/partial that strongly captures the receiver;
- omits
sender=forconnectWeakly()when an independently owned or longer-lived sender needs destroyed-receiver cleanup; - releases a delete-on-close asynchronous dialog's final strong owner at
finished; - captures the transient dialog in a registry-cleanup callback;
- verifies only native CPython when the defect can be introduced by Nuitka's PySide6 integration.
Do not reject a direct connection merely because it is direct. First prove that the receiver is transient/repeated, that the sender or compiler protection can outlive it, or that the connection is recreated without a bounded owner. Persistent controls, child timers, shared models/delegates, and process-lifetime controller presentation may intentionally use direct connections when their QObject trees and Python owners share the same lifetime.
Acceptance Criteria
For Qt/PySide6 UI code, a correct implementation should satisfy all applicable conditions:
- Transient windows/dialogs are destroyed when no longer needed.
- Reusable windows remain alive intentionally.
- Asynchronous top-level windows retain a valid Python reference while visible.
- Long-lived controllers do not accidentally retain transient widgets.
- Caches do not retain
QObjectinstances unintentionally. - Timers and event filters have clear lifecycle behavior.
- Models, delegates, menus, actions, animations, and effects have bounded owners and replacement cleanup.
- Network replies, sockets, workers, pools, threads, processes, and queued work terminate through one idempotent owner path in the correct thread.
- Custom close handlers preserve correct Qt lifecycle semantics.
- Repeated open/close cycles do not cause unbounded live-object growth.
- No visible window disappears because its wrapper is prematurely garbage-collected.
- Native Python execution remains correct.
- Packaged/compiled execution remains correct where testable.
- Transient/repeated PySide6 connections do not grow Nuitka's protected bound-method retention.
- Delete-on-close asynchronous dialogs remain retained through
finishedand are released only afterdestroyeddispatch.
Final rule: Every Qt object in this repository must have an intentional owner, lifetime, and destruction strategy. When in doubt, investigate the lifetime explicitly rather than relying on implicit Python garbage collection or Qt parent behavior.