fix: v0.4.3 修复长会话越跑越卡/命令无响应——日志手工 cursor 裁剪在 PySide6 6.11 必抛异常且打断 _poll_dlpc 消费;改 setMaximumBlockCount(500) 环形裁剪+逐事件兜底;新增 selftest_log 回归;重打包

This commit is contained in:
2026-10-13 11:40:19 +08:00
parent e3ef359df4
commit 296e220f20
3 changed files with 106 additions and 6 deletions
+21
View File
@@ -42,6 +42,8 @@ DLPilot/
| `src/config.py` | 运行目录定位(BASE_DIR)与 pattern_slots.json 读写(slots/area/serial 三键) |
| `src/dlpc_rs485.py` | 光机 RS485 协议模块: 帧构造/增量解析/译码表/预设命令/串口线程(DLPC485) |
| `src/selftest_rs485.py` | 光机协议自检(对照上游实测帧向量, 无硬件回归, 改协议代码后必跑) |
| `src/selftest_theme.py` | 主题切换回归(离屏, swatch 底色/渲染像素/标题字号, 改样式后必跑) |
| `src/selftest_log.py` | 收发日志上限与队列消费回归(离屏, 改 _dlpc_log/_poll_dlpc 后必跑) |
| `src/build_exe.py` | PyInstaller 打包 → `release/DLPilot.exe`(onefile/noconsole, 内嵌图标+pyserial+PySide6-Fluent-Widgets) |
| `src/package_release.py` | 复制图案到 release/(平铺) → `release/DLPilot-v<ver>-win64.zip`(校验栏位引用完整性; release/ 与 zip 内容一致) |
| `src/publish_gitea.py` | 建/复用 Gitea Release 并上传 zip(tag 默认取 zip 文件名) |
@@ -174,6 +176,8 @@ python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insec
2. `python selftest_rs485.py` —— 光机协议回归(改 dlpc_rs485.py 后必跑,向量来自上游 protocols.md);
3. `python selftest_theme.py` —— 主题切换回归(改 app_fluent.py 涉及样式/主题后必跑;
离屏渲染,Dark/Light 往返后校验 swatch 底色样式与渲染像素、标题字号);
3b. `python selftest_log.py` —— 收发日志上限/队列消费回归(改 `_dlpc_log`/`_poll_dlpc`
后必跑;600 行封顶 500、坏条目不中断 drain);
4. `python hdmi_gui.py --seconds 5` —— GUI 冒烟(自动开关);
5. `python hdmi_gui.py --test-pattern Checker --area 800x600 --seconds 3` —— 投图链路;
6. `python hdmi_gui.py --dump` —— EDID 解算不抛异常;
@@ -234,3 +238,20 @@ python publish_gitea.py ../release/DLPilot-v<版本>-win64.zip --replace --insec
本机 COM5 运行态已还原未入库),
https://gitea.hulk.wang/hulk/HW-Debug-Tools/releases/tag/DLPilot-v0.4.2 。
本次发布说明已用 --notes-file 传入(临时文件,未入库)。
- 2026-10-13 v0.4.3 修复"长会话越跑越卡、命令点了没反应"(FilmForge 同案):
根因是 `_dlpc_log` 的手工 textCursor 裁剪用了**实例级枚举访问**
(`cursor.Start`/`cursor.BlockUnderCursor`),PySide6 6.11 移除该形式,收发日志
超 500 行后每次追加必抛 AttributeError;异常又逃出 `_poll_dlpc` 的 drain 循环
(旧 try 只捕获 queue.Empty),每 tick 最多消费到第一条 event,`_dlpc_log_q`
棘轮积压、日志裁剪/自动滚动全失效;电源自检/功率检测序列在 `_pd_step_next()`
之前的 `_dlpc_log` 调用处被打断,`_pd_seq` 永不清空,后续所有手动命令被
"序列进行中"拦截直到断开重连。整改:①改用 `setMaximumBlockCount(500)` 环形
裁剪(O(1),不依赖枚举写法);②`_poll_dlpc` 改逐事件兜底 try/except,单条异常
只打栈放弃该条、不再中断本轮消费;③新增 `selftest_log.py` 回归(600 行封顶 +
坏条目 drain 不中断)。验证:compileall / selftest_rs485 / selftest_theme /
selftest_log ALL PASS;GUI 冒烟 exit 0;`--dump` / `--dlpc-cmd`(无串口)正常。
APP_VERSION → 0.4.3 重打包(67.1 MB),按 §8 约定临时还原预置 pattern_slots.json
后 `package_release.py 0.4.3` 生成 `DLPilot-v0.4.3-win64.zip`(66.8 MB,zip 内
为预置 COM15 配置,本机 COM5 运行态已还原未入库)。遗留(未随本版处理):
`dlpc_rs485.py` 的 `except OSError` 把写超时(SerialTimeoutException)与设备
拔出混判,一次转换器停滞即断链误报"设备拔出"。
+15 -6
View File
@@ -13,6 +13,7 @@ import queue
import re
import sys
import time
import traceback
# PyInstaller keeps Shiboken's ABI DLLs in a sibling directory. Register both
# Qt and Shiboken before importing QtCore; this is required by one-file builds
@@ -63,7 +64,7 @@ import edid_parse
import patterns
APP_NAME = "DLPilot"
APP_VERSION = "0.4.2"
APP_VERSION = "0.4.3"
REPO_URL = "https://gitea.hulk.wang/hulk/HW-Debug-Tools"
MONO = QFont("Consolas", 10)
CHIP_IDLE = "#666666"
@@ -440,6 +441,10 @@ class App(QMainWindow):
trailing=self._button("清空", self._dlpc_clear))
self.txt_dlpc = self._text()
self.txt_dlpc.setMinimumHeight(230)
# 收发日志环形裁剪: 超 500 行自动丢最旧(O(1))。勿改回 textCursor 手工裁剪——
# PySide6 6.11 移除实例级枚举访问, cursor.Start 必抛 AttributeError 且异常会
# 打断 _poll_dlpc 的队列消费(事件棘轮积压), 回归见 selftest_log.py
self.txt_dlpc.document().setMaximumBlockCount(500)
body.addWidget(self.txt_dlpc)
self._dlpc_log("帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC")
layout.addWidget(card)
@@ -755,9 +760,12 @@ class App(QMainWindow):
def _query_lamp(self): self._tl_order.append('LAMP'); self._dlpc_send('M202')
def _poll_dlpc(self):
try:
while True:
while True:
try:
kind, value = self._dlpc_log_q.get_nowait()
except queue.Empty:
break
try:
if kind == 'status':
self.lbl_dlpc_st.setText(value)
if value.startswith(('已断开', '串口丢失')): self._serial_reset_state()
@@ -766,15 +774,16 @@ class App(QMainWindow):
elif kind == 'pdframe': self._pd_step_done(value.type_code == 0xF0, value)
elif kind == 'pwrframe': self._pwr_step_done(value)
else: self._dlpc_log(value)
except queue.Empty: pass
except Exception:
# 单条事件处理异常只打栈放弃该条, 不得中断本轮消费——否则积压随事件流
# 单调增长(v0.4.3 前日志裁剪异常即此路径, 显示延迟越跑越大)
traceback.print_exc()
connected = self.dlpc.is_connected()
if connected != self._dlpc_connected:
self._dlpc_connected = connected; self.btn_dlpc.setText("断开" if connected else "连接")
def _dlpc_log(self, line):
self.txt_dlpc.appendPlainText(time.strftime('%H:%M:%S ') + line)
if self.txt_dlpc.blockCount() > 500:
cursor = self.txt_dlpc.textCursor(); cursor.movePosition(cursor.Start); cursor.select(cursor.BlockUnderCursor); cursor.removeSelectedText(); cursor.deleteChar()
self.txt_dlpc.verticalScrollBar().setValue(self.txt_dlpc.verticalScrollBar().maximum())
def _dlpc_clear(self): self.txt_dlpc.clear()
+70
View File
@@ -0,0 +1,70 @@
# coding: utf-8
"""收发日志上限与队列消费回归自检(无窗口, 离屏渲染)。
背景: v0.4.3 之前收发日志用 textCursor 手工裁剪, 其中 `cursor.Start` /
`cursor.BlockUnderCursor` 是实例级枚举访问 —— PySide6 6.11 移除了该形式,
日志一旦超过 500 行, 每次 _dlpc_log 追加后必抛 AttributeError; 且异常会
逃出 _poll_dlpc 的 drain 循环(旧 try 只捕获 queue.Empty), 每 tick 最多
消费到第一条 event, `_dlpc_log_q` 棘轮积压、日志裁剪与自动滚动全部失效,
电源自检/功率检测序列还会被异常打断在启动处(之后所有命令被"序列进行中"
拦截)。v0.4.3 改为 setMaximumBlockCount 环形裁剪 + 逐事件异常兜底。
本测试保证:
1. 连续追加 600 行日志不抛异常, document().blockCount() 环形封顶 500, 末行在;
2. 单条异常事件(模拟未来引入的坏数据)不中断 _poll_dlpc 本轮队列消费
—— 其后的事件仍全部入日志、队列清空。
运行: python selftest_log.py (依赖 PySide6-Fluent-Widgets 与 pyserial ——
串口卡构建后 txt_dlpc 才存在)
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PySide6.QtWidgets import QApplication
from qfluentwidgets import Theme, setTheme
import app_fluent
import dlpc_rs485
def check_cap(win):
for i in range(600):
win._dlpc_log(f"cap-line-{i}")
blocks = win.txt_dlpc.document().blockCount()
has_last = "cap-line-599" in win.txt_dlpc.toPlainText()
print(f" 追加 600 行: blockCount={blocks}(封顶 500), 末行在: {has_last}")
return blocks <= 500 and has_last
def check_drain_resilience(win):
win._timer.stop() # 排除 80ms 轮询定时器干扰
win._dlpc_log_q.put(("pdframe", "poison-not-a-frame")) # 该条分发必抛异常
win._dlpc_log_q.put(("event", "after-poison-1"))
win._dlpc_log_q.put(("event", "after-poison-2"))
win._poll_dlpc() # 单次手动驱动(不进事件循环)
left = win._dlpc_log_q.qsize()
text = win.txt_dlpc.toPlainText()
drained = left == 0 and "after-poison-1" in text and "after-poison-2" in text
print(f" 坏条目后消费不中断: 残留队列={left}, 后续事件全部写入: {drained}")
return drained
def main():
if not dlpc_rs485.HAS_SERIAL:
print("FAILED 缺少 pyserial: 串口卡未构建, txt_dlpc 不存在")
return 1
app = QApplication.instance() or QApplication(sys.argv)
setTheme(Theme.AUTO)
win = app_fluent.App(0)
passed = check_cap(win)
passed &= check_drain_resilience(win)
win.close()
print("ALL PASS" if passed else "FAILED")
return 0 if passed else 1
if __name__ == "__main__":
sys.exit(main())