217 lines
5.9 KiB
Python
217 lines
5.9 KiB
Python
# 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()
|