update addons (for 5.2)
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
# SPDX-FileCopyrightText: 2026 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
"""
|
||||
Deferred response handling for Blender background jobs.
|
||||
|
||||
When tool-code starts a background job (e.g. rendering with
|
||||
``INVOKE_DEFAULT``), the response cannot be sent immediately. This
|
||||
module holds the client connection open and polls a checker callable
|
||||
until the operation completes, then sends the result.
|
||||
|
||||
The checker (``check_fn``) is called on the server's standard timer
|
||||
which starts at an active interval but backs off when idle.
|
||||
|
||||
Checkers should be lightweight (e.g. check a flag or file existence)
|
||||
so they don't block the UI, yet return promptly so the user is not left waiting after the job finishes.
|
||||
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"add",
|
||||
"close_all",
|
||||
"has_pending",
|
||||
"poll",
|
||||
)
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
|
||||
from .mcp_to_blender_server import _encode_response
|
||||
|
||||
# Total wall-time in seconds allowed for a background task (e.g. rendering) to complete.
|
||||
# When exceeded, an error response is sent and the connection is closed.
|
||||
# The background task itself continues to run in Blender.
|
||||
# One hour is long for what should typically be an interactive experience,
|
||||
# but renders can take a long time and it's not desirable for them to simply give up.
|
||||
_DEFERRED_TIMEOUT = (60.0 * 60.0)
|
||||
|
||||
|
||||
class _DeferredClient:
|
||||
"""
|
||||
A client connection waiting for a background job to complete.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"conn",
|
||||
"check_fn",
|
||||
"strict_json",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"deadline",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: socket.socket,
|
||||
check_fn: Callable[[], dict[str, object] | None],
|
||||
strict_json: bool,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
) -> None:
|
||||
self.conn: socket.socket = conn
|
||||
self.check_fn: Callable[[], dict[str, object] | None] = check_fn
|
||||
self.strict_json: bool = strict_json
|
||||
self.stdout: str = stdout
|
||||
self.stderr: str = stderr
|
||||
self.deadline: float = time.monotonic() + _DEFERRED_TIMEOUT
|
||||
|
||||
|
||||
# Connections waiting for a background job to finish, polled each timer tick.
|
||||
_deferred_clients: list[_DeferredClient] = []
|
||||
|
||||
|
||||
def _send_and_close(dc: _DeferredClient, response: dict[str, object]) -> None:
|
||||
try:
|
||||
dc.conn.sendall(_encode_response(response))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
dc.conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
_deferred_clients.remove(dc)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def _is_disconnected(conn: socket.socket) -> bool:
|
||||
"""
|
||||
Return ``True`` if the remote end has closed the connection.
|
||||
"""
|
||||
try:
|
||||
data = conn.recv(1, socket.MSG_PEEK)
|
||||
# Empty data means the peer closed the connection.
|
||||
return len(data) == 0
|
||||
except BlockingIOError:
|
||||
# No data available - connection is still alive.
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def add(
|
||||
conn: socket.socket,
|
||||
check_fn: Callable[[], dict[str, object] | None],
|
||||
strict_json: bool,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
) -> None:
|
||||
"""
|
||||
Register a deferred client to be polled for completion.
|
||||
"""
|
||||
_deferred_clients.append(_DeferredClient(conn, check_fn, strict_json, stdout, stderr))
|
||||
|
||||
|
||||
def poll() -> bool:
|
||||
"""
|
||||
Check all deferred clients for completion.
|
||||
|
||||
Return ``True`` if at least one client was resolved or removed.
|
||||
"""
|
||||
did_work = False
|
||||
for dc in _deferred_clients[:]:
|
||||
# Check for client disconnection.
|
||||
if _is_disconnected(dc.conn):
|
||||
try:
|
||||
dc.conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
_deferred_clients.remove(dc)
|
||||
except ValueError:
|
||||
pass
|
||||
did_work = True
|
||||
continue
|
||||
|
||||
# Check for timeout.
|
||||
if time.monotonic() > dc.deadline:
|
||||
_send_and_close(dc, {
|
||||
"status": "error",
|
||||
"message": "Deferred operation timed out after {:.0f} seconds".format(_DEFERRED_TIMEOUT),
|
||||
})
|
||||
did_work = True
|
||||
continue
|
||||
|
||||
# Call the checker.
|
||||
try:
|
||||
result = dc.check_fn()
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_send_and_close(dc, {
|
||||
"status": "error",
|
||||
"message": traceback.format_exc(),
|
||||
})
|
||||
did_work = True
|
||||
continue
|
||||
|
||||
if result is None:
|
||||
# Still pending.
|
||||
continue
|
||||
|
||||
if not isinstance(result, dict):
|
||||
_send_and_close(dc, {
|
||||
"status": "error",
|
||||
"message": "check_is_finished must return None or dict, not {:s}".format(
|
||||
type(result).__name__,
|
||||
),
|
||||
})
|
||||
did_work = True
|
||||
continue
|
||||
|
||||
# Validate JSON serializability when strict_json is set.
|
||||
if dc.strict_json:
|
||||
try:
|
||||
json.dumps(result)
|
||||
except (TypeError, ValueError) as ex:
|
||||
_send_and_close(dc, {
|
||||
"status": "error",
|
||||
"message": "Deferred result is not JSON-serializable: {:s}".format(str(ex)),
|
||||
})
|
||||
did_work = True
|
||||
continue
|
||||
|
||||
# Build the final response with the standard envelope.
|
||||
response: dict[str, object] = {"status": "ok", "result": result}
|
||||
if dc.stdout:
|
||||
response["stdout"] = dc.stdout
|
||||
if dc.stderr:
|
||||
response["stderr"] = dc.stderr
|
||||
_send_and_close(dc, response)
|
||||
did_work = True
|
||||
|
||||
return did_work
|
||||
|
||||
|
||||
def has_pending() -> bool:
|
||||
"""
|
||||
Return ``True`` if there are deferred clients awaiting completion.
|
||||
"""
|
||||
return bool(_deferred_clients)
|
||||
|
||||
|
||||
def close_all() -> None:
|
||||
"""
|
||||
Close all deferred client connections without sending responses.
|
||||
"""
|
||||
for dc in _deferred_clients:
|
||||
try:
|
||||
dc.conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
_deferred_clients.clear()
|
||||
Reference in New Issue
Block a user