Manage Qt/PySide6 Lifetimes
Treat lifetime correctness as part of the implementation, not as a later cleanup step. Fix the ownership model instead of masking symptoms.
Start from the checked-out implementation, tests, packaging configuration, and verified runtime behavior. Treat this skill and its reference as maintained hypotheses: when evidence contradicts them, follow the evidence, correct the narrowest stale rule, and preserve the newly verified invariant for the next audit.
Required reference
Before changing or reviewing lifetime-relevant code, read references/qt-pyside6-object-lifetime-guidelines.md completely. Apply its detailed requirements together with the scoped AGENTS.md files.
Workflow
1. Inventory the lifetime graph
For every affected Qt object, record:
- creator and durable Python owner;
QObject parent;
- intended category: long-lived, reusable, or transient;
- close/hide/destroy path;
- thread affinity and every timer, event filter, model, delegate, menu, action, animation, graphics effect, network reply, worker, pool, or thread it owns;
- connections to application-lifetime senders;
- for each relevant signal: sender/receiver lifetimes and QObject trees, direct bound method versus weak dispatcher versus closure/partial, and its disconnect boundary;
- caches, registries, closures, partials, lambdas, or callbacks that can retain it.
Do not treat .show(), .open(), .close(), a parent, or a weak reference as proof that the lifetime is correct.
2. Trace both failure directions
Check for retention:
- closed transient UI still reachable from controllers, globals, registries, signals, timers, callbacks, or caches;
- Qt children hidden but never destroyed;
- native resources, worker threads, or handles outliving the feature.
Check for premature destruction:
- asynchronous top-level windows created only in a local variable;
- Python wrappers collected while the C++ object should remain usable;
- stale Python references after Qt deletes the native object.
3. Choose one intentional ownership model
- Long-lived: create once under an application-lifetime owner and shut down explicitly.
- Reusable: retain one strong owner, hide/show deliberately, reset state when reopened, and destroy with the owner.
- Transient: retain while visible, preserve normal close/accept/reject behavior, destroy after the interaction, and release owning references on destruction.
Use WA_DeleteOnClose, explicit disconnection, deleteLater(), or removeEventFilter() only when that specific model requires it.
4. Audit indirect ownership
Search the affected call paths for:
lru_cache, cache, memoization, and object-keyed dictionaries;
- module/application/controller/plugin registries;
- nested functions, bound methods, lambdas, and
functools.partial;
QTimer, installEventFilter, QAction, QMenu, QActionGroup, QShortcut, models, delegates, and watchers;
QNetworkReply, QThread, QThreadPool, QRunnable, queued events, and deferred deletion;
- long-lived signals connected to transient Python callables.
Prefer immutable metadata and classes/factories in caches and registries. Never cache a transient Qt instance or an instance method whose key contains self.
5. Enforce packaged signal safety
The selected Nuitka/PySide6 toolchain can retain compiled bound methods passed directly
to SignalInstance.connect() or QTimer.singleShot() in process-global protection.
A connection that is harmless under native CPython can therefore retain a transient
receiver and its Qt subtree for the life of the packaged process. Re-check the actual
package configuration when the selected Nuitka or PySide6 version changes.
- Do not connect a signal directly to a bound method of a transient or repeatedly
created
QObject.
- Use
Furious.Qt.connectWeakly(signal, receiver, 'methodName', ...).
- Pass
sender= when the sender is outside the receiver's QObject subtree. On
receiver destruction, connectWeakly() disconnects that independent sender through
Qt's opaque connection handle; it intentionally does not capture the sender's
SignalInstance. A sender owned in the receiver's subtree dies with that tree and
does not need this extra disconnect hook.
- Use
forwardSender=True when the named method needs the sender; the helper forwards
the weakly resolved sender explicitly rather than depending on QObject.sender().
- Use
singleShotWeakly() for deferred named-method delivery to transient or repeated
receivers.
- Do not substitute a lambda or partial that strongly captures the receiver.
- Direct connections remain appropriate for bounded, deliberately shared lifetimes,
such as persistent page controls, child timers, and application-lifetime
controllers. They are not appropriate when a transient/repeated receiver can be
retained by a longer-lived sender or by packaged bound-method protection. Audit the
actual capture graph of closures and partials instead of banning them by syntax.
The current weak dispatcher keeps only weak receiver/sender references, checks
shiboken6.isValid() before accessing a QObject wrapper, and resolves the method's
static string name at emission time. Renaming that method without updating the
connection is therefore a runtime contract break.
6. Preserve asynchronous dialog destruction
For non-blocking dialogs, distinguish interaction completion from native destruction:
- reusable dialogs may release an open-dialog registry entry at
finished;
- one-shot dialogs using
WA_DeleteOnClose must remain strongly retained after
finished, through deferred Qt deletion, until destroyed has been dispatched;
- release the registry entry on the next event-loop turn after
destroyed;
- registry callbacks must capture an opaque lifetime token, not the dialog;
- operation-specific context may be released at
finished once callbacks no longer
need it.
Use the existing AppQDialog/AppQTransientDialog/AppQMessageBox ownership model
rather than adding a parallel registry.
AppQMainWindow has a separate visible-window registry: it prevents an unowned shown
top-level wrapper from disappearing and releases it after an accepted close, with
destroyed as a fallback. Reusable windows still need their deliberate owner outside
that registry. Audit delete-on-close top-level windows against their own post-close
work instead of assuming the dialog registry's finished policy applies unchanged.
7. Own asynchronous resources through one terminal path
- Parent ordinary timers, models, delegates, menus, actions, and effects to the feature
that owns them; stop/remove/replace them explicitly when their logical lifetime can
end before the parent.
- Give each network reply, worker, pool, thread, socket, process, and queued operation
one durable owner, one cancellation/supersession rule, and one idempotent terminal
cleanup path in the correct Qt thread.
- Cross thread boundaries with immutable results and queued events/signals. Workers do
not mutate widgets or live GUI models.
- Remove event filters and dispose interrupted animations/effects when either side can
outlive the feature.
8. Diagnose before fixing
Use targeted evidence as needed:
weakref.ref or weakref.finalize;
QObject.destroyed;
- live instance/resource counters;
gc.get_referrers, gc.get_objects, or tracemalloc;
- repeated create/open/close cycles;
- exact process handle, thread, timer, action, and menu counts.
Distinguish retained objects from Python allocator high-water marks and Qt/native memory caching. Remove temporary diagnostics after the cause is understood.
9. Verify the lifecycle
Run the narrow behavior test first, then the applicable Qt lifetime tier in tests/README.md. For shared transient infrastructure, repeat at least 20-50 cycles and verify:
- every intended
destroyed signal fires;
- weak references and registries return to baseline;
- timers/actions/menus/threads/handles do not grow linearly;
- reusable windows remain valid across reopen cycles;
- asynchronous windows retain a Python owner while visible;
- native Python remains correct and the packaged build is checked when the issue is packaging-specific.
For transient signal/dialog infrastructure, run both the native lifecycle tests and a
Nuitka-compiled repeated-open/close probe. Cover accept, reject, and window-close
paths plus representative protocol/editor mixes. Assert that destroyed counts match,
weak wrappers and registries return to zero, operation context is released, no invalid
wrapper is accessed, and Nuitka's protected callback collection does not grow when that
internal diagnostic is observable. If it is hidden by the compiled runtime, combine
zero retained wrappers with inspection of the selected Nuitka package configuration;
do not report an unobservable counter as measured.
Prohibited shortcuts
Do not use routine gc.collect(), global retention of every window, indiscriminate WA_DeleteOnClose, hiding instead of destroying, broad deleted-wrapper exception suppression, or ever-growing thresholds as standalone fixes.
Completion checklist
Before handing off a Qt-related change, be able to explain:
- the Python owner and Qt parent;
- the intended lifetime category;
- the exact destruction or reuse path;
- why signals, timers, filters, caches, and registries cannot retain stale UI;
- why the object cannot disappear prematurely;
- whether direct signal callbacks or closures create unwanted packaged-build retention;
- for delete-on-close dialogs, why the final owner survives until native destruction;
- how replies, workers, pools, threads, queued work, and deferred deletion reach one terminal cleanup path;
- which native and, when relevant, Nuitka-compiled lifecycle verification passed.
1---2name: manage-qt-pyside6-lifetimes3description: Audit and implement safe Qt/PySide6 object ownership and destruction in Furious. Use for QObject/UI lifetimes, transient or reusable windows, signals/slots, direct bound-method connections, weak dispatch, delete-on-close dialogs, timers, models, delegates, registries, stale wrappers, memory growth, premature destruction, and native-versus-Nuitka packaged differences.4---56# Manage Qt/PySide6 Lifetimes78Treat lifetime correctness as part of the implementation, not as a later cleanup step. Fix the ownership model instead of masking symptoms.910Start from the checked-out implementation, tests, packaging configuration, and verified runtime behavior. Treat this skill and its reference as maintained hypotheses: when evidence contradicts them, follow the evidence, correct the narrowest stale rule, and preserve the newly verified invariant for the next audit.1112## Required reference1314Before changing or reviewing lifetime-relevant code, read [references/qt-pyside6-object-lifetime-guidelines.md](references/qt-pyside6-object-lifetime-guidelines.md) completely. Apply its detailed requirements together with the scoped `AGENTS.md` files.1516## Workflow1718### 1. Inventory the lifetime graph1920For every affected Qt object, record:2122- creator and durable Python owner;23- `QObject` parent;24- intended category: long-lived, reusable, or transient;25- close/hide/destroy path;26- thread affinity and every timer, event filter, model, delegate, menu, action, animation, graphics effect, network reply, worker, pool, or thread it owns;27- connections to application-lifetime senders;28- for each relevant signal: sender/receiver lifetimes and QObject trees, direct bound method versus weak dispatcher versus closure/partial, and its disconnect boundary;29- caches, registries, closures, partials, lambdas, or callbacks that can retain it.3031Do not treat `.show()`, `.open()`, `.close()`, a parent, or a weak reference as proof that the lifetime is correct.3233### 2. Trace both failure directions3435Check for retention:3637- closed transient UI still reachable from controllers, globals, registries, signals, timers, callbacks, or caches;38- Qt children hidden but never destroyed;39- native resources, worker threads, or handles outliving the feature.4041Check for premature destruction:4243- asynchronous top-level windows created only in a local variable;44- Python wrappers collected while the C++ object should remain usable;45- stale Python references after Qt deletes the native object.4647### 3. Choose one intentional ownership model4849- **Long-lived:** create once under an application-lifetime owner and shut down explicitly.50- **Reusable:** retain one strong owner, hide/show deliberately, reset state when reopened, and destroy with the owner.51- **Transient:** retain while visible, preserve normal close/accept/reject behavior, destroy after the interaction, and release owning references on destruction.5253Use `WA_DeleteOnClose`, explicit disconnection, `deleteLater()`, or `removeEventFilter()` only when that specific model requires it.5455### 4. Audit indirect ownership5657Search the affected call paths for:5859- `lru_cache`, `cache`, memoization, and object-keyed dictionaries;60- module/application/controller/plugin registries;61- nested functions, bound methods, lambdas, and `functools.partial`;62- `QTimer`, `installEventFilter`, `QAction`, `QMenu`, `QActionGroup`, `QShortcut`, models, delegates, and watchers;63- `QNetworkReply`, `QThread`, `QThreadPool`, `QRunnable`, queued events, and deferred deletion;64- long-lived signals connected to transient Python callables.6566Prefer immutable metadata and classes/factories in caches and registries. Never cache a transient Qt instance or an instance method whose key contains `self`.6768### 5. Enforce packaged signal safety6970The selected Nuitka/PySide6 toolchain can retain compiled bound methods passed directly71to `SignalInstance.connect()` or `QTimer.singleShot()` in process-global protection.72A connection that is harmless under native CPython can therefore retain a transient73receiver and its Qt subtree for the life of the packaged process. Re-check the actual74package configuration when the selected Nuitka or PySide6 version changes.7576- Do not connect a signal directly to a bound method of a transient or repeatedly77 created `QObject`.78- Use `Furious.Qt.connectWeakly(signal, receiver, 'methodName', ...)`.79- Pass `sender=` when the sender is outside the receiver's `QObject` subtree. On80 receiver destruction, `connectWeakly()` disconnects that independent sender through81 Qt's opaque connection handle; it intentionally does not capture the sender's82 `SignalInstance`. A sender owned in the receiver's subtree dies with that tree and83 does not need this extra disconnect hook.84- Use `forwardSender=True` when the named method needs the sender; the helper forwards85 the weakly resolved sender explicitly rather than depending on `QObject.sender()`.86- Use `singleShotWeakly()` for deferred named-method delivery to transient or repeated87 receivers.88- Do not substitute a lambda or partial that strongly captures the receiver.89- Direct connections remain appropriate for bounded, deliberately shared lifetimes,90 such as persistent page controls, child timers, and application-lifetime91 controllers. They are not appropriate when a transient/repeated receiver can be92 retained by a longer-lived sender or by packaged bound-method protection. Audit the93 actual capture graph of closures and partials instead of banning them by syntax.9495The current weak dispatcher keeps only weak receiver/sender references, checks96`shiboken6.isValid()` before accessing a `QObject` wrapper, and resolves the method's97static string name at emission time. Renaming that method without updating the98connection is therefore a runtime contract break.99100### 6. Preserve asynchronous dialog destruction101102For non-blocking dialogs, distinguish interaction completion from native destruction:103104- reusable dialogs may release an open-dialog registry entry at `finished`;105- one-shot dialogs using `WA_DeleteOnClose` must remain strongly retained after106 `finished`, through deferred Qt deletion, until `destroyed` has been dispatched;107- release the registry entry on the next event-loop turn after `destroyed`;108- registry callbacks must capture an opaque lifetime token, not the dialog;109- operation-specific context may be released at `finished` once callbacks no longer110 need it.111112Use the existing `AppQDialog`/`AppQTransientDialog`/`AppQMessageBox` ownership model113rather than adding a parallel registry.114115`AppQMainWindow` has a separate visible-window registry: it prevents an unowned shown116top-level wrapper from disappearing and releases it after an accepted close, with117`destroyed` as a fallback. Reusable windows still need their deliberate owner outside118that registry. Audit delete-on-close top-level windows against their own post-close119work instead of assuming the dialog registry's `finished` policy applies unchanged.120121### 7. Own asynchronous resources through one terminal path122123- Parent ordinary timers, models, delegates, menus, actions, and effects to the feature124 that owns them; stop/remove/replace them explicitly when their logical lifetime can125 end before the parent.126- Give each network reply, worker, pool, thread, socket, process, and queued operation127 one durable owner, one cancellation/supersession rule, and one idempotent terminal128 cleanup path in the correct Qt thread.129- Cross thread boundaries with immutable results and queued events/signals. Workers do130 not mutate widgets or live GUI models.131- Remove event filters and dispose interrupted animations/effects when either side can132 outlive the feature.133134### 8. Diagnose before fixing135136Use targeted evidence as needed:137138- `weakref.ref` or `weakref.finalize`;139- `QObject.destroyed`;140- live instance/resource counters;141- `gc.get_referrers`, `gc.get_objects`, or `tracemalloc`;142- repeated create/open/close cycles;143- exact process handle, thread, timer, action, and menu counts.144145Distinguish retained objects from Python allocator high-water marks and Qt/native memory caching. Remove temporary diagnostics after the cause is understood.146147### 9. Verify the lifecycle148149Run the narrow behavior test first, then the applicable Qt lifetime tier in `tests/README.md`. For shared transient infrastructure, repeat at least 20-50 cycles and verify:150151- every intended `destroyed` signal fires;152- weak references and registries return to baseline;153- timers/actions/menus/threads/handles do not grow linearly;154- reusable windows remain valid across reopen cycles;155- asynchronous windows retain a Python owner while visible;156- native Python remains correct and the packaged build is checked when the issue is packaging-specific.157158For transient signal/dialog infrastructure, run both the native lifecycle tests and a159Nuitka-compiled repeated-open/close probe. Cover `accept`, `reject`, and window-close160paths plus representative protocol/editor mixes. Assert that destroyed counts match,161weak wrappers and registries return to zero, operation context is released, no invalid162wrapper is accessed, and Nuitka's protected callback collection does not grow when that163internal diagnostic is observable. If it is hidden by the compiled runtime, combine164zero retained wrappers with inspection of the selected Nuitka package configuration;165do not report an unobservable counter as measured.166167## Prohibited shortcuts168169Do not use routine `gc.collect()`, global retention of every window, indiscriminate `WA_DeleteOnClose`, hiding instead of destroying, broad deleted-wrapper exception suppression, or ever-growing thresholds as standalone fixes.170171## Completion checklist172173Before handing off a Qt-related change, be able to explain:1741751. the Python owner and Qt parent;1762. the intended lifetime category;1773. the exact destruction or reuse path;1784. why signals, timers, filters, caches, and registries cannot retain stale UI;1795. why the object cannot disappear prematurely;1806. whether direct signal callbacks or closures create unwanted packaged-build retention;1817. for delete-on-close dialogs, why the final owner survives until native destruction;1828. how replies, workers, pools, threads, queued work, and deferred deletion reach one terminal cleanup path;1839. which native and, when relevant, Nuitka-compiled lifecycle verification passed.