Document Qt lifetime boundaries

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-09-19 12:07:51 +08:00
parent 5c1a5b3377
commit 7c04feafe3
3 changed files with 21 additions and 9 deletions
@@ -224,7 +224,7 @@ Avoid unnecessary manual disconnect boilerplate when Qt already manages the conn
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
(Nuitka 4.2.1, 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
@@ -233,10 +233,9 @@ 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.
This observation is specific to the inspected local package configuration. Do not
assume an exact introduction version or unchanged upstream behavior; inspect the
package configuration installed in the environment being shipped.
The following is prohibited for a transient or repeatedly created receiver:
@@ -287,6 +286,14 @@ receiver itself is gone. When the sender is the receiver or a child/descendant,
destruction already removes the connection and the extra destroyed-receiver hook is
unnecessary. Supplying `sender=` still keeps only a weak sender reference.
That subtree check describes the ownership at registration time. When a child can be
detached or reused, its feature must disconnect its own stored connection handles
before reparenting and clear stale child references on native destruction.
`AppQMessageBox` button registration and its detach/reuse tests cover this boundary.
User callbacks can also synchronously destroy a sender or receiver during delivery;
recheck native validity before further Qt work, as the HTTP completion and abort
regressions in `test_service_runtime.py` demonstrate.
`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.
@@ -443,7 +450,7 @@ 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
development combination is PySide6 6.8.3 with Nuitka 4.2.1. 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.
+6 -1
View File
@@ -47,6 +47,9 @@ behavior, and lifetime primitives; pages and services consume them without creat
`singleShotWeakly()` for deferred named-method delivery. A closure/partial that captures the receiver does not
substitute for weak dispatch. Bounded dialog-completion closures may intentionally capture context; verify their
native destruction/disconnection boundary and both owner-first and sender-first teardown.
- Detaching a child ends the shared QObject-tree lifetime assumption. Disconnect the registrations owned by that
feature before reparenting, preserve unrelated listeners, and remove default/escape/selection references when the
child dies. `AppQMessageBox` button reuse and native-destruction regressions exercise this boundary.
- Direct connections are appropriate for deliberately shared persistent lifetimes; syntax alone does not prove a
leak. Recheck the selected Nuitka/PySide6 callback protection when the toolchain changes. Static weak method names
are runtime contracts, so renames must update registrations and tests. Weak dispatch itself does not marshal
@@ -60,7 +63,9 @@ behavior, and lifetime primitives; pages and services consume them without creat
- Every `QNetworkReply` has one manager/context owner, one freshness rule, and one terminal deletion path. Request
context must also be released when native destruction skips `finished`, including manager-first teardown with
retained Python wrappers. Use the shared network-manager tracking boundary; cleanup must not capture a reply
strongly. Do not attach ad-hoc attributes to third-party Qt objects or multiply timers/connections across show/hide cycles.
strongly. User hooks and signal delivery can synchronously destroy the reply or manager; recheck native validity
before subsequent hooks or Qt cleanup. `test_service_runtime.py` covers completion and abort reentrancy.
Do not attach ad-hoc attributes to third-party Qt objects or multiply timers/connections across show/hide cycles.
- Queued delivery never transfers ownership implicitly. The sender may finish before delivery, so callbacks resolve a
still-valid receiver and current generation in the receiver's Qt thread before touching widgets, models, or wrappers.
+2 -2
View File
@@ -52,7 +52,7 @@ strategy in an individual test.
| Xray/Hysteria2 native-TUN ownership and proxy-only stripping | `test_native_tun_semantics.py` |
| Rolling metrics, stable buckets, lazy rendering, and hover | `test_metrics_behavior.py` |
| Proxy-only endpoint discovery, caching, and presentation | `test_endpoint_info.py` |
| Bounded service work, update validation, plugin UI, and worker lifetime | `test_service_runtime.py` |
| Bounded service work, update validation, plugin UI, worker lifetime, and reentrant HTTP completion/cancellation | `test_service_runtime.py` |
| Frozenlib state helpers and mocked platform-operation boundaries | `test_frozenlib.py` |
| Settings sandbox, navigation overlay behavior, public exports, and scale/theme isolation | `test_isolation_and_navigation.py`, `test_public_api.py`, `test_layout_matrix.py` |
| Shared Fluent visual states, zero table padding and intact rounded list corners, rounded menu/combo popup transparency and interaction, and native line-edit clear-button alignment and theme changes | `test_stylesheet_states.py` |
@@ -62,7 +62,7 @@ strategy in an individual test.
| Editor mappings, lazy log rendering, routing-rule internal-move drag restrictions, Move menu/shortcuts and persisted order, message-box/connection UI | `test_ui_behavior.py` |
| Bounded, incremental, cancellable QR export and snapshot/lifetime safety | `test_qr_export_scalability.py` |
| Real keyboard/mouse/focus, proxy mapping, shared Home/Settings state, Home empty/filter recovery, shared menus and Tests-popup selection highlighting, direct small profile operations and batched imports/deletions with throttled progress and stable cancellation targets, and transient editor input | `test_qt_interactions.py` |
| Direct Qt ownership and destruction across independent UI families | `test_qt_lifetime.py` |
| Direct Qt ownership and destruction across independent UI families, message-box button detachment/reuse, and callback-driven owner destruction | `test_qt_lifetime.py` |
| Batched real/probe Qt object, QR rendering/window lifecycle, handle, Python allocation, and RSS trends | `test_qt_stress.py` |
| Repeated harmless subprocess, pipe, thread, handle, and RSS trends | `test_process_stress.py` |
| Opt-in release-confidence counts (100 app children, 100 external cores, 100k metrics, 40k logs, 20k navigation, 5k plugins, 1k dialogs, 1k real QR tabs) | `test_very_heavy.py` |