BlueSkyXN commited on
Commit
99adaa0
·
verified ·
1 Parent(s): d3c6930

Fix ZeroGPU request serialization and preserve error tracebacks

Browse files
core/execution_plan.py CHANGED
@@ -7,6 +7,7 @@ trying to keep several checkpoints resident on one ZeroGPU allocation.
7
 
8
  from __future__ import annotations
9
 
 
10
  import random
11
  from collections.abc import Callable, Iterable, Sequence
12
  from dataclasses import dataclass
@@ -26,6 +27,8 @@ from core.settings import (
26
  MODEL_TYPE_MAP,
27
  )
28
 
 
 
29
  MODE_SINGLE = "single"
30
  MODE_MODEL_PK = "model_pk"
31
  MODE_MULTI_INDEPENDENT = "multi_independent"
@@ -441,6 +444,12 @@ def execute_generation_plan(
441
  summary.append("- ⏹️ 已取消:后续组合未执行,已保留成功结果。")
442
  break
443
  raise
 
 
 
 
 
 
444
  summary.append(f"- ❌ {item.caption}:{exc}")
445
 
446
  if not gallery:
 
7
 
8
  from __future__ import annotations
9
 
10
+ import logging
11
  import random
12
  from collections.abc import Callable, Iterable, Sequence
13
  from dataclasses import dataclass
 
27
  MODEL_TYPE_MAP,
28
  )
29
 
30
+ logger = logging.getLogger(__name__)
31
+
32
  MODE_SINGLE = "single"
33
  MODE_MODEL_PK = "model_pk"
34
  MODE_MULTI_INDEPENDENT = "multi_independent"
 
444
  summary.append("- ⏹️ 已取消:后续组合未执行,已保留成功结果。")
445
  break
446
  raise
447
+ logger.exception(
448
+ "生成任务失败(%s/%s,模型=%s)",
449
+ index + 1,
450
+ total,
451
+ item.inputs.get("model_display_name", "未知"),
452
+ )
453
  summary.append(f"- ❌ {item.caption}:{exc}")
454
 
455
  if not gallery:
core/pipelines/sd_image_pipeline.py CHANGED
@@ -61,12 +61,10 @@ class SdImagePipeline(BasePipeline):
61
  else:
62
  return [model_display_name]
63
 
64
- def _gpu_logic(self, ui_inputs: Dict, loras_string: str, workflow: Dict[str, Any], assembler: WorkflowAssembler, progress=gr.Progress(track_tqdm=True)):
65
  model_display_name = ui_inputs['model_display_name']
66
  succeeded = False
67
  try:
68
- progress(0.4, desc="正在执行工作流…")
69
-
70
  initial_objects = {}
