fix(kit): stop SummaryStrip flashing a ghost window on every grid rebuild

set_summary replaced its count labels with label.setParent(None) while they
were visible - reparenting a visible widget to None promotes it to a real
top-level window, which Windows shows with full native decoration (app icon,
min/max/close, label-sized blank body) until the deferred deleteLater runs.
Every grid rebuild in every module flashed one such ghost per replaced label;
the DA-07 optimistic-apply change made it glaring (8 flashes per device-row
Active toggle, one per channel write). Fix: hide the dying label and let
deleteLater collect it while still parented.

Found via a live user session on DA-07 hardware: synthetic probes could not
reproduce it (offscreen platforms swallow the flash; QTest clicks missed the
toggle), so this adds an env-gated diagnostic - CIM_UI_SPY=<log path> installs
a window spy (core/ui/window_spy.py, hooked in shell/app.py) that logs every
top-level Show with the widget creation stack; the user's log produced 84 ghost
windows pointing at the exact line. Screenshot kept in docs/samples; session
write-up in docs/DA07-FIELD-NOTES.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:22:24 -04:00
parent ec6d2578ae
commit 1d0a68f02c
6 changed files with 146 additions and 1 deletions

View File

@@ -36,7 +36,12 @@ class SummaryStrip(QFrame):
"""Replace the left-side counts. ``state`` may be None, "warn", or "alarm"."""
for label in self._segments:
self._box.removeWidget(label)
label.setParent(None)
# Hide, DON'T setParent(None): reparenting a visible widget to None
# promotes it to a top-level window, which flashes on screen as a tiny
# native-decorated popup until deleteLater runs (one flash per grid
# rebuild, suite-wide — found 2026-06-12 on DA-07 hardware via the
# CIM_UI_SPY diagnostic). It stays a hidden child until deletion.
label.hide()
label.deleteLater()
self._segments = []
insert_at = 0

View File

@@ -0,0 +1,72 @@
"""Env-gated UI diagnostic: log every top-level window Show with its creation stack.
Enable by setting ``CIM_UI_SPY`` to a log-file path before launching the suite
(hooked in ``shell/app.py``, same pattern as the DA-07 ``CIM_DA07_CAPTURE`` frame
recorder). Used to hunt transient "flashing window" bugs that only reproduce in a
live session: whenever any top-level widget is shown, one log block records the
widget's class, objectName, window flags, geometry — and, for widget classes whose
``__init__`` we can instrument, the Python stack that created it.
Diagnostic only: off unless the env var is set; never imported by normal code paths.
"""
from __future__ import annotations
import time
import traceback
import weakref
from PySide6.QtCore import QEvent, QObject
from PySide6.QtWidgets import QDialog, QFrame, QLabel, QWidget
_creation: "weakref.WeakKeyDictionary[QWidget, str]" = weakref.WeakKeyDictionary()
def _patch_init(cls) -> None:
orig = cls.__init__
def patched(self, *args, **kwargs):
orig(self, *args, **kwargs)
try:
_creation[self] = "".join(traceback.format_stack(limit=16)[:-2])
except TypeError:
pass # not weakref-able
cls.__init__ = patched
class _WindowSpy(QObject):
def __init__(self, log_path: str, parent=None) -> None:
super().__init__(parent)
self._path = log_path
self._t0 = time.monotonic()
self._log(f"spy installed {time.strftime('%Y-%m-%d %H:%M:%S')}")
def _log(self, msg: str) -> None:
try:
with open(self._path, "a", encoding="utf-8") as fh:
fh.write(f"[{time.monotonic() - self._t0:9.3f}] {msg}\n")
except OSError:
pass
def eventFilter(self, obj, event): # noqa: N802 (Qt override)
try:
if event.type() == QEvent.Type.Show and obj.isWidgetType() and obj.isWindow():
stack = _creation.get(obj, " (creation stack not captured for this class)")
self._log(
f"SHOW WINDOW: {type(obj).__name__} objectName={obj.objectName()!r} "
f"title={obj.windowTitle()!r} flags={int(obj.windowFlags()):#x} "
f"geom={obj.geometry().x()},{obj.geometry().y()} "
f"{obj.width()}x{obj.height()}\n{stack}"
)
except RuntimeError:
pass # widget mid-teardown
return False
def install_spy(app, log_path: str) -> None:
"""Patch common widget constructors and watch every top-level Show."""
for cls in (QWidget, QFrame, QLabel, QDialog):
_patch_init(cls)
spy = _WindowSpy(log_path, parent=app)
app.installEventFilter(spy)

View File

