File size: 3,966 Bytes
8a28a8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
"""One bounded scheduler for UI, API, and MCP generation requests."""

from __future__ import annotations

import threading
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import contextmanager
from functools import wraps
from typing import Callable, Iterator, TypeVar

from .runtime_config import CONFIG

T = TypeVar("T")


class QueueFullError(RuntimeError):
    pass


class TaskCancelledError(RuntimeError):
    pass


_pending_slots = threading.BoundedSemaphore(CONFIG.mcp_max_pending)
_executor = ThreadPoolExecutor(
    max_workers=CONFIG.gpu_concurrency,
    thread_name_prefix="imagegen-worker",
)


class _FairGenerationGate:
    """FIFO gate for the single process-global ComfyUI runtime."""

    def __init__(self) -> None:
        self._condition = threading.Condition()
        self._next_ticket = 0
        self._serving_ticket = 0
        self._active = False
        self._cancelled_tickets: set[int] = set()

    def _skip_cancelled_locked(self) -> None:
        while not self._active and self._serving_ticket in self._cancelled_tickets:
            self._cancelled_tickets.remove(self._serving_ticket)
            self._serving_ticket += 1

    def acquire(self, cancel_event: threading.Event | None = None) -> None:
        with self._condition:
            ticket = self._next_ticket
            self._next_ticket += 1
            while True:
                if cancel_event is not None and cancel_event.is_set():
                    self._cancelled_tickets.add(ticket)
                    self._skip_cancelled_locked()
                    self._condition.notify_all()
                    raise TaskCancelledError("任务已在等待 GPU 时取消。")

                self._skip_cancelled_locked()
                if ticket == self._serving_ticket and not self._active:
                    self._active = True
                    return

                # A cancellation event does not notify the gate, so poll it at
                # a low frequency. Jobs without cancellation remain fully
                # condition-driven.
                self._condition.wait(timeout=0.2 if cancel_event is not None else None)

    def release(self) -> None:
        with self._condition:
            self._active = False
            self._serving_ticket += 1
            self._skip_cancelled_locked()
            self._condition.notify_all()


_generation_gate = _FairGenerationGate()


@contextmanager
def generation_slot(cancel_event: threading.Event | None = None) -> Iterator[None]:
    """Serialize access to the shared in-process ComfyUI runtime by default."""

    _generation_gate.acquire(cancel_event)
    try:
        yield
    finally:
        _generation_gate.release()


def generation_guard(function: Callable[..., T]) -> Callable[..., T]:
    @wraps(function)
    def wrapped(*args, **kwargs):
        ui_inputs = kwargs.get("ui_inputs")
        if ui_inputs is None:
            ui_inputs = next(
                (value for value in args if isinstance(value, dict)), None
            )
        cancel_event = (
            ui_inputs.get("_cancel_event") if isinstance(ui_inputs, dict) else None
        )
        with generation_slot(cancel_event):
            if cancel_event is not None and cancel_event.is_set():
                raise TaskCancelledError("任务已在进入 GPU 前取消。")
            return function(*args, **kwargs)

    return wrapped


def submit_background(function: Callable[..., T], *args, **kwargs) -> Future[T]:
    """Submit an MCP job without creating an unbounded daemon thread."""

    if not _pending_slots.acquire(blocking=False):
        raise QueueFullError(
            f"任务队列已满(最多 {CONFIG.mcp_max_pending} 个待处理任务),请稍后再试。"
        )

    def run_and_release() -> T:
        try:
            return function(*args, **kwargs)
        finally:
            _pending_slots.release()

    return _executor.submit(run_and_release)