fix(product-suite): finalize and cancel generation
Tests / Python 3.11 / Windows (push) Has been cancelled

This commit is contained in:
chengma
2026-07-16 17:12:41 +08:00
parent 2675f85598
commit b68e22c094
11 changed files with 1094 additions and 85 deletions
+102 -22
View File
@@ -1447,6 +1447,7 @@ def _download_cmhub_image_with_retry(
use_system_proxy=False, use_system_proxy=False,
download_with_curl="false", download_with_curl="false",
on_step=None, on_step=None,
should_stop=None,
attempts=CMHUB_IMAGE_DOWNLOAD_ATTEMPTS, attempts=CMHUB_IMAGE_DOWNLOAD_ATTEMPTS,
slow_threshold=CMHUB_IMAGE_SLOW_DOWNLOAD_SECONDS, slow_threshold=CMHUB_IMAGE_SLOW_DOWNLOAD_SECONDS,
): ):
@@ -1454,6 +1455,7 @@ def _download_cmhub_image_with_retry(
total_started = time.perf_counter() total_started = time.perf_counter()
last_exc = None last_exc = None
for index in range(total_attempts): for index in range(total_attempts):
_raise_if_download_cancelled(should_stop)
try: try:
image_bytes = _download_cmhub_image( image_bytes = _download_cmhub_image(
url, url,
@@ -1461,7 +1463,9 @@ def _download_cmhub_image_with_retry(
read_timeout=read_timeout, read_timeout=read_timeout,
use_system_proxy=use_system_proxy, use_system_proxy=use_system_proxy,
download_with_curl=download_with_curl, download_with_curl=download_with_curl,
should_stop=should_stop,
) )
_raise_if_download_cancelled(should_stop)
elapsed = time.perf_counter() - total_started elapsed = time.perf_counter() - total_started
if elapsed >= float(slow_threshold or 0): if elapsed >= float(slow_threshold or 0):
_notify_step_event( _notify_step_event(
@@ -1473,6 +1477,8 @@ def _download_cmhub_image_with_retry(
level="warning", level="warning",
) )
return image_bytes, elapsed return image_bytes, elapsed
except CancelledError:
raise
except Exception as exc: except Exception as exc:
last_exc = exc last_exc = exc
if index + 1 >= total_attempts or not _cmhub_download_retryable(exc): if index + 1 >= total_attempts or not _cmhub_download_retryable(exc):
@@ -1486,7 +1492,7 @@ def _download_cmhub_image_with_retry(
attempt=index + 1, attempt=index + 1,
attempts=total_attempts, attempts=total_attempts,
) )
time.sleep(min(2.0, 0.5 * (index + 1))) _sleep_download_retry(min(2.0, 0.5 * (index + 1)), should_stop)
if total_attempts > 1 and _cmhub_download_retryable(last_exc): if total_attempts > 1 and _cmhub_download_retryable(last_exc):
raise AIError( raise AIError(
"下载 cmhub 图片失败(已尝试 %s 次): %s" "下载 cmhub 图片失败(已尝试 %s 次): %s"
@@ -1739,7 +1745,9 @@ def _download_cmhub_image(
max_bytes=CMHUB_IMAGE_MAX_BYTES, max_bytes=CMHUB_IMAGE_MAX_BYTES,
use_system_proxy=False, use_system_proxy=False,
download_with_curl="false", download_with_curl="false",
should_stop=None,
): ):
_raise_if_download_cancelled(should_stop)
_assert_public_http_url(url) _assert_public_http_url(url)
if _should_use_curl_for_cmhub_download(download_with_curl): if _should_use_curl_for_cmhub_download(download_with_curl):
try: try:
@@ -1749,7 +1757,10 @@ def _download_cmhub_image(
read_timeout=read_timeout, read_timeout=read_timeout,
max_bytes=max_bytes, max_bytes=max_bytes,
use_system_proxy=use_system_proxy, use_system_proxy=use_system_proxy,
should_stop=should_stop,
) )
except CancelledError:
raise
except AIError: except AIError:
pass pass
return _download_cmhub_image_with_requests( return _download_cmhub_image_with_requests(
@@ -1757,32 +1768,53 @@ def _download_cmhub_image(
connect_timeout=connect_timeout, connect_timeout=connect_timeout,
read_timeout=read_timeout, read_timeout=read_timeout,
max_bytes=max_bytes, max_bytes=max_bytes,
should_stop=should_stop,
) )
def _download_cmhub_image_with_requests(url, connect_timeout, read_timeout, max_bytes): def _download_cmhub_image_with_requests(
url,
connect_timeout,
read_timeout,
max_bytes,
should_stop=None,
):
response = None
try: try:
_raise_if_download_cancelled(should_stop)
response = _cmhub_session().get( response = _cmhub_session().get(
url, url,
stream=True, stream=True,
timeout=(max(1, int(connect_timeout)), max(1, int(read_timeout))), timeout=(max(1, int(connect_timeout)), max(1, int(read_timeout))),
) )
_raise_if_download_cancelled(should_stop)
status = getattr(response, "status_code", 200)
if status >= 400:
raise AIError("下载 cmhub 图片失败: HTTP %s" % status)
chunks = []
total = 0
iterator = (
response.iter_content(chunk_size=65536)
if hasattr(response, "iter_content")
else [response.content]
)
for chunk in iterator:
_raise_if_download_cancelled(should_stop)
if not chunk:
continue
total += len(chunk)
if total > max_bytes:
raise AIError("下载 cmhub 图片失败: 图片超过大小上限")
chunks.append(chunk)
_raise_if_download_cancelled(should_stop)
return b"".join(chunks)
except CancelledError:
raise
except requests.exceptions.RequestException as exc: except requests.exceptions.RequestException as exc:
raise AIError("下载 cmhub 图片失败: %s" % exc) from exc raise AIError("下载 cmhub 图片失败: %s" % exc) from exc
status = getattr(response, "status_code", 200) finally:
if status >= 400: if response is not None and hasattr(response, "close"):
raise AIError("下载 cmhub 图片失败: HTTP %s" % status) response.close()
chunks = []
total = 0
iterator = response.iter_content(chunk_size=65536) if hasattr(response, "iter_content") else [response.content]
for chunk in iterator:
if not chunk:
continue
total += len(chunk)
if total > max_bytes:
raise AIError("下载 cmhub 图片失败: 图片超过大小上限")
chunks.append(chunk)
return b"".join(chunks)
def _should_use_curl_for_cmhub_download(mode): def _should_use_curl_for_cmhub_download(mode):
@@ -1846,6 +1878,7 @@ def _download_cmhub_image_with_curl(
read_timeout, read_timeout,
max_bytes, max_bytes,
use_system_proxy=False, use_system_proxy=False,
should_stop=None,
): ):
curl_path = _find_system_curl() curl_path = _find_system_curl()
if not curl_path: if not curl_path:
@@ -1881,20 +1914,36 @@ def _download_cmhub_image_with_curl(
] ]
if not bool(use_system_proxy): if not bool(use_system_proxy):
args.extend(["--noproxy", "*"]) args.extend(["--noproxy", "*"])
process = None
try: try:
completed = subprocess.run( process = subprocess.Popen(
args, args,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=max(2, int(connect_timeout) + int(read_timeout) + 10),
check=False,
shell=False, shell=False,
**_subprocess_hidden_window_kwargs(), **_subprocess_hidden_window_kwargs(),
) )
except (OSError, subprocess.TimeoutExpired) as exc: deadline = time.monotonic() + max(
2,
int(connect_timeout) + int(read_timeout) + 10,
)
while process.poll() is None:
try:
_raise_if_download_cancelled(should_stop)
except CancelledError:
_stop_download_process(process)
raise
if time.monotonic() >= deadline:
_stop_download_process(process)
raise AIError("下载 cmhub 图片失败: curl 执行超时")
time.sleep(0.1)
process.communicate()
except CancelledError:
raise
except OSError as exc:
raise AIError("下载 cmhub 图片失败: curl 执行失败") from exc raise AIError("下载 cmhub 图片失败: curl 执行失败") from exc
if completed.returncode != 0: if process.returncode != 0:
raise AIError("下载 cmhub 图片失败: curl 退出码 %s" % completed.returncode) raise AIError("下载 cmhub 图片失败: curl 退出码 %s" % process.returncode)
size = os.path.getsize(temp_output_path) size = os.path.getsize(temp_output_path)
if size > max_bytes: if size > max_bytes:
raise AIError("下载 cmhub 图片失败: 图片超过大小上限") raise AIError("下载 cmhub 图片失败: 图片超过大小上限")
@@ -1909,6 +1958,37 @@ def _download_cmhub_image_with_curl(
pass pass
def _stop_download_process(process):
if process is None or process.poll() is not None:
return
try:
process.terminate()
process.wait(timeout=2)
except (OSError, subprocess.TimeoutExpired):
try:
process.kill()
process.wait(timeout=2)
except (OSError, subprocess.TimeoutExpired):
pass
def _raise_if_download_cancelled(should_stop):
try:
stopped = bool(should_stop and should_stop())
except Exception:
stopped = False
if stopped:
raise CancelledError()
def _sleep_download_retry(delay_seconds, should_stop):
deadline = time.monotonic() + max(0.0, float(delay_seconds or 0.0))
while time.monotonic() < deadline:
_raise_if_download_cancelled(should_stop)
time.sleep(min(0.1, max(0.0, deadline - time.monotonic())))
_raise_if_download_cancelled(should_stop)
def _curl_config_quote(value): def _curl_config_quote(value):
text = str(value or "") text = str(value or "")
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
+393 -27
View File
@@ -5,9 +5,20 @@ from __future__ import annotations
import os import os
import re import re
import time import time
import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QRect, QSize, Qt, QTimer, Signal from PySide6.QtCore import (
QByteArray,
QBuffer,
QIODevice,
QRect,
QSize,
Qt,
QTimer,
Signal,
Slot,
)
from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap from PySide6.QtGui import QColor, QIcon, QImage, QImageReader, QKeySequence, QPainter, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
@@ -688,6 +699,9 @@ class SuiteTaskState:
show_history: bool = False show_history: bool = False
worker: object = None worker: object = None
thread: object = None thread: object = None
generation_run_token: str = ""
generation_stop_requested: bool = False
generation_terminal_streak: int = 0
pull_worker: object = None pull_worker: object = None
pull_thread: object = None pull_thread: object = None
import_worker: object = None import_worker: object = None
@@ -739,6 +753,7 @@ class ProductSuiteTab(QWidget):
self._next_serial = 1 self._next_serial = 1
self._displayed_state = None self._displayed_state = None
self._prompt_save_timers = {} self._prompt_save_timers = {}
self._generation_run_states = {}
self._original_list_context = None self._original_list_context = None
self._loading = False self._loading = False
self._result_refresh_pending = False self._result_refresh_pending = False
@@ -759,6 +774,10 @@ class ProductSuiteTab(QWidget):
self.elapsed_timer.setInterval(1000) self.elapsed_timer.setInterval(1000)
self.elapsed_timer.timeout.connect(self._refresh_elapsed) self.elapsed_timer.timeout.connect(self._refresh_elapsed)
self.elapsed_timer.start() self.elapsed_timer.start()
self.generation_watchdog_timer = QTimer(self)
self.generation_watchdog_timer.setInterval(750)
self.generation_watchdog_timer.timeout.connect(self._check_generation_watchdogs)
self.generation_watchdog_timer.start()
if self._prompt_template_init_error: if self._prompt_template_init_error:
self._status(self._prompt_template_init_error, "danger") self._status(self._prompt_template_init_error, "danger")
@@ -1324,6 +1343,7 @@ class ProductSuiteTab(QWidget):
destructive=True, destructive=True,
): ):
return return
state.generation_stop_requested = True
state.worker.cancel() state.worker.cancel()
if state.ai_worker is not None: if state.ai_worker is not None:
state.ai_worker.cancel() state.ai_worker.cancel()
@@ -1347,6 +1367,8 @@ class ProductSuiteTab(QWidget):
except Exception as exc: except Exception as exc:
self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger") self._status("清理空临时草稿失败:%s" % _user_error(exc), "danger")
self._release_prompt_save_timer(state) self._release_prompt_save_timer(state)
if state.generation_run_token:
self._generation_run_states.pop(state.generation_run_token, None)
self._retired_states.append(state) self._retired_states.append(state)
self._states.pop(state.key, None) self._states.pop(state.key, None)
self.task_tabs.removeTab(index) self.task_tabs.removeTab(index)
@@ -1884,6 +1906,10 @@ class ProductSuiteTab(QWidget):
token = id(thread) token = id(thread)
_PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker) _PRODUCT_SUITE_THREAD_REFS[token] = (thread, worker)
thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None)) thread.finished.connect(lambda token=token: _PRODUCT_SUITE_THREAD_REFS.pop(token, None))
run_token = str(getattr(worker, "run_token", "") or "")
if run_token:
thread.setProperty("productSuiteRunToken", run_token)
thread.finished.connect(self._on_generation_thread_finished_signal)
thread.start() thread.start()
return thread return thread
@@ -2491,14 +2517,22 @@ class ProductSuiteTab(QWidget):
if state is None: if state is None:
return return
if state.generation_running(): if state.generation_running():
if state.generation_stop_requested:
self._status("正在停止当前套图任务", "warning")
return
if self._confirm( if self._confirm(
"停止生成套图", "停止生成套图",
"确认取消当前任务吗?已提交任务会在安全边界停止。", "确认取消当前任务吗?已提交任务会在安全边界停止。",
destructive=True, destructive=True,
): ):
state.generation_stop_requested = True
state.worker.cancel() state.worker.cancel()
self.generate_button.setText("正在停止...") self._log_generation_lifecycle(
self.generate_button.setEnabled(False) state,
state.generation_run_token,
"stop_requested",
)
self._apply_running_state(state)
self._status("已请求停止当前套图任务", "warning") self._status("已请求停止当前套图任务", "warning")
return return
self.start_generation(state) self.start_generation(state)
@@ -2547,26 +2581,38 @@ class ProductSuiteTab(QWidget):
): ):
return False return False
self._persist_state(state) self._persist_state(state)
run_token = uuid.uuid4().hex
worker = ProductSuiteGenerateWorker( worker = ProductSuiteGenerateWorker(
state.project_id, state.project_id,
specs, specs,
run_token=run_token,
aspect_ratio=state.settings["ratio"], aspect_ratio=state.settings["ratio"],
db_path=self.db_path, db_path=self.db_path,
config=self.config, config=self.config,
cmhub_config_path=self.cmhub_config_path, cmhub_config_path=self.cmhub_config_path,
) )
state.worker = worker state.worker = worker
state.generation_run_token = run_token
state.generation_stop_requested = False
state.generation_terminal_streak = 0
state.done = 0 state.done = 0
state.failed = 0 state.failed = 0
state.total = len(specs) state.total = len(specs)
state.started_at = time.monotonic() state.started_at = time.monotonic()
state.current_job_ids = [] state.current_job_ids = []
state.show_history = False state.show_history = False
worker.progress.connect(lambda payload, state=state: self._on_generation_progress(state, payload)) self._generation_run_states[run_token] = state.key
worker.finished.connect(lambda result, state=state: self._on_generation_finished(state, result)) worker.progress.connect(self._on_generation_progress_signal)
worker.cancelled.connect(lambda result, state=state: self._on_generation_finished(state, result)) worker.finished.connect(self._on_generation_finished_signal)
worker.failed.connect(lambda row, error, state=state: self._on_generation_failed(state, error)) worker.cancelled.connect(self._on_generation_finished_signal)
worker.failed.connect(self._on_generation_failed_signal)
state.thread = self._start_thread(worker, "商品套图生成") state.thread = self._start_thread(worker, "商品套图生成")
self._log_generation_lifecycle(
state,
run_token,
"started",
{"total": len(specs), "job_ids": 0},
)
if state is self._displayed_state: if state is self._displayed_state:
self._loading = True self._loading = True
try: try:
@@ -2578,52 +2624,367 @@ class ProductSuiteTab(QWidget):
self._status("商品套图生成已开始,共%d张;可切换到其他任务" % len(specs), "info") self._status("商品套图生成已开始,共%d张;可切换到其他任务" % len(specs), "info")
return True return True
def _generation_signal_token(self, payload=None):
token = str((payload or {}).get("run_token") or "")
sender = self.sender()
return token or str(getattr(sender, "run_token", "") or "")
def _generation_state(self, run_token):
token = str(run_token or "")
state = self._states.get(self._generation_run_states.get(token))
if state is None or state.generation_run_token != token:
return None
return state
@Slot(dict)
def _on_generation_progress_signal(self, payload):
token = self._generation_signal_token(payload)
state = self._generation_state(token)
if state is None:
return
self._on_generation_progress(state, payload)
def _on_generation_progress(self, state, payload): def _on_generation_progress(self, state, payload):
state.total = int(payload.get("total", state.total) or state.total) if "total" in payload:
state.total = max(0, int(payload.get("total") or 0))
state.done = int(payload.get("done", state.done) or 0) state.done = int(payload.get("done", state.done) or 0)
state.failed = int(payload.get("failed", state.failed) or 0) state.failed = int(payload.get("failed", state.failed) or 0)
job_ids = payload.get("job_ids") job_ids = payload.get("job_ids")
if job_ids: if job_ids is not None:
state.current_job_ids = [int(job_id) for job_id in job_ids] state.current_job_ids = [int(job_id) for job_id in job_ids]
state.generation_terminal_streak = 0
if state is self._displayed_state: if state is self._displayed_state:
self._refresh_results(state) self._refresh_results(state)
self._refresh_elapsed() self._refresh_elapsed()
def _on_generation_failed(self, state, error): @Slot(int, str)
def _on_generation_failed_signal(self, row, error):
token = self._generation_signal_token()
state = self._generation_state(token)
if state is None:
return
self._log_generation_lifecycle(
state,
token,
"worker_failed",
{"has_error": True},
level="ERROR",
)
self._status("商品套图生成失败:%s" % _user_error(error), "danger") self._status("商品套图生成失败:%s" % _user_error(error), "danger")
def _on_generation_finished(self, state, result): @Slot(dict)
def _on_generation_finished_signal(self, result):
token = self._generation_signal_token(result)
state = self._generation_state(token)
if state is None:
return
self._log_generation_lifecycle(
state,
token,
"worker_finished",
{
"ok": result.get("ok", True),
"success": result.get("success", 0),
"failed": result.get("failed", 0),
"cancelled": result.get(
"cancelled_count",
result.get("cancelled", 0),
),
},
)
if result.get("ok") is False:
self._reconcile_generation_jobs(state, "生成线程异常结束")
if (
state.generation_stop_requested
and not self._generation_job_ids(state)
and result.get("cancelled") is True
):
result = dict(result)
result["total"] = state.total
result["cancelled_count"] = state.total
self._finalize_generation(state, token, result, source="worker")
@Slot()
def _on_generation_thread_finished_signal(self):
sender = self.sender()
token = str(
sender.property("productSuiteRunToken")
if sender is not None
else ""
)
self._handle_generation_thread_finished(token)
def _handle_generation_thread_finished(self, run_token):
state = self._generation_state(run_token)
if state is None:
return
snapshot = self._generation_job_snapshot(state)
had_active = bool(snapshot["active"])
self._log_generation_lifecycle(
state,
run_token,
"thread_finished_fallback",
snapshot,
level="WARNING" if snapshot["active"] else "INFO",
)
if had_active:
self._reconcile_generation_jobs(state, "生成线程已结束")
snapshot = self._generation_job_snapshot(state)
result = self._generation_result_from_snapshot(snapshot)
if not snapshot["job_ids"] and state.generation_stop_requested:
result.update(
{
"total": state.total,
"cancelled_count": state.total,
}
)
elif had_active:
result.update(
{
"ok": False,
"error": "生成线程已结束,部分任务可稍后继续查询",
}
)
self._finalize_generation(
state,
run_token,
result,
source="thread_finished",
)
def _check_generation_watchdogs(self):
for state in list(self._states.values()):
token = state.generation_run_token
if not token or not state.generation_running():
continue
snapshot = self._generation_job_snapshot(state)
if snapshot["all_terminal"]:
state.generation_terminal_streak += 1
else:
state.generation_terminal_streak = 0
if state.generation_terminal_streak < 2:
continue
self._log_generation_lifecycle(
state,
token,
"terminal_watchdog_finalize",
snapshot,
level="WARNING",
)
self._finalize_generation(
state,
token,
self._generation_result_from_snapshot(snapshot),
source="terminal_watchdog",
)
def _generation_job_ids(self, state):
job_ids = list(state.current_job_ids)
if not job_ids and state.worker is not None:
worker_job_ids = getattr(state.worker, "job_ids", [])
if not isinstance(worker_job_ids, (list, tuple, set)):
worker_job_ids = []
job_ids = [
int(job_id)
for job_id in list(worker_job_ids or [])
]
if job_ids:
state.current_job_ids = job_ids
return job_ids
def _generation_job_snapshot(self, state):
job_ids = self._generation_job_ids(state)
counts = {
"success": 0,
"failed": 0,
"cancelled": 0,
"active": 0,
"job_ids": len(job_ids),
"all_terminal": False,
}
if not job_ids:
counts["active"] = max(0, int(state.total or 0))
return counts
for job_id in job_ids:
try:
job = image_studio.get_job(job_id, path=self.db_path)
except Exception:
job = None
status = str(getattr(job, "status", "") or "")
if status == "succeeded":
counts["success"] += 1
elif status in {"failed", "expired"}:
counts["failed"] += 1
elif status == "cancelled":
counts["cancelled"] += 1
else:
counts["active"] += 1
counts["all_terminal"] = (
len(job_ids) == int(state.total or 0)
and counts["active"] == 0
)
return counts
def _generation_result_from_snapshot(self, snapshot):
return {
"total": int(snapshot.get("job_ids", 0) or 0),
"success": int(snapshot.get("success", 0) or 0),
"failed": int(snapshot.get("failed", 0) or 0),
"cancelled_count": int(snapshot.get("cancelled", 0) or 0),
}
def _reconcile_generation_jobs(self, state, reason):
for job_id in self._generation_job_ids(state):
try:
job = image_studio.get_job(job_id, path=self.db_path)
if job is None or job.status in {
"succeeded",
"failed",
"expired",
"cancelled",
}:
continue
recovery = (
image_studio.JOB_RECOVERY_RESUME
if job.task_id
else image_studio.JOB_RECOVERY_REGENERATE
)
image_studio.update_job_status(
job.id,
"cancelled",
error="%s,任务可稍后继续处理" % reason,
recovery_action=recovery,
path=self.db_path,
)
except Exception as exc:
self._status(
"商品套图任务状态收尾失败:%s" % _user_error(exc),
"danger",
)
def _finalize_generation(self, state, run_token, result, *, source):
if self._generation_state(run_token) is not state:
return False
snapshot = self._generation_job_snapshot(state)
if snapshot["job_ids"] and (
snapshot["all_terminal"]
or source in {"thread_finished", "terminal_watchdog"}
):
result = dict(result or {})
result.update(self._generation_result_from_snapshot(snapshot))
result = dict(result or {})
stop_requested = state.generation_stop_requested
success = int(result.get("success", 0) or 0)
failed = int(result.get("failed", 0) or 0)
cancelled = int(
result.get("cancelled_count", result.get("cancelled", 0)) or 0
)
total = int(result.get("total", state.total) or state.total)
active = max(0, total - success - failed - cancelled)
elapsed = (
int(max(0, time.monotonic() - state.started_at))
if state.started_at
else 0
)
self._generation_run_states.pop(run_token, None)
state.generation_run_token = ""
state.generation_stop_requested = False
state.generation_terminal_streak = 0
state.worker = None state.worker = None
state.thread = None state.thread = None
state.done = int(result.get("success", 0) or 0) + int(result.get("failed", 0) or 0) + int( state.done = success + failed + cancelled
result.get("cancelled", 0) or 0 state.failed = failed
) state.total = total
state.failed = int(result.get("failed", state.failed) or 0)
state.total = int(result.get("total", state.total) or state.total)
elapsed = int(max(0, time.monotonic() - state.started_at)) if state.started_at else 0
state.started_at = None state.started_at = None
if state is self._displayed_state: if state is self._displayed_state:
self._apply_running_state(state) self._apply_running_state(state)
self._refresh_results(state) self._refresh_results(state)
self._refresh_elapsed() self._refresh_elapsed()
self._log_generation_lifecycle(
state,
run_token,
"finalized",
{
"source": source,
"total": total,
"success": success,
"failed": failed,
"cancelled": cancelled,
"active": active,
"elapsed_seconds": elapsed,
},
level="WARNING" if active or result.get("ok") is False else "INFO",
)
if state.key not in self._states: if state.key not in self._states:
return return True
if result.get("ok") is False: if active or result.get("ok") is False:
message = str(
result.get("error")
or "生成线程已结束,部分任务可稍后继续查询"
)
if state is self._displayed_state: if state is self._displayed_state:
self._message("商品套图生成失败", _user_error(result.get("error"))) self._message("商品套图生成未完整结束", _user_error(message))
else: else:
self._status("套图任务%d生成失败" % state.serial, "danger") self._status(
return "套图任务%d生成未完整结束" % state.serial,
success = int(result.get("success", 0) or 0) "danger",
cancelled = int(result.get("cancelled", 0) or 0) )
return True
if stop_requested or cancelled:
if state is self._displayed_state:
self._message(
"商品套图生成已停止",
"本轮共%d张:成功%d张,失败%d张,停止%d张;"
"已提交任务可稍后继续查询;总用时%d秒。"
% (total, success, failed, cancelled, elapsed),
icon=QMessageBox.Information,
)
self._status(
"商品套图生成已停止:成功%d张,失败%d张,停止%d张"
% (success, failed, cancelled),
"warning",
)
return True
if state is self._displayed_state: if state is self._displayed_state:
self._message( self._message(
"商品套图生成完成", "商品套图生成完成",
"本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。" "本轮共%d张:成功%d张,失败%d张,停止%d张;总用时%d秒。"
% (state.total, success, state.failed, cancelled, elapsed), % (total, success, failed, cancelled, elapsed),
icon=QMessageBox.Information, icon=QMessageBox.Information,
) )
self._status("商品套图生成完成:成功%d张,失败%d张" % (success, state.failed), "success") self._status(
"商品套图生成完成:成功%d张,失败%d张" % (success, failed),
"success",
)
return True
def _log_generation_lifecycle(
self,
state,
run_token,
event,
payload=None,
*,
level="INFO",
):
data = {
"run_token": str(run_token or "")[:8],
"project_id": getattr(state, "project_id", None),
"event": str(event or ""),
}
data.update(dict(payload or {}))
try:
diagnostics.write_diagnostic_log(
"商品套图生成生命周期",
level=level,
step="product_suite_generation",
task_id=getattr(state, "project_id", None),
item_id=getattr(state, "item_id", None),
payload=data,
log_dir=appconfig.diagnostic_log_dir(self.config),
)
except Exception:
pass
def _apply_running_state(self, state): def _apply_running_state(self, state):
generation_running = state.generation_running() generation_running = state.generation_running()
@@ -2648,7 +3009,11 @@ class ProductSuiteTab(QWidget):
row.set_controls_enabled(not generation_running) row.set_controls_enabled(not generation_running)
self.generate_button.setEnabled(True) self.generate_button.setEnabled(True)
if generation_running: if generation_running:
self.generate_button.setText("停止生成") self.generate_button.setText(
"正在停止..."
if state.generation_stop_requested
else "停止生成"
)
self.generate_button.setStyleSheet( self.generate_button.setStyleSheet(
"QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }" "QPushButton { background: #cf222e; color: white; border-color: #a40e26; font-weight: 600; }"
"QPushButton:hover { background: #a40e26; }" "QPushButton:hover { background: #a40e26; }"
@@ -2909,4 +3274,5 @@ class ProductSuiteTab(QWidget):
worker.cancel() worker.cancel()
for state in list(self._states.values()) + list(self._retired_states): for state in list(self._states.values()) + list(self._retired_states):
self._release_prompt_save_timer(state) self._release_prompt_save_timer(state)
self._generation_run_states.clear()
super().closeEvent(event) super().closeEvent(event)
+11 -5
View File
@@ -303,6 +303,7 @@ class ProductSuiteGenerateWorker(BaseWorker):
project_id, project_id,
job_specs, job_specs,
*, *,
run_token="",
aspect_ratio="1:1", aspect_ratio="1:1",
db_path=None, db_path=None,
config=None, config=None,
@@ -311,10 +312,12 @@ class ProductSuiteGenerateWorker(BaseWorker):
super().__init__() super().__init__()
self.project_id = int(project_id) self.project_id = int(project_id)
self.job_specs = [dict(spec) for spec in (job_specs or [])] self.job_specs = [dict(spec) for spec in (job_specs or [])]
self.run_token = str(run_token or "")
self.aspect_ratio = str(aspect_ratio or "1:1") self.aspect_ratio = str(aspect_ratio or "1:1")
self.db_path = db_path self.db_path = db_path
self.config = config self.config = config
self.cmhub_config_path = cmhub_config_path self.cmhub_config_path = cmhub_config_path
self.job_ids = []
self._done = 0 self._done = 0
self._failed = 0 self._failed = 0
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -325,8 +328,6 @@ class ProductSuiteGenerateWorker(BaseWorker):
raise ValueError("商品套图生成任务不能为空") raise ValueError("商品套图生成任务不能为空")
jobs = [] jobs = []
for spec in self.job_specs: for spec in self.job_specs:
if self.should_cancel():
break
jobs.append( jobs.append(
image_studio.create_job( image_studio.create_job(
self.project_id, self.project_id,
@@ -338,12 +339,14 @@ class ProductSuiteGenerateWorker(BaseWorker):
path=self.db_path, path=self.db_path,
) )
) )
self.job_ids = [job.id for job in jobs]
self.progress.emit( self.progress.emit(
{ {
"run_token": self.run_token,
"total": len(jobs), "total": len(jobs),
"done": 0, "done": 0,
"failed": 0, "failed": 0,
"job_ids": [job.id for job in jobs], "job_ids": list(self.job_ids),
} }
) )
@@ -356,10 +359,11 @@ class ProductSuiteGenerateWorker(BaseWorker):
if event.get("result") != "success": if event.get("result") != "success":
self._failed += 1 self._failed += 1
progress = { progress = {
"run_token": self.run_token,
"total": len(jobs), "total": len(jobs),
"done": self._done, "done": self._done,
"failed": self._failed, "failed": self._failed,
"job_ids": [job.id for job in jobs], "job_ids": list(self.job_ids),
} }
self.progress.emit(progress) self.progress.emit(progress)
@@ -373,7 +377,9 @@ class ProductSuiteGenerateWorker(BaseWorker):
on_event=on_event, on_event=on_event,
) )
summary["project_id"] = self.project_id summary["project_id"] = self.project_id
summary["job_ids"] = [job.id for job in jobs] summary["job_ids"] = list(self.job_ids)
summary["cancelled_count"] = int(summary.get("cancelled", 0) or 0)
summary["run_token"] = self.run_token
return summary return summary
+60 -15
View File
@@ -6,7 +6,7 @@ import os
import threading import threading
import time import time
import urllib.parse import urllib.parse
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from concurrent.futures import CancelledError, FIRST_COMPLETED, ThreadPoolExecutor, wait
from . import ai, appconfig, image_studio from . import ai, appconfig, image_studio
from .version import APP_VERSION from .version import APP_VERSION
@@ -185,7 +185,11 @@ def run_jobs(
for job in job_list for job in job_list
} }
while futures: while futures:
done, _ = wait(set(futures), return_when=FIRST_COMPLETED) done, _ = wait(
set(futures),
timeout=0.2,
return_when=FIRST_COMPLETED,
)
for future in done: for future in done:
futures.pop(future) futures.pop(future)
try: try:
@@ -252,7 +256,14 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event) request_result = _poll_job(job.id, request_result["task_id"], runtime, request_result, db_path, should_stop, on_event)
_raise_if_stopped(should_stop) _raise_if_stopped(should_stop)
out_path = _output_path(project, job, image_root) out_path = _output_path(project, job, image_root)
saved_path = _download_and_save_job_image(request_result, out_path, config, on_event, job.id) saved_path = _download_and_save_job_image(
request_result,
out_path,
config,
on_event,
job.id,
should_stop=should_stop,
)
try: try:
_raise_if_stopped(should_stop) _raise_if_stopped(should_stop)
except ImageStudioGenerationError: except ImageStudioGenerationError:
@@ -281,17 +292,27 @@ def _run_one_job(job_id, runtime, config, image_root, aspect_ratio, db_path, sho
_notify(on_event, {"job_id": job.id, "step": "job_done", "result": "success"}) _notify(on_event, {"job_id": job.id, "step": "job_done", "result": "success"})
return {"job": updated, "asset": asset, "status": "succeeded"} return {"job": updated, "asset": asset, "status": "succeeded"}
except Exception as exc: except Exception as exc:
status = "cancelled" if "停止" in str(exc) else "failed" cancelled = isinstance(exc, CancelledError) or "停止" in str(exc)
status = "cancelled" if cancelled else "failed"
error = "用户已停止,已提交任务可稍后继续查询" if cancelled else str(exc)
current_job = image_studio.get_job(job.id, path=db_path) current_job = image_studio.get_job(job.id, path=db_path)
updated = image_studio.update_job_status( updated = image_studio.update_job_status(
job.id, job.id,
status, status,
error=str(exc), error=error,
recovery_action=_recovery_action_for_job(current_job), recovery_action=_recovery_action_for_job(current_job),
path=db_path, path=db_path,
) )
_notify(on_event, {"job_id": job.id, "step": "job_done", "result": status, "detail": str(exc)}) _notify(
return {"job": updated, "status": status, "error": str(exc)} on_event,
{
"job_id": job.id,
"step": "job_done",
"result": status,
"detail": error,
},
)
return {"job": updated, "status": status, "error": error}
def _submit_or_resume_job( def _submit_or_resume_job(
@@ -395,6 +416,7 @@ def _poll_job(job_id, task_id, runtime, request_result, db_path, should_stop, on
read_timeout=ai.CMHUB_IMAGE_POLL_READ_TIMEOUT_SECONDS, read_timeout=ai.CMHUB_IMAGE_POLL_READ_TIMEOUT_SECONDS,
headers_extra={"X-Client-Version": str(APP_VERSION)}, headers_extra={"X-Client-Version": str(APP_VERSION)},
) )
_raise_if_stopped(should_stop)
status = str(data.get("status") or "").strip().lower() status = str(data.get("status") or "").strip().lower()
if status in {"queued", "running"}: if status in {"queued", "running"}:
_notify(on_event, {"job_id": job_id, "step": "cover_poll", "result": status, "task_id": task_id}) _notify(on_event, {"job_id": job_id, "step": "cover_poll", "result": status, "task_id": task_id})
@@ -424,21 +446,44 @@ def _poll_job(job_id, task_id, runtime, request_result, db_path, should_stop, on
raise ImageStudioGenerationError("cmhub 生图任务状态返回格式错误") raise ImageStudioGenerationError("cmhub 生图任务状态返回格式错误")
def _download_and_save_job_image(request_result, out_path, config, on_event, job_id): def _download_and_save_job_image(
request_result,
out_path,
config,
on_event,
job_id,
should_stop=None,
):
_raise_if_stopped(should_stop)
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "start"}) _notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "start"})
image_bytes, _ = ai._download_cmhub_image_with_retry( try:
request_result["image_url"], image_bytes, _ = ai._download_cmhub_image_with_retry(
connect_timeout=request_result["connect_timeout"], request_result["image_url"],
read_timeout=request_result["read_timeout"], connect_timeout=request_result["connect_timeout"],
use_system_proxy=request_result.get("use_system_proxy", False), read_timeout=request_result["read_timeout"],
download_with_curl=request_result.get("download_with_curl", "auto"), use_system_proxy=request_result.get("use_system_proxy", False),
) download_with_curl=request_result.get("download_with_curl", "auto"),
should_stop=should_stop,
)
except CancelledError as exc:
raise ImageStudioGenerationError(
"用户已停止,已提交任务可稍后继续查询"
) from exc
_raise_if_stopped(should_stop)
saved_path = ai._save_jpeg( saved_path = ai._save_jpeg(
image_bytes, image_bytes,
out_path, out_path,
request_result.get("resolution") or appconfig.ai_config(config).get("resolution", "1k"), request_result.get("resolution") or appconfig.ai_config(config).get("resolution", "1k"),
request_result.get("quality") or appconfig.ai_config(config).get("jpg_quality", 90), request_result.get("quality") or appconfig.ai_config(config).get("jpg_quality", 90),
) )
try:
_raise_if_stopped(should_stop)
except ImageStudioGenerationError:
try:
if os.path.isfile(saved_path):
os.remove(saved_path)
finally:
raise
_notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "success"}) _notify(on_event, {"job_id": job_id, "step": "cover_download", "result": "success"})
return saved_path return saved_path
+1
View File
@@ -437,6 +437,7 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
- ⑥已选账号但未填写商品 ID 时允许导入、拖入或粘贴本地图片,首次有效导入才创建草稿;取消选择和全部导入失败不保留空草稿。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。 - ⑥已选账号但未填写商品 ID 时允许导入、拖入或粘贴本地图片,首次有效导入才创建草稿;取消选择和全部导入失败不保留空草稿。草稿可管理本地图片、AI 帮写、生成套图、查看历史和打开结果目录,但在创建 worker、启动 Chrome 或执行 CDP 前禁止「拉取蝦皮主图」。输入合法数字商品 ID 后,经确认原地绑定同一个 `project_id`;资产、job、selection、提示词、套图设置和 `storage_key` 均保持不变。若同账号目标 ID(含软删除项目)已存在则拒绝覆盖或合并。
- 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。 - 启动时恢复未软删除、至少含一条资产或生成任务的草稿为独立中文“临时草稿”标签,按最近更新时间排序。关闭非空草稿可选择保留、软删除或取消;软删除不物理删除图片目录。③「更新蝦皮」只处理正式任务,不接受临时草稿。
- 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。 - 第六 Tab 的多个 `SuiteTaskState` 各自保留 generation/pull/import/AI/download worker 与线程引用;切换任务不取消任务。多个任务可并行,但 `image_studio_generation` 使用进程级 semaphore 保证所有套图任务合计最多5个 cmhub 在途 job。线程还在运行时关闭任务只请求协作式停止,模块级引用保留到 `QThread.finished`,不得提前销毁线程对象;下载前后均检查停止信号,停止后的临时文件不入资产库。
- T-639 后每轮套图生成使用仅存在内存的 `run_token` 隔离迟到信号,progress/finished/cancelled/failed 通过主线程绑定槽统一处理;正常 worker 结果、`QThread.finished` 和本轮 job 连续两次全部终态看门狗共同进入幂等 finalize。GUI 只按本轮明确 `job_ids` 判断完成,不用历史图片数量;即使最终信号丢失也会恢复按钮,旧线程引用仍保留到真实结束。停止为协作式:调度循环约每200ms检查标记并取消未开始 future,提交/轮询在有界请求返回后停止;requests 在流式数据块边界取消,Windows curl 由隐藏窗口 `Popen` 有界 terminate/kill。已有 `task_id` 的停止任务保留 resume,不假设服务端任务被取消或点数退回。
提示词管理: 提示词管理:
+1 -1
View File
@@ -205,7 +205,7 @@
- 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。 - 「拉取蝦皮主图」复用只读 CDP,读取 URL 后由最多2个下载 worker 后台落盘;不改标题/封面、不拖拽、不点击更新。拉取、下载期间其余界面和其他任务仍可操作。
- 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。 - 套图只有一个图片类型,不再展示详情图、终选盘或模板 CRUD。默认分类为白底图1、场景图2、卖点图2;自定义分类名称非空、无空格、最多10字且不可重名。逐图主图开启后,白底图只生成一次,其余分类按每张有效原图展开。
- 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。 - 平台、国家地区、语言和比例以四个带独立标签的同行下拉展示,选项只显示真实值;四项都写进每个 job 的完整提示词,比例还透传到 cmhub 生图请求,不是装饰字段。已有项目保存自己的完整设置;未绑定商品的新任务在重启后采用 `config.json` 的最近四项选择。生成仍走 `image_studio_generation.run_jobs()` 的 submit → poll → download 管线。
- 生成按钮按当前总数显示并在运行时切换为停止。结果区显示本轮或历史 job;成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。 - 生成按钮按当前总数显示并在运行时切换为「停止生成」;确认停止后显示「正在停止...」,重复点击不再弹确认框。每轮生成用独立运行标识隔离旧信号,本轮全部 job 终态或线程结束时都会统一恢复按钮;最终 worker 信号缺失时由数据库终态看门狗兜底,不要求用户重启。停止会取消未开始任务,已提交任务停止本地等待并保留后续继续查询语义;客户端不承诺取消服务端任务或退回点数。结果区显示本轮或历史 job;成功图可预览、复制路径、打开目录、重新生成、移入项目废纸篓并撤销,失败卡显示脱敏中文摘要与重试入口。
- AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。 - AI帮写和生图按任务独立运行。AI帮写期间若用户改过卖点,返回后必须确认才覆盖;全部用户可见错误隐藏 URL/接口路径和敏感信息。
- ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 - ⑥只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
+6 -1
View File
@@ -3,7 +3,7 @@ id: T-639
title: 商品套图生成完成状态复位与可响应停止 title: 商品套图生成完成状态复位与可响应停止
phase: 7 phase: 7
deps: [T-638] deps: [T-638]
status: TODO status: DONE
created: 2026-07-16 created: 2026-07-16
--- ---
@@ -182,3 +182,8 @@ git diff --check
- 不修改 CDP、蝦皮主图拉取、①导入采集、②AI生成、③更新蝦皮、④账号管理或⑤设置。 - 不修改 CDP、蝦皮主图拉取、①导入采集、②AI生成、③更新蝦皮、④账号管理或⑤设置。
## 执行记录 ## 执行记录
- 2026-07-16:每轮商品套图生成新增内存 `run_token`,worker 的 progress/summary 携带同一 token,GUI 改用主线程绑定槽处理信号。正常结果、`QThread.finished` 和每750ms检查的本轮 job 终态看门狗统一进入幂等 finalize;旧轮次迟到信号会被忽略,旧线程引用仍由模块级容器保留到真实结束。
- 2026-07-16:统一 finalize 按本轮明确 `job_ids` 查询 SQLite,恢复按钮和编辑控件,并只显示一次中文完成/停止/异常汇总。线程异常结束时,已有 `task_id` 的非终态 job 记为 `cancelled + resume`,未提交任务记为 `cancelled + regenerate`;生命周期诊断日志只记录 token 短值、项目ID、数量、来源和用时,不记录提示词、URL或密钥。
- 2026-07-16:`image_studio_generation.run_jobs()` 的 future wait 改为200ms短轮询,停止后及时取消未开始任务;提交/轮询请求返回后再次检查停止。下载链路新增可选 `should_stop`:requests 在流式数据块边界取消并关闭 response,curl 改为隐藏窗口 `Popen`,停止时有界 terminate/kill,重试等待可取消,停止后的本地文件不入资产库。
- 2026-07-16:补齐真实 QThread 完成、终态信号丢失兜底、线程异常收尾、旧 token、重复停止、立即停止、排队 future 取消、下载取消 resume、requests response 关闭和 curl 子进程/临时文件清理测试。相关 90 项通过;隔离工作树全量 533 项 unittest、Ruff、compileall、`git diff --check` 全部通过。当前主工作区全量测试仅有3项原有封面默认模板 `papa1` 改名导致的失败,该未提交用户改动未纳入本任务。
+122 -13
View File
@@ -6,6 +6,7 @@ import socket
import sys import sys
import threading import threading
import unittest import unittest
from concurrent.futures import CancelledError
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock from unittest import mock
@@ -38,6 +39,7 @@ class _RequestsResponse:
self.content = content self.content = content
self.headers = headers or {} self.headers = headers or {}
self.text = json.dumps(self.payload, ensure_ascii=False) self.text = json.dumps(self.payload, ensure_ascii=False)
self.closed = False
def json(self): def json(self):
return self.payload return self.payload
@@ -46,6 +48,9 @@ class _RequestsResponse:
if self.content: if self.content:
yield self.content yield self.content
def close(self):
self.closed = True
class AITests(TempDirMixin, unittest.TestCase): class AITests(TempDirMixin, unittest.TestCase):
def _write_models(self, path, text=None, image=None): def _write_models(self, path, text=None, image=None):
text = text or { text = text or {
@@ -732,7 +737,16 @@ class AITests(TempDirMixin, unittest.TestCase):
] ]
calls = [] calls = []
def fake_run(args, **kwargs): class FakeProcess:
returncode = 0
def poll(self):
return self.returncode
def communicate(self):
return b"", b""
def fake_popen(args, **kwargs):
calls.append((args, kwargs)) calls.append((args, kwargs))
self.assertIn("-K", args) self.assertIn("-K", args)
config_path = args[args.index("-K") + 1] config_path = args[args.index("-K") + 1]
@@ -755,12 +769,12 @@ class AITests(TempDirMixin, unittest.TestCase):
output_path = args[args.index("--output") + 1] output_path = args[args.index("--output") + 1]
with open(output_path, "wb") as fh: with open(output_path, "wb") as fh:
fh.write(generated_png) fh.write(generated_png)
return SimpleNamespace(returncode=0, stdout=b"", stderr=b"") return FakeProcess()
with mock.patch("app.ai.os.name", "nt"), \ with mock.patch("app.ai.os.name", "nt"), \
mock.patch("app.ai.subprocess.CREATE_NO_WINDOW", 0x08000000, create=True), \ mock.patch("app.ai.subprocess.CREATE_NO_WINDOW", 0x08000000, create=True), \
mock.patch("app.ai._find_system_curl", return_value=r"C:\Windows\System32\curl.exe"), \ mock.patch("app.ai._find_system_curl", return_value=r"C:\Windows\System32\curl.exe"), \
mock.patch("app.ai.subprocess.run", side_effect=fake_run), \ mock.patch("app.ai.subprocess.Popen", side_effect=fake_popen), \
mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns): mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns):
image_bytes = ai._download_cmhub_image( image_bytes = ai._download_cmhub_image(
url, url,
@@ -786,7 +800,7 @@ class AITests(TempDirMixin, unittest.TestCase):
def test_cmhub_image_download_skips_curl_for_private_url(self): def test_cmhub_image_download_skips_curl_for_private_url(self):
with mock.patch("app.ai._find_system_curl", return_value=r"C:\Windows\System32\curl.exe"), \ with mock.patch("app.ai._find_system_curl", return_value=r"C:\Windows\System32\curl.exe"), \
mock.patch("app.ai.subprocess.run") as run: mock.patch("app.ai.subprocess.Popen") as popen:
with self.assertRaises(ai.AIError): with self.assertRaises(ai.AIError):
ai._download_cmhub_image( ai._download_cmhub_image(
"http://127.0.0.1/a.png", "http://127.0.0.1/a.png",
@@ -794,7 +808,7 @@ class AITests(TempDirMixin, unittest.TestCase):
read_timeout=ai.CMHUB_IMAGE_READ_TIMEOUT_SECONDS, read_timeout=ai.CMHUB_IMAGE_READ_TIMEOUT_SECONDS,
download_with_curl="true", download_with_curl="true",
) )
run.assert_not_called() popen.assert_not_called()
def test_cmhub_image_download_falls_back_to_requests_when_curl_fails(self): def test_cmhub_image_download_falls_back_to_requests_when_curl_fails(self):
generated_png = self._png_bytes() generated_png = self._png_bytes()
@@ -805,11 +819,20 @@ class AITests(TempDirMixin, unittest.TestCase):
def fake_get(url, **kwargs): def fake_get(url, **kwargs):
return _RequestsResponse(content=generated_png) return _RequestsResponse(content=generated_png)
class FailedProcess:
returncode = 28
def poll(self):
return self.returncode
def communicate(self):
return b"", b"timeout"
with mock.patch("app.ai._find_system_curl", return_value=r"C:\Windows\System32\curl.exe"), \ with mock.patch("app.ai._find_system_curl", return_value=r"C:\Windows\System32\curl.exe"), \
mock.patch( mock.patch(
"app.ai.subprocess.run", "app.ai.subprocess.Popen",
return_value=SimpleNamespace(returncode=28, stdout=b"", stderr=b"timeout"), return_value=FailedProcess(),
) as run, \ ) as popen, \
mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get) as get, \ mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get) as get, \
mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns): mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns):
image_bytes = ai._download_cmhub_image( image_bytes = ai._download_cmhub_image(
@@ -820,7 +843,7 @@ class AITests(TempDirMixin, unittest.TestCase):
) )
self.assertEqual(generated_png, image_bytes) self.assertEqual(generated_png, image_bytes)
self.assertEqual(1, run.call_count) self.assertEqual(1, popen.call_count)
self.assertEqual(1, get.call_count) self.assertEqual(1, get.call_count)
def test_cmhub_image_download_auto_without_curl_uses_requests(self): def test_cmhub_image_download_auto_without_curl_uses_requests(self):
@@ -834,7 +857,7 @@ class AITests(TempDirMixin, unittest.TestCase):
with mock.patch("app.ai.os.name", "nt"), \ with mock.patch("app.ai.os.name", "nt"), \
mock.patch("app.ai._find_system_curl", return_value=""), \ mock.patch("app.ai._find_system_curl", return_value=""), \
mock.patch("app.ai.subprocess.run") as run, \ mock.patch("app.ai.subprocess.Popen") as popen, \
mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \ mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \
mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns): mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns):
image_bytes = ai._download_cmhub_image( image_bytes = ai._download_cmhub_image(
@@ -845,7 +868,7 @@ class AITests(TempDirMixin, unittest.TestCase):
) )
self.assertEqual(generated_png, image_bytes) self.assertEqual(generated_png, image_bytes)
run.assert_not_called() popen.assert_not_called()
def test_cmhub_image_download_auto_on_non_windows_uses_requests(self): def test_cmhub_image_download_auto_on_non_windows_uses_requests(self):
generated_png = self._png_bytes() generated_png = self._png_bytes()
@@ -858,7 +881,7 @@ class AITests(TempDirMixin, unittest.TestCase):
with mock.patch("app.ai.os.name", "posix"), \ with mock.patch("app.ai.os.name", "posix"), \
mock.patch("app.ai._find_system_curl", return_value="/usr/bin/curl"), \ mock.patch("app.ai._find_system_curl", return_value="/usr/bin/curl"), \
mock.patch("app.ai.subprocess.run") as run, \ mock.patch("app.ai.subprocess.Popen") as popen, \
mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \ mock.patch.object(ai._cmhub_session(), "get", side_effect=fake_get), \
mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns): mock.patch("app.ai.socket.getaddrinfo", return_value=public_dns):
image_bytes = ai._download_cmhub_image( image_bytes = ai._download_cmhub_image(
@@ -869,7 +892,93 @@ class AITests(TempDirMixin, unittest.TestCase):
) )
self.assertEqual(generated_png, image_bytes) self.assertEqual(generated_png, image_bytes)
run.assert_not_called() popen.assert_not_called()
def test_cmhub_requests_download_cancel_closes_response(self):
stopped = {"value": False}
class StreamingResponse(_RequestsResponse):
def iter_content(self, chunk_size=65536):
yield b"first"
stopped["value"] = True
yield b"second"
response = StreamingResponse()
with mock.patch.object(
ai._cmhub_session(),
"get",
return_value=response,
):
with self.assertRaises(CancelledError):
ai._download_cmhub_image_with_requests(
"https://cdn.example.com/generated.png",
connect_timeout=3,
read_timeout=30,
max_bytes=ai.CMHUB_IMAGE_MAX_BYTES,
should_stop=lambda: stopped["value"],
)
self.assertTrue(response.closed)
def test_cmhub_curl_download_cancel_terminates_process_and_cleans_temp_files(self):
stopped = {"value": False}
captured_paths = []
class RunningProcess:
def __init__(self):
self.returncode = None
self.terminated = False
self.killed = False
def poll(self):
return self.returncode
def terminate(self):
self.terminated = True
self.returncode = -15
def kill(self):
self.killed = True
self.returncode = -9
def wait(self, timeout=None):
return self.returncode
def communicate(self):
return b"", b""
process = RunningProcess()
def fake_popen(args, **kwargs):
captured_paths.extend(
[
args[args.index("-K") + 1],
args[args.index("--output") + 1],
]
)
stopped["value"] = True
return process
with mock.patch(
"app.ai._find_system_curl",
return_value=r"C:\Windows\System32\curl.exe",
), mock.patch(
"app.ai.subprocess.Popen",
side_effect=fake_popen,
):
with self.assertRaises(CancelledError):
ai._download_cmhub_image_with_curl(
"https://cdn.example.com/generated.png",
connect_timeout=3,
read_timeout=30,
max_bytes=ai.CMHUB_IMAGE_MAX_BYTES,
should_stop=lambda: stopped["value"],
)
self.assertTrue(process.terminated)
self.assertFalse(process.killed)
self.assertTrue(captured_paths)
self.assertTrue(all(not os.path.exists(path) for path in captured_paths))
def test_cmhub_upstream_error_retries_and_keeps_metadata(self): def test_cmhub_upstream_error_retries_and_keeps_metadata(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
+143 -1
View File
@@ -1,7 +1,10 @@
import io import io
import os import os
import sys import sys
import threading
import time
import unittest import unittest
from concurrent.futures import CancelledError
from unittest import mock from unittest import mock
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
@@ -193,7 +196,14 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
stopped = {"value": False} stopped = {"value": False}
saved_paths = [] saved_paths = []
def fake_download(request_result, out_path, config, on_event, job_id): def fake_download(
request_result,
out_path,
config,
on_event,
job_id,
should_stop=None,
):
os.makedirs(os.path.dirname(out_path), exist_ok=True) os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "wb") as fh: with open(out_path, "wb") as fh:
fh.write(self._png_bytes()) fh.write(self._png_bytes())
@@ -235,6 +245,138 @@ class ImageStudioGenerationTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
def test_download_cancel_maps_to_cancelled_and_keeps_resume_action(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
stopped = {"value": False}
def fake_download(*args, **kwargs):
self.assertIsNotNone(kwargs.get("should_stop"))
stopped["value"] = True
raise CancelledError()
with mock.patch(
"app.image_studio_generation._runtime",
return_value=self._runtime(),
), mock.patch(
"app.image_studio_generation.ai._cmhub_call_with_retry",
return_value={"task_id": "cmhub-cancel-download", "status": "queued"},
), mock.patch(
"app.image_studio_generation.ai._cmhub_call_once",
return_value={
"task_id": "cmhub-cancel-download",
"status": "succeeded",
"result": {
"image_url": "https://cdn.example.com/cancel-download.png"
},
},
), mock.patch(
"app.image_studio_generation.ai._download_cmhub_image_with_retry",
side_effect=fake_download,
):
summary = image_studio_generation.generate_image_jobs(
project.id,
source.id,
"下载停止测试",
1,
config=cfg,
path=cfg["db_path"],
should_stop=lambda: stopped["value"],
)
self.assertEqual(1, summary["cancelled"])
job = summary["jobs"][0]["job"]
stored = image_studio.get_job(job.id, path=cfg["db_path"])
self.assertEqual("cancelled", stored.status)
self.assertEqual(image_studio.JOB_RECOVERY_RESUME, stored.recovery_action)
self.assertEqual(
[],
image_studio.list_assets(
project.id,
kind="generated_main",
path=cfg["db_path"],
),
)
self.assert_removed(temp_dir)
def test_run_jobs_cancels_queued_future_before_running_slot_is_released(self):
with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir)
cfg["ai"]["image_concurrency"] = 1
jobs = [
image_studio.create_job(
project.id,
source_asset_id=source.id,
job_type="main",
prompt="排队停止测试",
path=cfg["db_path"],
)
for _ in range(2)
]
started_ids = []
first_started = threading.Event()
release_first = threading.Event()
stop_requested = threading.Event()
result_holder = {}
def fake_run_one(*args):
job_id = int(args[0])
started_ids.append(job_id)
if len(started_ids) == 1:
first_started.set()
release_first.wait(timeout=3)
updated = image_studio.update_job_status(
job_id,
"cancelled",
error="用户停止",
recovery_action=image_studio.JOB_RECOVERY_REGENERATE,
path=cfg["db_path"],
)
return {
"job": updated,
"status": "cancelled",
"error": "用户停止",
}
def run():
result_holder["summary"] = image_studio_generation.run_jobs(
jobs,
config=cfg,
path=cfg["db_path"],
should_stop=stop_requested.is_set,
)
with mock.patch(
"app.image_studio_generation._runtime",
return_value=self._runtime(),
), mock.patch(
"app.image_studio_generation._run_one_job_with_global_slot",
side_effect=fake_run_one,
):
thread = threading.Thread(target=run)
thread.start()
self.assertTrue(first_started.wait(timeout=2))
stop_requested.set()
time.sleep(0.35)
release_first.set()
thread.join(timeout=3)
self.assertFalse(thread.is_alive())
self.assertEqual([jobs[0].id], started_ids)
summary = result_holder["summary"]
self.assertEqual(2, summary["cancelled"])
self.assertEqual(2, len(summary["jobs"]))
self.assertEqual(
["cancelled", "cancelled"],
[
image_studio.get_job(job.id, path=cfg["db_path"]).status
for job in jobs
],
)
self.assert_removed(temp_dir)
def test_resume_existing_job_polls_without_new_submit(self): def test_resume_existing_job_polls_without_new_submit(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg, project, source = self._project_source(temp_dir) cfg, project, source = self._project_source(temp_dir)
+246
View File
@@ -1,5 +1,6 @@
import os import os
import sys import sys
import time
import unittest import unittest
from unittest import mock from unittest import mock
@@ -823,6 +824,251 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase):
self.app.processEvents() self.app.processEvents()
self.assertGreater(edit.height(), wide_height) self.assertGreater(edit.height(), wide_height)
def test_generation_terminal_watchdog_finalizes_once_and_restores_button(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 1)
jobs = [
image_studio.create_job(
project.id,
source_asset_id=assets[0].id,
job_type="白底图",
prompt="终态看门狗测试",
path=config["db_path"],
)
for _ in range(2)
]
for job in jobs:
image_studio.update_job_status(
job.id,
"succeeded",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "watchdog-run"
state.current_job_ids = [job.id for job in jobs]
state.total = len(jobs)
state.started_at = time.monotonic()
tab._generation_run_states["watchdog-run"] = state.key
tab._load_state(state)
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
tab._check_generation_watchdogs()
self.assertIsNotNone(state.worker)
tab._check_generation_watchdogs()
self.assertIsNone(state.worker)
self.assertIsNone(state.thread)
self.assertEqual("", state.generation_run_token)
self.assertTrue(tab.generate_button.text().startswith("生成套图"))
self.assertEqual(1, len(messages))
self.assertEqual("商品套图生成完成", messages[0][0])
self.assertFalse(
tab._finalize_generation(
state,
"watchdog-run",
{"total": 2, "success": 2},
source="worker",
)
)
self.assertEqual(1, len(messages))
self.assert_removed(temp_dir)
def test_generation_real_qthread_completion_restores_gui_state(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 1)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.prompt = "真实线程完成测试"
tab._load_state(state)
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
def fake_run_jobs(jobs, **kwargs):
job_list = list(jobs)
for job in job_list:
image_studio.update_job_status(
job.id,
"succeeded",
path=config["db_path"],
)
return {
"total": len(job_list),
"success": len(job_list),
"failed": 0,
"cancelled": 0,
"jobs": [],
}
with mock.patch(
"app.gui.workers.image_studio_generation.run_jobs",
side_effect=fake_run_jobs,
):
self.assertTrue(
tab.start_generation(
state,
specs=[
{
"source_asset_id": assets[0].id,
"job_type": "白底图",
"prompt": "真实线程完成测试",
}
],
)
)
deadline = time.monotonic() + 3
while state.worker is not None and time.monotonic() < deadline:
QTest.qWait(20)
self.app.processEvents()
self.assertIsNone(state.worker)
self.assertIsNone(state.thread)
self.assertTrue(tab.generate_button.text().startswith("生成套图"))
self.assertEqual(1, len(messages))
self.assertEqual("商品套图生成完成", messages[0][0])
self.assert_removed(temp_dir)
def test_generation_thread_finished_reconciles_nonterminal_job(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
project, assets = self._create_project_with_assets(temp_dir, config, 1)
job = image_studio.create_job(
project.id,
source_asset_id=assets[0].id,
job_type="场景图",
prompt="线程结束兜底测试",
path=config["db_path"],
)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.account_alias = "alias-a"
state.item_id = project.item_id
state.project_id = project.id
state.project_binding_state = project.binding_state
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "thread-fallback"
state.current_job_ids = [job.id]
state.total = 1
state.started_at = time.monotonic()
tab._generation_run_states["thread-fallback"] = state.key
tab._load_state(state)
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
tab._handle_generation_thread_finished("thread-fallback")
stored = image_studio.get_job(job.id, path=config["db_path"])
self.assertEqual("cancelled", stored.status)
self.assertEqual(
image_studio.JOB_RECOVERY_REGENERATE,
stored.recovery_action,
)
self.assertIsNone(state.worker)
self.assertEqual("商品套图生成未完整结束", messages[0][0])
self.assertIn("稍后继续查询", messages[0][1])
self.assert_removed(temp_dir)
def test_generation_old_run_token_and_repeated_stop_are_ignored(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
statuses = []
tab = ProductSuiteTab(
config=config,
db_path=config["db_path"],
status_callback=lambda message, level=None: statuses.append(
(message, level)
),
)
self.addCleanup(tab.close)
state = tab._displayed_state
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "current-run"
state.generation_stop_requested = True
state.total = 2
tab._generation_run_states["current-run"] = state.key
original_worker = state.worker
self.assertFalse(
tab._finalize_generation(
state,
"old-run",
{"total": 2, "success": 2},
source="worker",
)
)
self.assertIs(original_worker, state.worker)
confirm = mock.Mock(return_value=True)
with mock.patch.object(tab, "_confirm", confirm):
tab.toggle_generation()
confirm.assert_not_called()
original_worker.cancel.assert_not_called()
self.assertEqual(("正在停止当前套图任务", "warning"), statuses[-1])
self.assert_removed(temp_dir)
def test_generation_immediate_stop_before_job_creation_finishes_cleanly(self):
with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir)
accounts.create_account("主店", "alias-a", debug_port=9222, config=config)
tab = ProductSuiteTab(config=config, db_path=config["db_path"])
self.addCleanup(tab.close)
state = tab._displayed_state
state.worker = mock.Mock()
state.thread = mock.Mock()
state.generation_run_token = "immediate-stop"
state.generation_stop_requested = True
state.total = 3
state.started_at = time.monotonic()
tab._generation_run_states["immediate-stop"] = state.key
messages = []
tab._message = lambda title, message, **kwargs: messages.append(
(title, message)
)
tab._on_generation_finished_signal(
{
"run_token": "immediate-stop",
"cancelled": True,
}
)
self.assertIsNone(state.worker)
self.assertEqual(3, state.done)
self.assertEqual("商品套图生成已停止", messages[0][0])
self.assertIn("停止3张", messages[0][1])
self.assert_removed(temp_dir)
def test_project_settings_and_result_history_use_existing_backend(self): def test_project_settings_and_result_history_use_existing_backend(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
config = self._config(temp_dir) config = self._config(temp_dir)
+9
View File
@@ -48,10 +48,13 @@ class ProductSuiteWorkerTests(TempDirMixin, unittest.TestCase):
"prompt": "通勤场景图", "prompt": "通勤场景图",
}, },
], ],
run_token="run-123",
aspect_ratio="3:4", aspect_ratio="3:4",
db_path=db_path, db_path=db_path,
config={"db_path": db_path}, config={"db_path": db_path},
) )
progress_events = []
worker.progress.connect(progress_events.append)
with mock.patch( with mock.patch(
"app.gui.workers.image_studio_generation.run_jobs", "app.gui.workers.image_studio_generation.run_jobs",
@@ -71,6 +74,12 @@ class ProductSuiteWorkerTests(TempDirMixin, unittest.TestCase):
self.assertEqual([job.id for job in reversed(jobs)], result["job_ids"]) self.assertEqual([job.id for job in reversed(jobs)], result["job_ids"])
self.assertEqual("3:4", run_jobs.call_args.kwargs["aspect_ratio"]) self.assertEqual("3:4", run_jobs.call_args.kwargs["aspect_ratio"])
self.assertEqual(db_path, run_jobs.call_args.kwargs["path"]) self.assertEqual(db_path, run_jobs.call_args.kwargs["path"])
self.assertTrue(progress_events)
self.assertTrue(
all(event.get("run_token") == "run-123" for event in progress_events)
)
self.assertEqual("run-123", result["run_token"])
self.assertEqual(0, result["cancelled_count"])
self.assert_removed(temp_dir) self.assert_removed(temp_dir)