71
  decoded_images_tensor = WorkflowExecutor.execute_workflow(
72
  workflow, initial_objects=initial_objects
@@ -306,16 +304,20 @@ class SdImagePipeline(BasePipeline):
306
  gpu_duration = estimate_gpu_duration(ui_inputs)
307
  progress(1.0, desc=f"模型已就绪,正在申请 GPU(预计上限 {gpu_duration} 秒)…")
308
 
 
 
 
 
 
 
309
  results = self._execute_gpu_logic(
310
  self._gpu_logic,
311
  duration=gpu_duration,
312
  default_duration=60,
313
  task_name=f"ImageGen ({task_type})",
314
- ui_inputs=ui_inputs,
315
  loras_string=loras_string,
316
  workflow=workflow,
317
- assembler=assembler,
318
- progress=progress
319
  )
320
 
321
  import json
 
61
  else:
62
  return [model_display_name]
63
 
64
+ def _gpu_logic(self, ui_inputs: Dict, loras_string: str, workflow: Dict[str, Any]):
65
  model_display_name = ui_inputs['model_display_name']
66
  succeeded = False
67
  try:
 
 
68
  initial_objects = {}
69
  decoded_images_tensor = WorkflowExecutor.execute_workflow(
70
  workflow, initial_objects=initial_objects
 
304
  gpu_duration = estimate_gpu_duration(ui_inputs)
305
  progress(1.0, desc=f"模型已就绪,正在申请 GPU(预计上限 {gpu_duration} 秒)…")
306
 
307
+ # 取消信号和持有它的 assembler 留在 CPU 侧,不能跨 ZeroGPU 序列化边界。
308
+ gpu_inputs = {
309
+ key: value
310
+ for key, value in ui_inputs.items()
311
+ if key != "_cancel_event"
312
+ }
313
  results = self._execute_gpu_logic(
314
  self._gpu_logic,
315
  duration=gpu_duration,
316
  default_duration=60,
317
  task_name=f"ImageGen ({task_type})",
318
+ ui_inputs=gpu_inputs,
319
  loras_string=loras_string,
320
  workflow=workflow,
 
 
321
  )
322
 
323
  import json
tests/test_execution_plan.py CHANGED
@@ -119,9 +119,38 @@ class ExecutionPlanTests(unittest.TestCase):
119
  raise RuntimeError("显存不足")
120
  return ["one.png"]
121
 
122
- gallery, summary = execute_generation_plan(plan, generate)
 
123
  self.assertEqual(gallery, [("one.png", "模型一")])
124
  self.assertIn("模型二:显存不足", summary)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  def test_executor_stops_before_next_case_after_cancel(self):
127
  cancellation = threading.Event()
 
119
  raise RuntimeError("显存不足")
120
  return ["one.png"]
121
 
122
+ with self.assertLogs("core.execution_plan", level="ERROR") as logs:
123
+ gallery, summary = execute_generation_plan(plan, generate)
124
  self.assertEqual(gallery, [("one.png", "模型一")])
125
  self.assertIn("模型二:显存不足", summary)
126
+ self.assertIn("RuntimeError: 显存不足", logs.output[0])
127
+
128
+ def test_failed_job_logs_original_traceback_without_input_payload(self):
129
+ plan = [
130
+ PlannedGeneration(
131
+ {
132
+ "model_display_name": "example-model",
133
+ "positive_prompt": "do-not-log-this-input",
134
+ },
135
+ "测试任务",
136
+ )
137
+ ]
138
+
139
+ def generate(_inputs, _progress):
140
+ raise TypeError("cannot pickle '_thread.lock' object")
141
+
142
+ with (
143
+ self.assertLogs("core.execution_plan", level="ERROR") as logs,
144
+ self.assertRaisesRegex(ExecutionPlanError, "cannot pickle"),
145
+ ):
146
+ execute_generation_plan(plan, generate)
147
+
148
+ self.assertEqual(len(logs.records), 1)
149
+ self.assertIs(logs.records[0].exc_info[0], TypeError)
150
+ self.assertIn("Traceback (most recent call last)", logs.output[0])
151
+ self.assertIn("in generate", logs.output[0])
152
+ self.assertIn("模型=example-model", logs.output[0])
153
+ self.assertNotIn("do-not-log-this-input", logs.output[0])
154
 
155
  def test_executor_stops_before_next_case_after_cancel(self):
156
  cancellation = threading.Event()
tests/test_gpu_boundary.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import pickle
5
+ import sys
6
+ import tempfile
7
+ import threading
8
+ import types
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ from core.task_scheduler import TaskCancelledError
14
+
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+
18
+
19
+ class _LockedProgress:
20
+ def __init__(self, *args, **kwargs):
21
+ self.lock = threading.Lock()
22
+ self.updates = []
23
+
24
+ def __call__(self, value, desc=None):
25
+ self.updates.append((value, desc))
26
+
27
+
28
+ class _Assembler:
29
+ def __init__(self, after_assembly=None):
30
+ self.after_assembly = after_assembly
31
+
32
+ def assemble(self, values):
33
+ self.ui_values = values
34
+ if self.after_assembly:
35
+ self.after_assembly()
36
+ return {"1": {"class_type": "TestNode", "inputs": {"seed": values["seed"]}}}
37
+
38
+
39
+ def _module(name, **attributes):
40
+ module = types.ModuleType(name)
41
+ module.__dict__.update(attributes)
42
+ return module
43
+
44
+
45
+ class GpuBoundaryTests(unittest.TestCase):
46
+ """执行真实 pipeline 调用链,只替换下载、推理依赖和 GPU 传输。"""
47
+
48
+ def setUp(self):
49
+ output_dir = self.enterContext(tempfile.TemporaryDirectory())
50
+ self.payloads = []
51
+ self.assemblers = []
52
+ self.after_assembly = None
53
+ self.download = mock.Mock()
54
+ self.release_models = mock.Mock()
55
+ self.execute_workflow = mock.Mock(
56
+ return_value=types.SimpleNamespace(shape=(0,))
57
+ )
58
+
59
+ def gpu_decorator(**_options):
60
+ def decorate(function):
61
+ def run(*args, **kwargs):
62
+ # 真正序列化入参,不能像 UI 烟测那样直接跳过生成调用。
63
+ args, kwargs = pickle.loads(pickle.dumps((args, kwargs)))
64
+ self.payloads.append(kwargs)
65
+ return function(*args, **kwargs)
66
+
67
+ return run
68
+
69
+ return decorate
70
+
71
+ def create_assembler(*_args, **_kwargs):
72
+ assembler = _Assembler(self.after_assembly)
73
+ self.assemblers.append(assembler)
74
+ return assembler
75
+
76
+ prepared_keys = (
77
+ "temp_files_to_clean", "active_loras_for_gpu", "active_loras_for_meta",
78
+ "active_controlnets", "active_anima_controlnets", "active_diffsynth_controlnets",
79
+ "active_ipadapters", "active_flux1_ipadapters", "active_sd3_ipadapters",
80
+ "active_styles", "active_reference_latents", "active_hidream_o1_reference",
81
+ "active_conditioning",
82
+ )
83
+ base_name = "core.pipelines.base_pipeline"
84
+ pipeline_name = "core.pipelines.sd_image_pipeline"
85
+ stubs = {
86
+ "gradio": _module("gradio", Error=RuntimeError, Progress=_LockedProgress),
87
+ "spaces": _module("spaces", GPU=gpu_decorator),
88
+ "torch": _module("torch"),
89
+ "imageio": _module("imageio"),
90
+ "numpy": _module("numpy"),
91
+ "core.model_manager": _module(
92
+ "core.model_manager",
93
+ model_manager=types.SimpleNamespace(ensure_models_downloaded=self.download),
94
+ release_loaded_models=self.release_models,
95
+ ),
96
+ "core.workflow_assembler": _module(
97
+ "core.workflow_assembler", WorkflowAssembler=create_assembler
98
+ ),
99
+ "imagegen_utils.app_utils": _module(
100
+ "imagegen_utils.app_utils", sanitize_prompt=lambda value: value
101
+ ),
102
+ "core.pipelines.pipeline_input_processor": _module(
103
+ "core.pipelines.pipeline_input_processor",
104
+ process_pipeline_inputs=lambda *_: {key: [] for key in prepared_keys},
105
+ ),
106
+ "core.pipelines.workflow_executor": _module(
107
+ "core.pipelines.workflow_executor",
108
+ WorkflowExecutor=types.SimpleNamespace(execute_workflow=self.execute_workflow),
109
+ ),
110
+ }
111
+ specs = []
112
+ for name in (base_name, pipeline_name):
113
+ spec = importlib.util.spec_from_file_location(
114
+ name, ROOT / "core" / "pipelines" / f"{name.rsplit('.', 1)[1]}.py"
115
+ )
116
+ stubs[name] = importlib.util.module_from_spec(spec)
117
+ specs.append(spec)
118
+
119
+ self.enterContext(mock.patch.dict(sys.modules, stubs))
120
+ for spec in specs:
121
+ spec.loader.exec_module(stubs[spec.name])
122
+ self.module = stubs[pipeline_name]
123
+ self.module.OUTPUT_DIR = output_dir
124
+ self.pipeline = self.module.SdImagePipeline()
125
+
126
+ def inputs(self, **overrides):
127
+ values = {
128
+ "task_type": "txt2img",
129
+ "model_display_name": "circlestone-labs/Anima-Turbo-v1.0",
130
+ "positive_prompt": "测试图片",
131
+ "negative_prompt": "",
132
+ "seed": 42,
133
+ "batch_size": 1,
134
+ "num_inference_steps": 4,
135
+ "guidance_scale": 1.0,
136
+ "sampler": "euler",
137
+ "scheduler": "simple",
138
+ "width": 64,
139
+ "height": 64,
140
+ "denoise": 1.0,
141
+ }
142
+ values.update(overrides)
143
+ return values
144
+
145
+ def test_repeated_ui_calls_serialize_and_preserve_model_release(self):
146
+ for release in (True, False):
147
+ with self.subTest(release=release):
148
+ cancellation = threading.Event()
149
+ values = self.inputs(
150
+ _cancel_event=cancellation, _release_models_after_run=release
151
+ )
152
+ progress = _LockedProgress()
153
+ self.assertEqual(self.pipeline.run(values, progress), [])
154
+
155
+ payload = self.payloads[-1]
156
+ self.assertEqual(set(payload), {"ui_inputs", "loras_string", "workflow"})
157
+ self.assertNotIn("_cancel_event", payload["ui_inputs"])
158
+ self.assertEqual(payload["ui_inputs"]["_release_models_after_run"], release)
159
+ self.assertIs(values["_cancel_event"], cancellation)
160
+ self.assertIs(self.assemblers[-1].ui_values["_cancel_event"], cancellation)
161
+ self.assertTrue(progress.updates)
162
+
163
+ self.assertEqual(len(self.payloads), 2)
164
+ self.release_models.assert_called_once_with()
165
+
166
+ def test_request_without_cancel_event_serializes(self):
167
+ self.pipeline.run(self.inputs(), _LockedProgress())
168
+ self.assertNotIn("_cancel_event", self.payloads[0]["ui_inputs"])
169
+ self.release_models.assert_not_called()
170
+
171
+ def test_cancellation_after_download_stays_on_cpu(self):
172
+ cancellation = threading.Event()
173
+ self.download.side_effect = lambda *args, **kwargs: cancellation.set()
174
+ with self.assertRaises(TaskCancelledError):
175
+ self.pipeline.run(self.inputs(_cancel_event=cancellation), _LockedProgress())
176
+ self.assertEqual(self.payloads, [])
177
+ self.execute_workflow.assert_not_called()
178
+
179
+ def test_cancellation_after_assembly_stays_on_cpu(self):
180
+ cancellation = threading.Event()
181
+ self.after_assembly = cancellation.set
182
+ with self.assertRaises(TaskCancelledError):
183
+ self.pipeline.run(self.inputs(_cancel_event=cancellation), _LockedProgress())
184
+ self.assertEqual(self.payloads, [])
185
+ self.execute_workflow.assert_not_called()
186
+
187
+ def test_gpu_failure_still_releases_model_state(self):
188
+ self.execute_workflow.side_effect = RuntimeError("sampling failed")
189
+ with self.assertRaisesRegex(RuntimeError, "sampling failed"):
190
+ self.pipeline.run(
191
+ self.inputs(_cancel_event=threading.Event()), _LockedProgress()
192
+ )
193
+ self.release_models.assert_called_once_with()
194
+
195
+
196
+ if __name__ == "__main__":
197
+ unittest.main()