Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions Progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,22 +219,6 @@ capability enum 值與 variadic `ei_seat_bind_capabilities`、event-type enum

---

## 三個 Qt thread-marshal 測試永久跳過中

`TODO` — 需要子行程隔離,做法已知

`test/unit_test/headless/test_r3_gui_thread_marshal.py` 裡三個測試被無條件 skip:
`test_panel_signals_expose_file_received`、`test_webrtc_received_file_marshaled_to_gui`、
`test_thumbnail_poll_thread_is_reaped`。skip 理由自己寫著「needs subprocess
isolation (see test_actions_menu_gui) … skip until then」——那句「until then」就是這條。

跟 `CLAUDE.md` §Testing 記的 0xC0000409 `__fastfail` 是同一個家族:
worker→GUI 的 teardown 在共用的 pytest 行程裡把整個監獸帶走。
`test_actions_menu_gui.py` 已經示範過解法(把建 widget 的部分丟進子行程),
這三個只是還沒改過去。沒改之前,這三條路徑沒有任何回歸保護。

---

## 兩個講好要爬、還沒爬的門檻

`TODO` — 兩者都寫在 `pyproject.toml` 的註解裡,但不在任何待辦清單上
Expand Down
25 changes: 25 additions & 0 deletions README/WHATS_NEW_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

## 本次更新 (2026-08-20) — 声称支援的平台,這回真的量過了

### 三个从写出来就一直被跳过的测试

`test_r3_gui_thread_marshal.py` 里有三个 `@pytest.mark.skip`,而它们的 skip 理由
自己就写着需要什么:“needs subprocess isolation (see test_actions_menu_gui)
… skip until then”。它们盖的是真的接线:WebRTC worker 线程收到的文件要通过
queued signal 回到 GUI 线程(而不是线程绑定的 `QTimer.singleShot`),以及
admin console 的缩图轮询每一 tick 都要把 `QThread` 删掉,而不是每个间隔漏一个。

当时跳过是对的:在**共用的** pytest 进程里建 WebRTC 面板或 admin console、
再拆掉 worker `QThread`,在 offscreen Qt 下会直接 abort。而因为 `deleteLater` 在没有
event loop 跑之前是 no-op,那个 abort 甚至不会落在胇事的那支测试上——它会在后面某支
毫无关系的文件里引爆,而且没有 traceback。

**现在它们跑了,在自己的进程里。** 一支 probe 把三项检查一次做完,每项写一个 JSON
结论,然后 `os._exit(0)` 不做 teardown——跟 `test_actions_menu_gui` 对整组标签页用的是
同一个形状。每项结论是 `ok`、`failed: …` 或 `unavailable: …`,所以没装 `[webrtc]`
extra 的机器(CI 的 `pytest-headless` 就是)得到的是 skip 而不是失败,装了的机器则真的在验接线。

这三项检查是有牙的,而且是验过不是假设的:把 `admin_console_tab.py` 里
`thread.finished.connect(thread.deleteLater)` 那一行拿掉,第三项结论就变成
`failed: the QThread outlived finish`,而另外两项依旧绿。

headless 整套现在不需要任何 `--ignore` 就能从头跑到尾——共 4,815 项通过,
剩下的 skip 全部是可选依赖与平台闸门。里面已经没有任何一句“skip until then”。

### Windows arm64 从来不是代码的问题

这一项原本标成 `BLOCKED`,而那只对一半。两个依赖确实没发
Expand Down
25 changes: 25 additions & 0 deletions README/WHATS_NEW_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

## 本次更新 (2026-08-20) — 嬣稱支援的平台,這回真的量過了

### 三個從寫出來就一直被跳過的測試

`test_r3_gui_thread_marshal.py` 裡有三個 `@pytest.mark.skip`,而它們的 skip 理由
自己就寫著需要什麼:「needs subprocess isolation (see test_actions_menu_gui)
… skip until then」。它們蓋的是真的接線:WebRTC worker 執行緒收到的檔案要透過
queued signal 回到 GUI 執行緒(而不是執行緒綁定的 `QTimer.singleShot`),以及
admin console 的縮圖輪詢每一 tick 都要把 `QThread` 刪掉,而不是每個間隔漏一個。

