Skip to content

Commit 0286f2c

Browse files
workflow: use anyio.to_thread to send sync server functions to thread pool (#13612)
* run server funcs in thread pool with asyncio * add changeset * lint * add changeset * rm import * use anyio + add test * limit call_fn concurrency * format * test fix * refactor * add changeset * clean up * format + lint * typefix * move import * tweak * rm direct call_fn tests * rm call_fn server function * add sanitized name error * lint * format * fix test * tweak * format * add changeset * add flag for unicode * add blocks check * use gr.api() to register bound fn endpoints * save lock * trigger job.cancel() on abort * prevent race in image editor * format * format * error tweak * revert * wrap bound fn exceptions as gr.Error * format --------- Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
1 parent 7d71469 commit 0286f2c

6 files changed

Lines changed: 137 additions & 81 deletions

File tree

.changeset/funny-nights-leave.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@gradio/workflowcanvas": patch
3+
"gradio": patch
4+
---
5+
6+
fix:workflow: use anyio.to_thread to send sync server functions to thread pool

gradio/routes.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
cast,
2828
)
2929

30+
import anyio
3031
import fastapi
3132
import httpx
3233
import markupsafe
@@ -1691,7 +1692,9 @@ async def component_server(
16911692
if inspect.iscoroutinefunction(fn):
16921693
return await fn(*processed_input)
16931694
else:
1694-
return fn(*processed_input)
1695+
return await anyio.to_thread.run_sync(
1696+
fn, *processed_input, limiter=app.get_blocks().limiter
1697+
)
16951698

16961699
@router.get(
16971700
"/queue/status",

gradio/workflow.py

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import secrets
1111
import sys
1212
import tempfile
13+
import threading
1314
import types
1415
import urllib.parse
1516
import warnings
@@ -18,13 +19,15 @@
1819
from concurrent.futures import ThreadPoolExecutor
1920
from typing import TYPE_CHECKING, Optional, TypedDict, Union, get_type_hints
2021

22+
import anyio
2123
import httpx
2224
from huggingface_hub import HfApi
2325
from huggingface_hub import get_token as hf_get_token
2426

2527
import gradio as gr
2628
from gradio.blocks import Blocks
2729
from gradio.components.workflowcanvas import WorkflowCanvas
30+
from gradio.context import Context
2831
from gradio.helpers import special_args as _special_args
2932
from gradio.oauth import OAuthProfile, OAuthToken
3033
from gradio.route_utils import Request
@@ -67,7 +70,7 @@ class _CuratedCache(TypedDict):
6770

6871

6972
_CURATED_CACHE: _CuratedCache = {"fetched_at": 0.0, "items": None}
70-
_CURATED_LOCK = __import__("threading").Lock()
73+
_CURATED_LOCK = threading.Lock()
7174

7275

7376
def _bundled_snapshot_path() -> str:
@@ -135,6 +138,7 @@ def _load_curated() -> list[dict]:
135138
# Scalar-only — everything else (str, list, dict, custom classes) falls through
136139
# to the default "text" port type, which round-trips as JSON.
137140
_PY_TO_PORT = {int: "number", float: "number", bool: "boolean"}
141+
_SANITIZE_RE = re.compile(r"[^a-zA-Z0-9_-]")
138142

139143

140144
def _build_edges(
@@ -1304,6 +1308,11 @@ def __init__(
13041308
self._bound: dict[str, Callable] = bind or {}
13051309
self._edges: list[tuple[str, str]] = edges or []
13061310

1311+
if Context.root_block is not None:
1312+
raise ValueError(
1313+
"gr.Workflow cannot be created inside another gr.Blocks context."
1314+
)
1315+
13071316
warnings.warn(
13081317
"gr.Workflow is currently in beta. Its API and UX may change in future releases.",
13091318
UserWarning,
@@ -1338,11 +1347,10 @@ def _load_initial() -> str | None:
13381347
return None
13391348

13401349
bound = self._bound
1350+
_save_lock = threading.Lock()
13411351

13421352
def call_fn(
1343-
data,
1344-
_request: Optional[Request] = None,
1345-
_token: Optional[OAuthToken] = None,
1353+
data, request: Optional[Request] = None, token: Optional[OAuthToken] = None
13461354
) -> str:
13471355
fn_name = data[0] if data else ""
13481356
try:
@@ -1359,10 +1367,15 @@ def call_fn(
13591367
args = json.loads(args_json)
13601368
if not isinstance(args, list):
13611369
args = [args]
1362-
args, *_ = _special_args(fn, args, _request, None, token=_token)
1363-
result = fn(*args)
1364-
result = list(result) if isinstance(result, (list, tuple)) else [result]
1365-
return json.dumps(result)
1370+
args, *_ = _special_args(fn, args, request, None, token=token)
1371+
if inspect.iscoroutinefunction(fn):
1372+
import asyncio
1373+
1374+
result = asyncio.run(fn(*args))
1375+
else:
1376+
result = fn(*args)
1377+
out = list(result) if isinstance(result, (list, tuple)) else [result]
1378+
return json.dumps(out)
13661379
except Exception as e:
13671380
logger.error("call_fn failed for %s: %s", fn_name, e, exc_info=True)
13681381
return json.dumps(
@@ -1459,17 +1472,17 @@ def save_workflow(
14591472
WorkflowGraph(parsed)
14601473
except ValueError as exc:
14611474
return json.dumps({"error": f"Invalid workflow schema: {exc}"})
1462-
with open(workflow_file, "w", encoding="utf-8") as f:
1463-
f.write(payload)
1464-
# Re-derive API endpoints so /info + /call track the saved graph
1465-
# (outputs added / removed / renamed / retyped).
1466-
if self._api_endpoints is not None:
1467-
try:
1468-
self._api_endpoints.sync()
1469-
except Exception:
1470-
logger.error(
1471-
"Workflow: endpoint sync after save failed", exc_info=True
1472-
)
1475+
with _save_lock:
1476+
with open(workflow_file, "w", encoding="utf-8") as f:
1477+
f.write(payload)
1478+
if self._api_endpoints is not None:
1479+
try:
1480+
self._api_endpoints.sync()
1481+
except Exception:
1482+
logger.error(
1483+
"Workflow: endpoint sync after save failed",
1484+
exc_info=True,
1485+
)
14731486
return "ok"
14741487
except Exception as e:
14751488
logger.error("save_workflow failed: %s", e, exc_info=True)
@@ -1491,7 +1504,6 @@ def save_workflow(
14911504
curated_modalities,
14921505
curated_modality_tasks,
14931506
get_dataset_schema,
1494-
call_fn,
14951507
list_bound_fns,
14961508
get_workflow_api,
14971509
save_workflow,
@@ -1520,6 +1532,51 @@ def _current_graph() -> WorkflowGraph | None:
15201532
server_functions=server_functions,
15211533
)
15221534

1535+
def _wrap_bound_fn(fn: Callable) -> Callable:
1536+
async def wrapper(
1537+
args_json: str,
1538+
_request: Optional[Request] = None,
1539+
_token: Optional[OAuthToken] = None,
1540+
) -> str:
1541+
args = json.loads(args_json)
1542+
if not isinstance(args, list):
1543+
args = [args]
1544+
args, *_ = _special_args(fn, args, _request, None, token=_token)
1545+
try:
1546+
result = (
1547+
await fn(*args)
1548+
if inspect.iscoroutinefunction(fn)
1549+
else await anyio.to_thread.run_sync(
1550+
lambda: fn(*args), limiter=self.limiter
1551+
)
1552+
)
1553+
except Exception as e:
1554+
raise gr.Error(str(e)) from e
1555+
out = list(result) if isinstance(result, (list, tuple)) else [result]
1556+
return json.dumps(out)
1557+
1558+
return wrapper
1559+
1560+
if bound:
1561+
from gradio.workflow_api import _active_blocks
1562+
1563+
if len(bound) != len({_SANITIZE_RE.sub("_", n) for n in bound}):
1564+
sanitized = [_SANITIZE_RE.sub("_", n) for n in bound]
1565+
dupes = {s for s in sanitized if sanitized.count(s) > 1}
1566+
raise ValueError(
1567+
f"gr.Workflow: bound function names produce duplicate endpoint "
1568+
f"names after sanitizing: {dupes}. Rename one."
1569+
)
1570+
1571+
with _active_blocks(self):
1572+
for fn_name, fn in bound.items():
1573+
sanitized_name = _SANITIZE_RE.sub("_", fn_name)
1574+
gr.api(
1575+
_wrap_bound_fn(fn),
1576+
api_name=f"predict_fn_{sanitized_name}",
1577+
concurrency_limit="default",
1578+
api_visibility="undocumented",
1579+
)
15231580
# Expose each subject (output) as a named API endpoint reusing /info +
15241581
# /call. The manager re-syncs on every save_workflow, so adding,
15251582
# removing, renaming, or retyping an output updates the live API.

js/workflowcanvas/Index.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@
4545
</script>
4646

4747
<div class="workflow-fullscreen">
48-
<WorkflowCanvas server={serverObj} {initialValue} />
48+
<WorkflowCanvas
49+
server={serverObj}
50+
{initialValue}
51+
gradio_shared={gradio.shared}
52+
/>
4953
</div>
5054

5155
<style>

js/workflowcanvas/workflow/WorkflowCanvas.svelte

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,15 @@
7676
7777
let {
7878
server = {},
79-
initialValue = null
80-
}: { server?: Record<string, any>; initialValue?: string | null } = $props();
79+
initialValue = null,
80+
gradio_shared = undefined
81+
}: {
82+
server?: Record<string, any>;
83+
initialValue?: string | null;
84+
gradio_shared?: Record<string, any> | undefined;
85+
} = $props();
86+
87+
const gradio_client = $derived(gradio_shared?.client);
8188
8289
const auth = createHFAuth(() => server);
8390
@@ -1529,9 +1536,35 @@
15291536
])
15301537
: undefined;
15311538
1532-
const callFnWithToken = server?.call_fn
1533-
? async (fnName: string, argsJson: string) =>
1534-
server.call_fn([fnName, argsJson])
1539+
const callFnWithToken = gradio_client
1540+
? async (fnName: string, argsJson: string) => {
1541+
const safeN = fnName.replace(/[^a-zA-Z0-9_-]/gu, "_");
1542+
const job = gradio_client.submit(`/predict_fn_${safeN}`, [argsJson]);
1543+
abortController?.signal.addEventListener(
1544+
"abort",
1545+
() => job.cancel(),
1546+
{
1547+
once: true
1548+
}
1549+
);
1550+
for await (const msg of job) {
1551+
if (msg.type === "data") {
1552+
return (msg.data as unknown[])[0] as string;
1553+
}
1554+
if (msg.type === "status" && msg.stage === "error") {
1555+
return JSON.stringify({
1556+
error: msg.message ?? "Function call failed",
1557+
error_type: "unknown",
1558+
suggestion: ""
1559+
});
1560+
}
1561+
}
1562+
return JSON.stringify({
1563+
error: "No data received from fn endpoint",
1564+
error_type: "unknown",
1565+
suggestion: ""
1566+
});
1567+
}
15351568
: undefined;
15361569
15371570
await executeWorkflow(

test/test_workflow.py

Lines changed: 7 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import json
22
import os
33
import tempfile
4-
from typing import Optional
54

65
import pytest
76

87
import gradio as gr
98
import gradio.workflow as workflow_module
10-
from gradio.oauth import OAuthProfile, OAuthToken
9+
from gradio.oauth import OAuthToken
1110
from gradio.route_utils import Request
1211
from gradio.workflow import (
1312
WRITE_TOKEN,
@@ -441,58 +440,12 @@ def text_generation(self, *args, **kwargs):
441440

442441

443442
class TestCallFn:
444-
def _call_fn(self, tmp_path, bind):
445-
wf = Workflow(graph=str(tmp_path / "wf.json"), bind=bind)
446-
canvas = next(
447-
b for b in wf.blocks.values() if b.get_block_name() == "workflowcanvas"
448-
)
449-
return canvas.call_fn
450-
451-
def test_calls_bound_fn(self, tmp_path):
452-
call_fn = self._call_fn(tmp_path, {"echo": lambda x: x})
453-
result = json.loads(call_fn(["echo", '["hello"]']))
454-
assert result == ["hello"]
455-
456-
def test_injects_oauth_token(self, tmp_path):
457-
received = {}
458-
459-
def fn_with_token(text: str, token: Optional[OAuthToken]) -> str:
460-
received["token"] = token
461-
return text
462-
463-
call_fn = self._call_fn(tmp_path, {"fn_with_token": fn_with_token})
464-
465-
class _MockRequest:
466-
session = {
467-
"oauth_info": {
468-
"access_token": "test-tok",
469-
"scope": "openid",
470-
"expires_at": 9999999999,
471-
}
472-
}
473-
474-
result = json.loads(
475-
call_fn(["fn_with_token", '["hi"]'], _request=_MockRequest())
476-
)
477-
assert result == ["hi"]
478-
assert received["token"].token == "test-tok"
479-
480-
def test_injects_direct_token_without_session(self, tmp_path):
481-
def fn(text: str, token: OAuthToken, profile: Optional[OAuthProfile]) -> str:
482-
return f"{token.token},{profile}"
483-
484-
call_fn = self._call_fn(tmp_path, {"fn": fn})
485-
direct = OAuthToken.__new__(OAuthToken)
486-
direct.token = "direct-token"
487-
direct.scope = "openid"
488-
direct.expires_at = 9999999999
489-
result = json.loads(call_fn(["fn", '["hello"]'], _request=None, _token=direct))
490-
assert result == ["direct-token,None"]
491-
492-
def test_unknown_fn_returns_error(self, tmp_path):
493-
call_fn = self._call_fn(tmp_path, {"echo": lambda x: x})
494-
result = json.loads(call_fn(["missing", "[]"]))
495-
assert result.get("error_type") == "unknown"
443+
def test_queue_endpoint_registered_for_bound_fn(self, tmp_path):
444+
wf = Workflow(graph=str(tmp_path / "wf.json"), bind={"my_fn": lambda x: x})
445+
api_names = [
446+
fn.api_name for fn in wf.fns.values() if isinstance(fn.api_name, str)
447+
]
448+
assert "predict_fn_my_fn" in api_names
496449

497450

498451
class TestCallSpaceValidation:

0 commit comments

Comments
 (0)