Files
dify/api/dev/cached_console_openapi.py
T
Stephen Zhouandautofix-ci[bot] 04ba877c1a feat(web): export OpenAPI mapper types and compatible contract subpaths (#44915)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-10-28 14:39:37 +00:00

361 lines
14 KiB
Python

"""Local, idle-expiring OpenAPI export cache. Contract bytes never touch the filesystem.
The POSIX server owns a private Unix socket and an empty lifecycle lock. It caches
the document in RAM. A single-threaded snapshot preloads only dependency modules,
never controllers. Each refresh imports controllers in a fresh fork; edits to a
preloaded dependency re-exec the snapshot before exporting. Unsupported platforms
or unsafe snapshots use the ordinary fresh-process exporter.
"""
from __future__ import annotations
import contextlib
import hashlib
import importlib.machinery
import io
import json
import os
import select
import signal
import socket
import subprocess
import sys
import tempfile
import time
import traceback
from pathlib import Path
from typing import Any, Never
API_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOTS = (API_ROOT, API_ROOT.parent / "dify-agent/src")
PACKAGE_INPUTS = (API_ROOT / "pyproject.toml", API_ROOT / "uv.lock", API_ROOT.parent / "dify-agent/pyproject.toml")
TIMEOUT = 90
IDLE_TIMEOUT = 120
MAX_BYTES = 64 * 1024 * 1024
def source_imports() -> None:
"""Read editable backend modules from source, preserving installed-library bytecode caches."""
original = importlib.machinery.SourceFileLoader.get_code
def get_code(self: importlib.machinery.SourceFileLoader, fullname: str):
path = Path(self.path)
if any(path.is_relative_to(root) for root in SOURCE_ROOTS) and ".venv" not in path.parts:
return self.source_to_code(self.get_data(self.path), self.path)
return original(self, fullname)
importlib.machinery.SourceFileLoader.get_code = get_code
sys.dont_write_bytecode = True
def source_state() -> dict[str, tuple[int, int, int]]:
"""Match the Console descriptor's inputs, excluding tests and local environments."""
files = [*PACKAGE_INPUTS, Path.cwd() / ".env"]
for root in SOURCE_ROOTS:
for directory, children, names in os.walk(root):
children[:] = [name for name in children if name not in {".venv", ".git", "__pycache__"}]
if Path(directory) == API_ROOT:
children[:] = [name for name in children if name != "tests"]
files.extend(Path(directory) / name for name in names if name.endswith(".py"))
return {str(file): file_stamp(file) for file in files}
def file_stamp(file: Path) -> tuple[int, int, int]:
try:
stat = file.stat()
return stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size
except FileNotFoundError:
return 0, 0, 0
def fresh_export() -> bytes:
result = subprocess.run(
[
sys.executable,
"-B",
"-c",
"import sys;sys.path.insert(0,sys.argv[1]);"
"from dev.cached_console_openapi import source_imports;source_imports();"
"from dev.export_console_openapi import main;main()",
str(API_ROOT),
],
capture_output=True,
timeout=TIMEOUT,
check=False,
)
if result.returncode:
raise RuntimeError(result.stderr.decode(errors="replace")[-16000:])
return result.stdout
def single_threaded() -> bool:
"""Check native threads too: threading.active_count misses C library pools."""
try:
if sys.platform.startswith("linux"):
return len(os.listdir("/proc/self/task")) == 1
if sys.platform == "darwin":
# Darwin -M always prints its thread header, even with -o pid=.
output = subprocess.check_output(["ps", "-M", "-p", str(os.getpid())], timeout=5)
return len(output.strip().splitlines()) == 2
except (OSError, subprocess.SubprocessError):
return False
return False
def preload() -> tuple[bool, dict[str, tuple[int, int, int]]]:
"""Load schema dependencies without retaining route registries or controller classes."""
import urllib.request
getproxies = urllib.request.getproxies
try:
if sys.platform == "darwin":
# macOS SystemConfiguration starts native threads. Resolve its proxy
# settings in a helper, preserving the values without forking those threads.
proxies = json.loads(
subprocess.check_output(
[sys.executable, "-c", "import json,urllib.request;print(json.dumps(urllib.request.getproxies()))"],
timeout=10,
)
)
urllib.request.getproxies = lambda: proxies
sys.path.insert(0, str(API_ROOT))
from dev.generate_swagger_specs import apply_runtime_defaults
apply_runtime_defaults()
# Keep runtime composition (and libraries with native thread pools) out
# of the snapshot. These pure model dependencies account for much of startup.
import dify_agent.protocol.schemas
import core.mcp.types
import graphon.nodes.parameter_extractor.entities
import models
import services.knowledge_fs.product_dto
_ = core.mcp.types, dify_agent.protocol.schemas, graphon.nodes.parameter_extractor.entities
_ = models, services.knowledge_fs.product_dto
safe = not any(name.startswith("controllers.") for name in sys.modules) and single_threaded()
except Exception:
# A cache optimization must not prevent a normal export from working.
safe = False
finally:
urllib.request.getproxies = getproxies
dependencies = {}
for module in tuple(sys.modules.values()):
filename = getattr(module, "__file__", None) # guard-ignore: no-new-getattr -- optional module source metadata
if isinstance(filename, str):
dependencies[str(Path(filename).resolve())] = file_stamp(Path(filename))
for filename in (*PACKAGE_INPUTS, Path.cwd() / ".env"):
dependencies[str(filename)] = file_stamp(filename)
return safe, dependencies
def export_worker(write_fd: int) -> Never:
"""Run the isolated exporter and keep its pipe open through process exit."""
try:
from dev.export_console_openapi import main
output = io.StringIO()
with contextlib.redirect_stdout(output):
main()
payload = b"OK\n" + output.getvalue().encode()
except BaseException:
payload = b"ERROR\n" + traceback.format_exc().encode()
try:
with os.fdopen(write_fd, "wb") as pipe:
pipe.write(payload)
pipe.flush()
# Keep the pipe open through process exit. Closing it first lets
# the parent kill a successful worker while exit instrumentation
# (for example, subprocess coverage) is still flushing its data.
os._exit(0)
finally:
os._exit(1)
def fork_export() -> bytes:
"""Export from a clean controller registry; the parent snapshot is never mutated."""
read_fd, write_fd = os.pipe()
pid = os.fork()
if pid == 0:
os.close(read_fd)
export_worker(write_fd)
os.close(write_fd)
chunks: list[bytes] = []
size = 0
deadline = time.monotonic() + TIMEOUT
try:
while True:
if not select.select([read_fd], [], [], max(0, deadline - time.monotonic()))[0]:
raise TimeoutError("OpenAPI refresh timed out")
chunk = os.read(read_fd, 65536)
if not chunk:
break
size += len(chunk)
if size > MAX_BYTES:
raise ValueError("OpenAPI export exceeds the response limit")
chunks.append(chunk)
result = b"".join(chunks)
status, _, payload = result.partition(b"\n")
if status != b"OK":
raise RuntimeError(payload.decode(errors="replace") or "OpenAPI worker exited without a response")
return payload
finally:
os.close(read_fd)
# A worker can fail after closing its pipe. Never leave it running or unreaped.
with contextlib.suppress(ProcessLookupError):
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
def socket_path() -> Path:
directory = Path(tempfile.gettempdir()) / f"dify-openapi-{os.getuid()}"
# macOS Unix socket paths have a small length limit.
if len(str(directory)) > 55:
directory = Path("/tmp") / f"dify-openapi-{os.getuid()}"
directory.mkdir(mode=0o700, exist_ok=True)
stat = directory.lstat()
if directory.is_symlink() or stat.st_uid != os.getuid() or stat.st_mode & 0o077:
raise PermissionError("OpenAPI IPC directory must be private to the current user")
environment = {
key: value
for key, value in os.environ.items()
if key not in {"_", "PWD", "OLDPWD", "SHLVL", "UV_RUN_RECURSION_DEPTH"}
}
key = hashlib.sha256(json.dumps([str(API_ROOT), os.getcwd(), sys.executable, environment], sort_keys=True).encode())
key.update(Path(__file__).read_bytes())
return directory / f"{key.hexdigest()[:24]}.sock"
def serve(path: Path, inherited: list[str]) -> None:
import fcntl
if inherited:
listener = socket.socket(fileno=int(inherited[0]))
lock_fd = int(inherited[1])
else:
lock_fd = os.open(path.with_suffix(".lock"), os.O_CREAT | os.O_RDWR, 0o600)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.close(lock_fd)
return
path.unlink(missing_ok=True)
listener = socket.socket(socket.AF_UNIX)
listener.bind(str(path))
listener.listen(16)
listener.settimeout(IDLE_TIMEOUT)
baseline = source_state()
safe, dependencies = preload()
snapshot_dirty = source_state() != baseline
cached_state: dict[str, tuple[int, int, int]] | None = None
cached = b""
try:
while True:
try:
connection, _ = listener.accept()
except TimeoutError:
return
with connection:
connection.settimeout(TIMEOUT)
try:
request = b""
while not request.endswith(b"\n") and len(request) < 16:
part = connection.recv(16 - len(request))
if not part:
break
request += part
if request == b"stop\n":
connection.sendall(b"OK\n")
return
if request != b"export\n":
continue
current = source_state()
stale = (
snapshot_dirty
or set(current) != set(baseline)
or any(file_stamp(Path(file)) != stamp for file, stamp in dependencies.items())
)
if stale:
# No reload(sys.modules): restart all preloaded dependencies.
connection.sendall(b"RETRY\n")
connection.close()
os.set_inheritable(listener.fileno(), True)
os.set_inheritable(lock_fd, True)
os.execv(
sys.executable,
[sys.executable, __file__, "--serve", str(path), str(listener.fileno()), str(lock_fd)],
)
if current != cached_state:
output = fork_export() if safe and single_threaded() else fresh_export()
# Do not publish a contract assembled while source files changed.
if source_state() != current:
connection.sendall(b"RETRY\n")
continue
payload: Any = json.loads(output)
if not isinstance(payload, dict) or set(payload) != {"console", "fast"}:
raise ValueError("Invalid Console export response")
cached, cached_state = output, current
connection.sendall(b"OK\n" + cached)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception:
with contextlib.suppress(OSError):
connection.sendall(b"ERROR\n" + traceback.format_exc().encode()[-16000:])
finally:
listener.close()
path.unlink(missing_ok=True)
os.close(lock_fd)
def request_export(*, stop: bool = False) -> bytes:
if not hasattr(os, "fork"):
return b"" if stop else fresh_export()
path = socket_path()
deadline = time.monotonic() + TIMEOUT
launched = False
while time.monotonic() < deadline:
try:
with socket.socket(socket.AF_UNIX) as connection:
connection.settimeout(max(0.1, deadline - time.monotonic()))
connection.connect(str(path))
connection.sendall(b"stop\n" if stop else b"export\n")
chunks: list[bytes] = []
size = 0
while chunk := connection.recv(65536):
size += len(chunk)
if size > MAX_BYTES:
raise ValueError("OpenAPI response exceeds the limit")
chunks.append(chunk)
status, _, payload = b"".join(chunks).partition(b"\n")
if status == b"OK":
return payload
if status == b"ERROR":
raise RuntimeError(payload.decode(errors="replace"))
if status != b"RETRY":
raise RuntimeError("Incomplete OpenAPI cache response")
except (FileNotFoundError, ConnectionRefusedError):
if stop:
return b""
if not launched:
subprocess.Popen(
[sys.executable, __file__, "--serve", str(path)],
start_new_session=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
launched = True
time.sleep(0.03)
raise TimeoutError("OpenAPI cache did not respond in time")
if __name__ == "__main__":
# Timestamp-based .pyc files can serve stale code after same-size edits within one second.
source_imports()
if sys.argv[1:2] == ["--serve"]:
serve(Path(sys.argv[2]), sys.argv[3:])
elif sys.argv[1:] == ["--stop"]:
request_export(stop=True)
else:
sys.stdout.buffer.write(request_export())