fix: allow long-running community image fetches
This commit is contained in:
1 parent
6778110c50
commit
89d8119e8f
3 files changed
+137
-1
No files matched your search
@@ -104,6 +104,7 @@ Selecting a LoRA loader opens the **LoRA Info** sidebar and follows the node's c
|
|||||||
|
|
||||||
- If the selected LoRA is indexed by the remote LoRA Manager, the sidebar shows its image or video preview, file details, base model, trigger words, tags, usage tips, and direct model links.
|
- If the selected LoRA is indexed by the remote LoRA Manager, the sidebar shows its image or video preview, file details, base model, trigger words, tags, usage tips, and direct model links.
|
||||||
- Cached community creations and Civitai examples appear with video controls, their shared prompt and negative prompt, generation settings, attribution, navigation, and one-click prompt copying.
|
- Cached community creations and Civitai examples appear with video controls, their shared prompt and negative prompt, generation settings, attribution, navigation, and one-click prompt copying.
|
||||||
|
- Community image fetches and per-model refreshes can run beyond the normal connection timeout; progress updates and cancellation remain available while they finish.
|
||||||
- If a node contains multiple active LoRAs, use the selector at the top of the sidebar to switch cards.
|
- If a node contains multiple active LoRAs, use the selector at the top of the sidebar to switch cards.
|
||||||
- LoRA Info closes automatically after the selected LoRA loader is cleared, but stays open while switching directly between loaders.
|
- LoRA Info closes automatically after the selected LoRA loader is cleared, but stays open while switching directly between loaders.
|
||||||
- If no Manager card exists, the sidebar offers name searches on LoRA Manager, Civitai, Civitai Red, and CivArchive.
|
- If no Manager card exists, the sidebar offers name searches on LoRA Manager, Civitai, Civitai Red, and CivArchive.
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ _MAX_CONFIG_BODY = 64 * 1024
|
|||||||
_MAX_TEST_RESPONSE = 64 * 1024
|
_MAX_TEST_RESPONSE = 64 * 1024
|
||||||
_MEDIA_STREAM_CHUNK_SIZE = 64 * 1024
|
_MEDIA_STREAM_CHUNK_SIZE = 64 * 1024
|
||||||
_MEDIA_IDLE_TIMEOUT = 300
|
_MEDIA_IDLE_TIMEOUT = 300
|
||||||
|
_COMMUNITY_FETCH_ROUTES = frozenset(
|
||||||
|
{
|
||||||
|
"/api/lm/community-images/fetch",
|
||||||
|
"/api/lm/community-images/refresh-model",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# URL prefixes that should be forwarded to the remote LoRA Manager
|
# URL prefixes that should be forwarded to the remote LoRA Manager
|
||||||
@@ -772,6 +778,15 @@ async def _proxy_http(
|
|||||||
"auto_decompress": False,
|
"auto_decompress": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif request.method == "POST" and request.path in _COMMUNITY_FETCH_ROUTES:
|
||||||
|
# Community jobs only send a response when finished. Progress
|
||||||
|
# and cancellation use separate WebSocket/HTTP connections.
|
||||||
|
request_options["timeout"] = aiohttp.ClientTimeout(
|
||||||
|
total=None,
|
||||||
|
connect=min(snapshot.timeout, 30),
|
||||||
|
sock_connect=min(snapshot.timeout, 30),
|
||||||
|
sock_read=None,
|
||||||
|
)
|
||||||
async with session.request(
|
async with session.request(
|
||||||
**request_options,
|
**request_options,
|
||||||
) as resp:
|
) as resp:
|
||||||
@@ -822,7 +837,22 @@ async def _proxy_http(
|
|||||||
)
|
)
|
||||||
return downstream
|
return downstream
|
||||||
logger.error(
|
logger.error(
|
||||||
"[LM-Remote] Proxy error for %s %s: %s", request.method, request.path, exc
|
"[LM-Remote] Proxy error for %s %s (%s): %s",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
if isinstance(exc, asyncio.TimeoutError):
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"error": (
|
||||||
|
"Remote LoRA Manager request timed out. "
|
||||||
|
"The operation may still be running on the Manager; "
|
||||||
|
"check its progress before retrying."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status=504,
|
||||||
)
|
)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Remote LoRA Manager unavailable."},
|
{"error": "Remote LoRA Manager unavailable."},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
@@ -9,6 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import TestServer
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
@@ -626,6 +628,109 @@ async def test_proxy_session_does_not_retain_remote_cookies(
|
|||||||
await proxy._close_all_proxy_sessions()
|
await proxy._close_all_proxy_sessions()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"job_path",
|
||||||
|
[
|
||||||
|
"/api/lm/community-images/fetch",
|
||||||
|
"/api/lm/community-images/refresh-model",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_community_job_outlives_lookup_timeout_and_remains_cancellable(
|
||||||
|
isolated_proxy, modules, job_path
|
||||||
|
):
|
||||||
|
proxy, config = isolated_proxy
|
||||||
|
started = asyncio.Event()
|
||||||
|
finish = asyncio.Event()
|
||||||
|
|
||||||
|
async def job_handler(request):
|
||||||
|
assert await request.json() == {"hashes": ["example-hash"]}
|
||||||
|
started.set()
|
||||||
|
await finish.wait()
|
||||||
|
return web.json_response({"success": True, "stored": 2})
|
||||||
|
|
||||||
|
async def cancel_handler(request):
|
||||||
|
finish.set()
|
||||||
|
return web.json_response({"success": True, "cancelled": True})
|
||||||
|
|
||||||
|
upstream = web.Application()
|
||||||
|
upstream.router.add_post(job_path, job_handler)
|
||||||
|
upstream.router.add_post("/api/lm/community-images/cancel", cancel_handler)
|
||||||
|
async with TestServer(upstream) as server:
|
||||||
|
# A short normal timeout keeps the real HTTP regression test fast.
|
||||||
|
snapshot = modules.config.ConfigSnapshot(
|
||||||
|
config.generation, str(server.make_url("")).rstrip("/"), 0.05, ()
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(
|
||||||
|
proxy._proxy_http(
|
||||||
|
DummyRequest(
|
||||||
|
method="POST",
|
||||||
|
path=job_path,
|
||||||
|
payload={"hashes": ["example-hash"]},
|
||||||
|
),
|
||||||
|
snapshot,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=2)
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
assert not task.done(), "community fetch hit the normal lookup timeout"
|
||||||
|
cancelled = await proxy._proxy_http(
|
||||||
|
DummyRequest(method="POST", path="/api/lm/community-images/cancel"),
|
||||||
|
snapshot,
|
||||||
|
)
|
||||||
|
assert cancelled.status == 200
|
||||||
|
assert response_json(cancelled)["cancelled"] is True
|
||||||
|
response = await asyncio.wait_for(task, timeout=2)
|
||||||
|
assert response.status == 200
|
||||||
|
assert response_json(response) == {"success": True, "stored": 2}
|
||||||
|
finally:
|
||||||
|
finish.set()
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
await proxy._close_all_proxy_sessions()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "path"),
|
||||||
|
[
|
||||||
|
("GET", "/api/lm/community-images/status"),
|
||||||
|
("POST", "/api/lm/community-images/cancel"),
|
||||||
|
("POST", "/api/lm/community-images/by-hashes"),
|
||||||
|
("GET", "/api/lm/community-images/fetch"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_normal_community_requests_still_time_out_with_clear_error(
|
||||||
|
isolated_proxy, modules, method, path
|
||||||
|
):
|
||||||
|
proxy, config = isolated_proxy
|
||||||
|
finish = asyncio.Event()
|
||||||
|
|
||||||
|
async def slow_handler(request):
|
||||||
|
await finish.wait()
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
|
||||||
|
upstream = web.Application()
|
||||||
|
upstream.router.add_route(method, path, slow_handler)
|
||||||
|
async with TestServer(upstream) as server:
|
||||||
|
snapshot = modules.config.ConfigSnapshot(
|
||||||
|
config.generation, str(server.make_url("")).rstrip("/"), 0.05, ()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
proxy._proxy_http(DummyRequest(method=method, path=path), snapshot),
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
assert response.status == 504
|
||||||
|
assert "timed out" in response_json(response)["error"]
|
||||||
|
assert "may still be running" in response_json(response)["error"]
|
||||||
|
finally:
|
||||||
|
finish.set()
|
||||||
|
await proxy._close_all_proxy_sessions()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proxy_session_rotation_waits_for_inflight_request(
|
async def test_proxy_session_rotation_waits_for_inflight_request(
|
||||||
isolated_proxy, monkeypatch
|
isolated_proxy, monkeypatch
|
||||||
|
|||||||
Reference in new issue
Block a user