@@ -32,6 +32,13 @@ def main(argv: list[str] | None = None) -> int:
from .branding import app_icon
app = QApplication.instance() or QApplication(sys.argv)
# Diagnostic (off unless set): CIM_UI_SPY=<log path> logs every top-level
# window Show with its creation stack — for hunting transient flashing
# windows that only reproduce in a live session. See core/ui/window_spy.py.
if os.environ.get("CIM_UI_SPY"):
from cim_suite.core.ui.window_spy import install_spy
install_spy(app, os.environ["CIM_UI_SPY"])
app.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings)
app.setApplicationName("CIMTechniques Service Suite")
app.setOrganizationName("CIMTechniques")

View File

@@ -12,6 +12,39 @@ Companion docs: `HARDWARE-VERIFICATION.md` (the flagged-protocol checklist),
---
## 2026-06-12 — Tiny "popup window" flashing on every write (suite-wide kit bug)
**Symptom:** a small, empty, native-decorated window (app icon + min/max/close,
label-sized) flashed on top of the app on every settings write — 8× on a Devices-tab
Active toggle (one per channel write). Screenshot: `docs/samples/DA-07 flashing
pop-up.png`. Not DA-07-specific and **not** the VB6 "Working" indicator — it fired on
any grid rebuild in any module; the optimistic-apply change (below) just multiplied
the rebuilds that exposed it.
**Root cause:** `SummaryStrip.set_summary` (`core/ui/kit/summary_strip.py`) replaced
its count labels with `label.setParent(None)` while the labels were **visible**
reparenting a visible widget to None promotes it to a top-level window, which Windows
shows with full native decoration until the deferred `deleteLater` runs. Fix: hide
the dying label and let `deleteLater` collect it while still parented (one line +
regression test `tests/core/kit/test_summary_strip.py::
test_set_summary_never_orphans_a_visible_label`).
**How it was found — the `CIM_UI_SPY` diagnostic (keep for next time):** the flash
could not be reproduced by any synthetic probe (offscreen platforms swallow it, and
QTest clicks missed the toggle hotspot), but an env-gated window spy
(`core/ui/window_spy.py`, hooked in `shell/app.py`) run in the *user's live session*
logged 84 ghost windows with full creation stack traces pointing at the exact line.
Usage:
```powershell
$env:CIM_UI_SPY="$PWD\ui-spy.log"; .venv\Scripts\python -m cim_suite.shell.app --simulate
```
**Lesson:** when a UI glitch reproduces for the user but not for instrumented
probes, instrument the user's own session instead of approximating it.
---
## 2026-06-12 — Tag column truncation confirmed + first real capture (BL-E5 part 1)
**Setup:** same session as the stale-model entry below. Ran an instrumented refresh

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -21,10 +21,15 @@ def test_segments_render_microcaps_with_states(qtbot):
def test_set_summary_replaces_previous_segments(qtbot):
from PySide6.QtCore import QCoreApplication, QEvent
strip = SummaryStrip()
qtbot.addWidget(strip)
strip.set_summary([("2 devices", None)])
strip.set_summary([("3 devices", None), ("1 alarm", "alarm")])
# Replaced labels stay as hidden children until deleteLater processes (they
# are deliberately NOT reparented to None — see set_summary). Flush them.
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
assert [i.text() for i in _items(strip)] == ["3 DEVICES", "1 ALARM"]
@@ -41,3 +46,26 @@ def test_edit_hint_is_the_shared_kit_constant(qtbot):
strip.set_hint(EDIT_HINT)
assert strip._hint.text() == EDIT_HINT.upper()
assert "CLICK A VALUE TO EDIT" in strip._hint.text()
def test_set_summary_never_orphans_a_visible_label(qtbot):
"""Regression (2026-06-12, found on DA-07 hardware session): replacing segments
called setParent(None) on still-VISIBLE labels — reparenting a visible widget
to None promotes it to a real top-level window, which flashes on screen as a
tiny native-decorated popup until deleteLater runs. Old labels must be hidden
before they are orphaned."""
from PySide6.QtWidgets import QApplication
strip = SummaryStrip()
qtbot.addWidget(strip)
strip.show()
strip.set_summary([("16 slots", None), ("3 devices", None)])
strip.set_summary([("16 slots", None), ("2 devices", None), ("1 warn", "warn")])
# A replaced label awaiting deleteLater must still be a (hidden) child of
# the strip - if it shows up as a top-level widget it WAS promoted to a window.
ghosts = [
w for w in QApplication.topLevelWidgets()
if isinstance(w, QLabel)
and w.objectName() in ("SummaryItem", "SummarySep")
]
assert ghosts == [], [g.text() for g in ghosts]