fix(ai-studio): keep worker thread alive until finished
This commit is contained in:
@@ -274,6 +274,7 @@ class ImageStudioTab(QWidget):
|
|||||||
self.selected_source_asset_id = None
|
self.selected_source_asset_id = None
|
||||||
self._running_worker = None
|
self._running_worker = None
|
||||||
self._running_thread = None
|
self._running_thread = None
|
||||||
|
self._worker_error_handled = False
|
||||||
self._download_open_after = {}
|
self._download_open_after = {}
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
@@ -603,9 +604,6 @@ class ImageStudioTab(QWidget):
|
|||||||
def pull_main_images(self, checked=False):
|
def pull_main_images(self, checked=False):
|
||||||
alias = str(self.account_combo.currentData() or "").strip()
|
alias = str(self.account_combo.currentData() or "").strip()
|
||||||
item_id = self.item_id_edit.text().strip()
|
item_id = self.item_id_edit.text().strip()
|
||||||
if self.current_project is not None:
|
|
||||||
alias = self.current_project.account_alias
|
|
||||||
item_id = self.current_project.item_id
|
|
||||||
if not alias or not item_id:
|
if not alias or not item_id:
|
||||||
self._message("项目未打开", "请先选择账号和商品ID并打开项目。")
|
self._message("项目未打开", "请先选择账号和商品ID并打开项目。")
|
||||||
return
|
return
|
||||||
@@ -622,9 +620,7 @@ class ImageStudioTab(QWidget):
|
|||||||
self._append_log("[AI工场] 拉取主图开始")
|
self._append_log("[AI工场] 拉取主图开始")
|
||||||
|
|
||||||
def _on_pull_finished(self, summary):
|
def _on_pull_finished(self, summary):
|
||||||
if summary.get("ok") is False:
|
if self._handle_finished_error(summary, "拉取主图失败"):
|
||||||
if self._running_worker is not None:
|
|
||||||
self._on_worker_failed(-1, summary.get("error") or "拉取主图失败")
|
|
||||||
return
|
return
|
||||||
project = summary.get("project")
|
project = summary.get("project")
|
||||||
if project is not None:
|
if project is not None:
|
||||||
@@ -871,9 +867,7 @@ class ImageStudioTab(QWidget):
|
|||||||
self._start_worker(worker, "AI工场下载原图")
|
self._start_worker(worker, "AI工场下载原图")
|
||||||
|
|
||||||
def _on_download_finished(self, summary):
|
def _on_download_finished(self, summary):
|
||||||
if summary.get("ok") is False:
|
if self._handle_finished_error(summary, "下载原图失败"):
|
||||||
if self._running_worker is not None:
|
|
||||||
self._on_worker_failed(-1, summary.get("error") or "下载原图失败")
|
|
||||||
return
|
return
|
||||||
asset = summary.get("asset")
|
asset = summary.get("asset")
|
||||||
self._finish_worker()
|
self._finish_worker()
|
||||||
@@ -1153,9 +1147,7 @@ class ImageStudioTab(QWidget):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _on_export_finished(self, summary):
|
def _on_export_finished(self, summary):
|
||||||
if summary.get("ok") is False:
|
if self._handle_finished_error(summary, "导出终选失败"):
|
||||||
if self._running_worker is not None:
|
|
||||||
self._on_worker_failed(-1, summary.get("error") or "导出终选失败")
|
|
||||||
return
|
return
|
||||||
self._finish_worker()
|
self._finish_worker()
|
||||||
target_dir = summary.get("target_dir") or ""
|
target_dir = summary.get("target_dir") or ""
|
||||||
@@ -1196,9 +1188,7 @@ class ImageStudioTab(QWidget):
|
|||||||
self.billing_label.setText(text)
|
self.billing_label.setText(text)
|
||||||
|
|
||||||
def _on_generation_finished(self, summary):
|
def _on_generation_finished(self, summary):
|
||||||
if summary.get("ok") is False:
|
if self._handle_finished_error(summary, "AI工场生成失败"):
|
||||||
if self._running_worker is not None:
|
|
||||||
self._on_worker_failed(-1, summary.get("error") or "AI工场生成失败")
|
|
||||||
return
|
return
|
||||||
self._finish_worker()
|
self._finish_worker()
|
||||||
self.refresh_project_assets()
|
self.refresh_project_assets()
|
||||||
@@ -1211,6 +1201,9 @@ class ImageStudioTab(QWidget):
|
|||||||
self._status(f"AI工场生成完成:成功{success},失败{failed},停止{cancelled}", level)
|
self._status(f"AI工场生成完成:成功{success},失败{failed},停止{cancelled}", level)
|
||||||
|
|
||||||
def _on_worker_failed(self, row, error):
|
def _on_worker_failed(self, row, error):
|
||||||
|
if self._worker_error_handled:
|
||||||
|
return
|
||||||
|
self._worker_error_handled = True
|
||||||
self._finish_worker()
|
self._finish_worker()
|
||||||
message = diagnostics.redact_log_text(str(error or "未知错误"))
|
message = diagnostics.redact_log_text(str(error or "未知错误"))
|
||||||
self._append_log(f"[AI工场] 失败:{message}")
|
self._append_log(f"[AI工场] 失败:{message}")
|
||||||
@@ -1218,16 +1211,30 @@ class ImageStudioTab(QWidget):
|
|||||||
self._message("AI工场任务失败", message)
|
self._message("AI工场任务失败", message)
|
||||||
self.refresh_project_assets()
|
self.refresh_project_assets()
|
||||||
|
|
||||||
|
def _handle_finished_error(self, summary, fallback_message):
|
||||||
|
if summary.get("ok") is not False:
|
||||||
|
return False
|
||||||
|
if not self._worker_error_handled:
|
||||||
|
self._on_worker_failed(-1, summary.get("error") or fallback_message)
|
||||||
|
return True
|
||||||
|
|
||||||
def _start_worker(self, worker, thread_name):
|
def _start_worker(self, worker, thread_name):
|
||||||
self._set_running(True)
|
thread = run_worker(worker, thread_name=thread_name, start=False)
|
||||||
|
thread.finished.connect(lambda: self._forget_running_thread(thread))
|
||||||
self._running_worker = worker
|
self._running_worker = worker
|
||||||
self._running_thread = run_worker(worker, thread_name)
|
self._running_thread = thread
|
||||||
|
self._worker_error_handled = False
|
||||||
|
self._set_running(True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
def _finish_worker(self):
|
def _finish_worker(self):
|
||||||
self._running_worker = None
|
|
||||||
self._running_thread = None
|
|
||||||
self._set_running(False)
|
self._set_running(False)
|
||||||
|
|
||||||
|
def _forget_running_thread(self, thread):
|
||||||
|
if self._running_thread is thread:
|
||||||
|
self._running_thread = None
|
||||||
|
self._running_worker = None
|
||||||
|
|
||||||
def _set_running(self, running):
|
def _set_running(self, running):
|
||||||
self.open_project_button.setEnabled(not running)
|
self.open_project_button.setEnabled(not running)
|
||||||
self.pull_images_button.setEnabled(not running)
|
self.pull_images_button.setEnabled(not running)
|
||||||
|
|||||||
+23
-2
@@ -3,7 +3,7 @@ id: T-604
|
|||||||
title: AI工场拉取主图 QThread 生命周期闪退修复
|
title: AI工场拉取主图 QThread 生命周期闪退修复
|
||||||
phase: 7
|
phase: 7
|
||||||
deps: [T-591]
|
deps: [T-591]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-11
|
created: 2026-07-11
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,4 +117,25 @@ git diff --check
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
待执行。
|
- 2026-07-11:已完成。
|
||||||
|
- 代码:
|
||||||
|
- `app/gui/tabs/image_studio.py`:AI工场 `_start_worker()` 改为 `run_worker(..., start=False)` 后保存 `worker/thread`,连接 `thread.finished` 再清理引用,最后手动 `thread.start()`;`_finish_worker()` 不再提前置空 `self._running_thread`,避免 `QThread` 仍运行时被销毁。
|
||||||
|
- `app/gui/tabs/image_studio.py`:新增 `_worker_error_handled` 与 `_handle_finished_error()`,收敛 `failed` + `finished(ok=False)` 双信号路径,避免失败重复弹窗/重复日志。
|
||||||
|
- `app/gui/tabs/image_studio.py`:`pull_main_images()` 改为使用当前账号下拉和商品ID输入框,不再被旧 `current_project` 覆盖。
|
||||||
|
- 测试:
|
||||||
|
- `tests/test_gui.py`:新增 AI工场线程生命周期测试,确认 `_finish_worker()` 后线程引用仍保留,只有 `thread.finished` 后才清理。
|
||||||
|
- `tests/test_gui.py`:新增失败只处理一次测试,覆盖 `failed` 后又收到 `finished(ok=False)` 不重复弹窗。
|
||||||
|
- `tests/test_gui.py`:新增拉取主图参数测试,覆盖当前输入商品ID不同于旧项目时仍传入当前输入。
|
||||||
|
- 验证:
|
||||||
|
- 主工作区定向验证通过:
|
||||||
|
- `py -3.10 -m unittest tests.test_gui.GuiTests.test_image_studio_worker_thread_is_kept_until_thread_finished tests.test_gui.GuiTests.test_image_studio_worker_failure_is_handled_once tests.test_gui.GuiTests.test_image_studio_pull_main_images_uses_current_item_input`
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest tests.test_gui.GuiTests.test_image_studio_tab_builds_project_pool_and_template_controls tests.test_gui.GuiTests.test_image_studio_generation_log_uses_cmhub_tier_summary tests.test_gui.GuiTests.test_image_studio_worker_thread_is_kept_until_thread_finished tests.test_gui.GuiTests.test_image_studio_worker_failure_is_handled_once tests.test_gui.GuiTests.test_image_studio_pull_main_images_uses_current_item_input`
|
||||||
|
- `git diff --check -- app\gui\tabs\image_studio.py tests\test_gui.py docs\tasks\T-604.md`
|
||||||
|
- 主工作区全量 `py -3.10 -m unittest discover -s tests` 因既有未提交默认提示词改动失败 3 个用例,失败内容为 `papa1` 与乱码 `默认` 文件名不一致,不属于 T-604。
|
||||||
|
- 为满足提交前全绿验证,已在临时干净 worktree `D:\chengma\cmshopee-t604-verify` 只应用 T-604 diff 后通过:
|
||||||
|
- `python -m ruff check app tests main.py`
|
||||||
|
- `py -3.10 -m compileall app main.py`
|
||||||
|
- `py -3.10 -m unittest discover -s tests`(378 tests)
|
||||||
|
- `git diff --check -- app\gui\tabs\image_studio.py tests\test_gui.py docs\tasks\T-604.md`
|
||||||
|
|||||||
@@ -702,6 +702,102 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_image_studio_worker_thread_is_kept_until_thread_finished(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self.make_config(temp_dir)
|
||||||
|
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
worker = SimpleNamespace()
|
||||||
|
fake_thread = FakeThread()
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.image_studio.run_worker", return_value=fake_thread) as run_worker:
|
||||||
|
tab._start_worker(worker, "AI工场测试线程")
|
||||||
|
|
||||||
|
run_worker.assert_called_once_with(worker, thread_name="AI工场测试线程", start=False)
|
||||||
|
self.assertIs(tab._running_worker, worker)
|
||||||
|
self.assertIs(tab._running_thread, fake_thread)
|
||||||
|
self.assertTrue(fake_thread.started)
|
||||||
|
self.assertFalse(tab.pull_images_button.isEnabled())
|
||||||
|
|
||||||
|
tab._finish_worker()
|
||||||
|
self.assertIs(tab._running_worker, worker)
|
||||||
|
self.assertIs(tab._running_thread, fake_thread)
|
||||||
|
self.assertTrue(tab.pull_images_button.isEnabled())
|
||||||
|
|
||||||
|
fake_thread.finished.emit()
|
||||||
|
self.assertIsNone(tab._running_worker)
|
||||||
|
self.assertIsNone(tab._running_thread)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_image_studio_worker_failure_is_handled_once(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self.make_config(temp_dir)
|
||||||
|
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
messages = []
|
||||||
|
tab._message = lambda title, text: messages.append((title, text))
|
||||||
|
worker = SimpleNamespace()
|
||||||
|
fake_thread = FakeThread()
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.image_studio.run_worker", return_value=fake_thread):
|
||||||
|
tab._start_worker(worker, "AI工场测试线程")
|
||||||
|
|
||||||
|
tab._on_worker_failed(-1, "第一次失败")
|
||||||
|
tab._on_pull_finished({"ok": False, "error": "第二次失败"})
|
||||||
|
|
||||||
|
self.assertEqual([("AI工场任务失败", "第一次失败")], messages)
|
||||||
|
log_text = tab.log_view.toPlainText()
|
||||||
|
self.assertEqual(1, log_text.count("[AI工场] 失败"))
|
||||||
|
self.assertIn("第一次失败", log_text)
|
||||||
|
self.assertNotIn("第二次失败", log_text)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_image_studio_pull_main_images_uses_current_item_input(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
cfg = self.make_config(temp_dir)
|
||||||
|
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||||
|
old_project = image_studio.create_or_get_project(
|
||||||
|
account_alias="alias-a",
|
||||||
|
account_slug="alias_a",
|
||||||
|
item_id="11111111111",
|
||||||
|
path=cfg["db_path"],
|
||||||
|
)
|
||||||
|
tab = ImageStudioTab(config=cfg, db_path=cfg["db_path"])
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
tab._select_project(old_project.id)
|
||||||
|
tab.item_id_edit.setText("22222222222")
|
||||||
|
|
||||||
|
class FakePullWorker:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self, account_alias, item_id, *, db_path=None, config=None):
|
||||||
|
self.account_alias = account_alias
|
||||||
|
self.item_id = item_id
|
||||||
|
self.db_path = db_path
|
||||||
|
self.config = config
|
||||||
|
self.log = DummySignal()
|
||||||
|
self.finished = DummySignal()
|
||||||
|
self.failed = DummySignal()
|
||||||
|
FakePullWorker.instances.append(self)
|
||||||
|
|
||||||
|
fake_thread = FakeThread()
|
||||||
|
with mock.patch(
|
||||||
|
"app.gui.tabs.image_studio.ImageStudioPullImagesWorker",
|
||||||
|
FakePullWorker,
|
||||||
|
), mock.patch("app.gui.tabs.image_studio.run_worker", return_value=fake_thread):
|
||||||
|
tab.pull_main_images()
|
||||||
|
|
||||||
|
self.assertEqual(1, len(FakePullWorker.instances))
|
||||||
|
worker = FakePullWorker.instances[0]
|
||||||
|
self.assertEqual("alias-a", worker.account_alias)
|
||||||
|
self.assertEqual("22222222222", worker.item_id)
|
||||||
|
self.assertEqual("11111111111", old_project.item_id)
|
||||||
|
self.assertTrue(fake_thread.started)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_image_studio_event_log_hides_provider_urls(self):
|
def test_image_studio_event_log_hides_provider_urls(self):
|
||||||
message = gui_workers._format_image_studio_event(
|
message = gui_workers._format_image_studio_event(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user