當時跳過是對的:在**共用的** pytest 行程裡建 WebRTC 面板或 admin console、
再拆掉 worker `QThread`,在 offscreen Qt 下會直接 abort。而因為 `deleteLater` 在沒有
event loop 跑之前是 no-op,那個 abort 甚至不會落在胇事的那支測試上——它會在後面某支
毫無關係的檔案裡引爆,而且沒有 traceback。

**現在它們跑了,在自己的行程裡。** 一支 probe 把三項檢查一次做完,每項寫一個 JSON
結論,然後 `os._exit(0)` 不做 teardown——跟 `test_actions_menu_gui` 對整組分頁用的是
同一個形狀。每項結論是 `ok`、`failed: …` 或 `unavailable: …`,所以沒裝 `[webrtc]`
extra 的機器(CI 的 `pytest-headless` 就是)得到的是 skip 而不是失敗,裝了的機器則真的在驗接線。

這三項檢查是有牙的,而且是驗過不是假設的:把 `admin_console_tab.py` 裡
`thread.finished.connect(thread.deleteLater)` 那一行拿掉,第三項結論就變成
`failed: the QThread outlived finish`,而另外兩項依舊綠。

headless 整套現在不需要任何 `--ignore` 就能從頭跑到尾——共 4,815 項通過,
剩下的 skip 全部是選用相依與平台閘門。裡面已經沒有任何一句「skip until then」。

### Windows arm64 從來不是程式的問題

這一項原本標成 `BLOCKED`,而那只對一半。兩個相依確實沒發
Expand Down
32 changes: 32 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

## What's new (2026-08-20)

### Three Tests That Had Been Skipped Since They Were Written

`test_r3_gui_thread_marshal.py` carried three `@pytest.mark.skip`s whose own
reason said what they needed: *"needs subprocess isolation (see
test_actions_menu_gui) … skip until then."* They covered real wiring — that a
file received on a WebRTC worker thread reaches the GUI thread through a
queued signal rather than a thread-affine `QTimer.singleShot`, and that the
admin console's thumbnail poll deletes its `QThread` each tick instead of
leaking one per interval.

Skipping them was the right call at the time: building the WebRTC panel or the
admin console and then tearing a worker `QThread` down aborts the *shared*
pytest process under offscreen Qt. Because `deleteLater` is a no-op until an
event loop runs, the abort does not even land in the test that caused it — it
detonates inside some later, unrelated file, with no traceback.

**They now run, in their own process.** One probe performs all three checks,
writes a JSON verdict per check, and `os._exit(0)`s without teardown — the same
shape `test_actions_menu_gui` has used for the full tab set. Each verdict is
`ok`, `failed: …` or `unavailable: …`, so a machine without the `[webrtc]`
extra (CI's `pytest-headless`, among others) reports a skip rather than a
failure, while a machine that has it actually checks the wiring.

The checks have teeth, which was verified rather than assumed: deleting the
one line `thread.finished.connect(thread.deleteLater)` from
`admin_console_tab.py` turns the third verdict into `failed: the QThread
outlived finish`, and leaves the other two green.

The headless suite now runs end to end with no `--ignore` flags — 4,815
passing, and the only remaining skips are optional-dependency and
platform gates. No "skip until then" is left in it.

### Windows arm64 Was Never a Code Problem

The entry for this said `BLOCKED`, and that was half right. Two dependencies
Expand Down
2 changes: 1 addition & 1 deletion architecture_explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
> 擷取每個模組的 docstring 與頂層公開名稱;統計數字取自實際檔案,非估算。
> 指令數與公開 API 數以 `executor.known_commands()` 與 `je_auto_control.__all__` 在工作樹上實測取得。
>
> **掃描時間**:2026-08-20 **版本**:`pyproject.toml` version `0.0.220` **分支**:`feat/windows-arm64-install`
> **掃描時間**:2026-08-21 **版本**:`pyproject.toml` version `0.0.220` **分支**:`feat/qt-thread-marshal-isolation`

---

Expand Down
196 changes: 148 additions & 48 deletions test/unit_test/headless/test_r3_gui_thread_marshal.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,21 @@
Each test drives the real method from a background ``threading.Thread`` and
pumps the GUI event loop, so a queued signal is required for the effect to
appear (a thread-affine ``singleShot`` would not fire).

Three of these run in a subprocess. Constructing the WebRTC panel or the
admin console, then tearing a worker QThread down, aborts the *shared* pytest
process under offscreen Qt (``0xC0000409`` / SIGABRT) — and because
``deleteLater`` is a no-op until an event loop runs, the abort lands inside
some later, unrelated test file. They were skipped outright for that reason;
quarantining the Qt lifetime in a child process is what
``test_actions_menu_gui`` already does, and it is what they needed. The child
writes a JSON verdict per check and ``os._exit(0)``s without teardown.
"""
import json
import os
import pathlib
import subprocess
import sys
import threading
import time

Expand All @@ -19,22 +32,10 @@
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError)

import shiboken6 # noqa: E402
from PySide6.QtCore import QEvent, QObject, QThread # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402


# Constructing the WebRTC panel / admin-console QThread teardown inside the
# SHARED pytest process natively aborts (SIGABRT/0xC0000409) under the offscreen
# Qt platform on CI — accumulated Qt state across GUI tests corrupts on teardown.
# The product paths these cover are exercised by the full-widget build in
# test_actions_menu_gui, which is deliberately run in an isolated subprocess for
# exactly this reason. These need the same subprocess isolation before they can
# run in-process; skip until then rather than crash the whole suite.
_OFFSCREEN_SHARED_PROC_ABORT = (
"worker->GUI teardown aborts the shared pytest process under offscreen Qt; "
"needs subprocess isolation (see test_actions_menu_gui)"
)
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]


@pytest.fixture(scope="module")
Expand Down Expand Up @@ -93,67 +94,166 @@ def test_presence_registry_event_marshaled_to_gui(qapp):
tab.deleteLater()


# --- Finding 8: WebRTC file-received callback ------------------------------
# --- The three that need their own process --------------------------------

# Each check returns "ok", "failed: ...", or "unavailable: ..." so a missing
# optional extra reads as a skip rather than a failure: CI's pytest-headless
# does not install [webrtc], and importing the panel without it raises.
_PROBE = r"""
import json
import os
import pathlib
import sys
import tempfile
import threading
import time

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

import shiboken6
from PySide6.QtCore import QEvent, QObject, QThread
from PySide6.QtWidgets import QApplication

app = QApplication.instance() or QApplication([])
report = {}


def pump_until(predicate, timeout=3.0):
deadline = time.monotonic() + timeout
while not predicate() and time.monotonic() < deadline:
app.processEvents()
time.sleep(0.005)
return predicate()


def run_off_thread(target):
thread = threading.Thread(target=target)
thread.start()
thread.join(3.0)

@pytest.mark.skip(reason=_OFFSCREEN_SHARED_PROC_ABORT)
def test_panel_signals_expose_file_received():

def check_panel_signals():
from je_auto_control.gui.remote_desktop.webrtc_panel import _PanelSignals
assert hasattr(_PanelSignals(), "file_received")
assert hasattr(_PanelSignals(), "file_received"), "no file_received signal"


@pytest.mark.skip(reason=_OFFSCREEN_SHARED_PROC_ABORT)
def test_webrtc_received_file_marshaled_to_gui(qapp):
def check_webrtc_marshal():
import types
from je_auto_control.gui.remote_desktop.webrtc_panel import (
_PanelSignals, _WebRTCViewerPanel,
)

signals = _PanelSignals()

class _Receiver(QObject):
class Receiver(QObject):
def __init__(self):
super().__init__()
self.got = None

def on_file(self, path):
self.got = path

recv = _Receiver()
signals.file_received.connect(recv.on_file)
receiver = Receiver()
signals.file_received.connect(receiver.on_file)
stub = types.SimpleNamespace(_signals=signals)

_run_off_thread(lambda: _WebRTCViewerPanel._on_received_file(stub, "file-123"))
assert _pump_until(qapp, lambda: recv.got == "file-123")

run_off_thread(
lambda: _WebRTCViewerPanel._on_received_file(stub, "file-123"))
assert pump_until(lambda: receiver.got == "file-123"), (
"the file-received callback never reached the GUI thread; a "
"thread-affine QTimer.singleShot would fail exactly like this")

# --- Finding 9: thumbnail poll thread is reaped on finish ------------------

@pytest.mark.skip(reason=_OFFSCREEN_SHARED_PROC_ABORT)
def test_thumbnail_poll_thread_is_reaped(qapp, monkeypatch, tmp_path):
def check_thumbnail_reaped():
import je_auto_control.gui.admin_console_tab as admin_mod
from je_auto_control.utils.admin.admin_client import AdminConsoleClient

client = AdminConsoleClient(persist_path=tmp_path / "hosts.json")
monkeypatch.setattr(admin_mod, "default_admin_console", lambda: client)
tmp = pathlib.Path(tempfile.mkdtemp())
client = AdminConsoleClient(persist_path=tmp / "hosts.json")
admin_mod.default_admin_console = lambda: client
# Don't run a real background thread: the reaping wiring is what matters,
# and this keeps the test deterministic (no timing, no dangling threads).
monkeypatch.setattr(admin_mod.QThread, "start", lambda self: None)
# and this keeps the check deterministic (no timing, no dangling threads).
admin_mod.QThread.start = lambda self: None

tab = admin_mod.AdminConsoleTab()
tab._thumb_timer.stop()
tab._refresh_thumbnails()

thread = tab._thumb_thread
assert thread is not None, "no thumbnail QThread was created"
assert thread in tab.findChildren(QThread), "thread is not a child of the tab"

thread.finished.emit() # simulate the QThread finishing
assert tab._thumb_thread is None, "_on_thumb_thread_done did not run"
# Flush the deferred deletions the finished signal scheduled.
app.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
# Without the deleteLater wiring the QThread would linger as a child of
# the tab, accumulating one per poll tick.
assert not shiboken6.Shiboken.isValid(thread), "the QThread outlived finish"
assert tab.findChildren(QThread) == [], "a QThread lingers as a child"


for name, check in [
("panel_signals", check_panel_signals),
("webrtc_marshal", check_webrtc_marshal),
("thumbnail_reaped", check_thumbnail_reaped),
]:
try:
tab._thumb_timer.stop()
tab._refresh_thumbnails()
thread = tab._thumb_thread
assert thread is not None
assert thread in tab.findChildren(QThread)

thread.finished.emit() # simulate the QThread finishing
assert tab._thumb_thread is None # _on_thumb_thread_done ran
# Flush the deferred deletions the finished signal scheduled.
qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete.value)
# Without the deleteLater wiring the QThread would linger as a child of
# the tab, accumulating one per poll tick.
assert not shiboken6.Shiboken.isValid(thread)
assert tab.findChildren(QThread) == []
finally:
tab.deleteLater()
check()
except ImportError as error:
report[name] = "unavailable: %s" % (error,)
except AssertionError as error:
report[name] = "failed: %s" % (error or "assertion failed",)
except BaseException as error:
report[name] = "error: %s: %s" % (type(error).__name__, error)
else:
report[name] = "ok"

sys.stdout.write(json.dumps(report))
sys.stdout.flush()
# Skip Qt/native-thread teardown entirely -- that teardown is the whole
# reason this runs out of process. The report is already on stdout.
os._exit(0)
"""


@pytest.fixture(scope="module")
def marshal_report():
"""Run all three checks in one child process; return its verdicts."""
env = dict(os.environ, PYTHONPATH=str(REPO_ROOT))
env.setdefault("QT_QPA_PLATFORM", "offscreen")
# argv is this interpreter plus a module-level literal probe. No shell.
completed = subprocess.run( # nosec B603 # nosemgrep # reason: literal argv, no shell
[sys.executable, "-c", _PROBE],
capture_output=True, text=True, check=False, timeout=180, env=env,
)
if completed.returncode != 0 or not completed.stdout:
pytest.fail(
"thread-marshal probe subprocess failed "
f"(exit {completed.returncode}):\n{completed.stdout}\n{completed.stderr}"
)
return json.loads(completed.stdout)


def _verdict(report, key):
"""Turn one probe verdict into a pass, a skip or a named failure."""
status = report.get(key)
assert status is not None, f"{key} missing from the probe report: {report}"
if status.startswith("unavailable:"):
pytest.skip(status)
assert status == "ok", status


def test_panel_signals_expose_file_received(marshal_report):
"""The panel declares the signal the worker hands the file back on."""
_verdict(marshal_report, "panel_signals")


def test_webrtc_received_file_marshaled_to_gui(marshal_report):
"""A file received off-thread reaches the GUI thread via a queued signal."""
_verdict(marshal_report, "webrtc_marshal")


def test_thumbnail_poll_thread_is_reaped(marshal_report):
"""The thumbnail poll deletes its QThread per tick instead of leaking one."""
_verdict(marshal_report, "thumbnail_reaped")
Loading