mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
234 lines
9.0 KiB
Python
234 lines
9.0 KiB
Python
"""Exercise the actual cache client/server, with small isolated backend modules."""
|
|
|
|
import concurrent.futures
|
|
import json
|
|
import os
|
|
import py_compile
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.skipif(not hasattr(os, "fork"), reason="The memory export server uses POSIX isolation")
|
|
|
|
|
|
@dataclass
|
|
class CacheClient:
|
|
api: Path
|
|
env: dict[str, str]
|
|
used_envs: list[dict[str, str]] = field(default_factory=list)
|
|
|
|
def request(self, *, setting: str | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
request_env = self.env if setting is None else {**self.env, "OPENAPI_TEST_SETTING": setting}
|
|
self.used_envs.append(request_env)
|
|
return subprocess.run(
|
|
[sys.executable, str(self.api / "dev/cached_console_openapi.py")],
|
|
cwd=self.api.parent,
|
|
env=request_env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=check,
|
|
)
|
|
|
|
def export(self, *, setting: str | None = None) -> dict[str, str | int | None]:
|
|
return json.loads(self.request(setting=setting).stdout)["console"]
|
|
|
|
|
|
@pytest.fixture
|
|
def backend(tmp_path: Path) -> Iterator[CacheClient]:
|
|
api = tmp_path / "api"
|
|
(api / "dev").mkdir(parents=True)
|
|
script = api / "dev/cached_console_openapi.py"
|
|
original = Path(__file__).resolve().parents[3] / "dev/cached_console_openapi.py"
|
|
shutil.copyfile(original, script)
|
|
modules = {
|
|
"dev/__init__.py": "",
|
|
"dev/generate_swagger_specs.py": "def apply_runtime_defaults(): pass\n",
|
|
"models.py": "import secrets\nVALUE = 'v1'\nSNAPSHOT = secrets.token_hex(8)\n",
|
|
"controllers/fixture.py": "FIELD = 'integer'\n",
|
|
"core/mcp/types.py": "",
|
|
"dify_agent/protocol/schemas.py": "",
|
|
"graphon/nodes/parameter_extractor/entities.py": "",
|
|
"services/knowledge_fs/product_dto.py": "",
|
|
"dev/export_console_openapi.py": """
|
|
import json, os
|
|
def main():
|
|
from controllers.fixture import FIELD
|
|
from models import VALUE, SNAPSHOT
|
|
print(json.dumps({'console': {'field': FIELD, 'model': VALUE, 'pid': os.getpid(),
|
|
'snapshot': SNAPSHOT, 'setting': os.getenv('OPENAPI_TEST_SETTING')}, 'fast': {}}))
|
|
if __name__ == '__main__': main()
|
|
""",
|
|
}
|
|
for filename, content in modules.items():
|
|
path = api / filename
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
for parent in path.parents:
|
|
if parent == api:
|
|
break
|
|
(parent / "__init__.py").touch()
|
|
path.write_text(content)
|
|
client = CacheClient(api, {**os.environ, "PYTHONPATH": str(api)})
|
|
yield client
|
|
for request_env in client.used_envs:
|
|
subprocess.run(
|
|
[sys.executable, str(script), "--stop"],
|
|
cwd=tmp_path,
|
|
env=request_env,
|
|
capture_output=True,
|
|
timeout=10,
|
|
check=True,
|
|
)
|
|
|
|
|
|
def test_memory_cache_is_shared_and_ignores_test_files(backend: CacheClient) -> None:
|
|
api, call = backend.api, backend.export
|
|
first = call()
|
|
assert call() == first
|
|
(api / "tests").mkdir()
|
|
(api / "tests/test_unrelated.py").write_text("# unrelated test\n")
|
|
assert call() == first
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
|
assert all(result == first for result in pool.map(lambda _: call(), range(4)))
|
|
assert not list(api.rglob("*.json"))
|
|
assert not list(api.rglob("*.pyc"))
|
|
|
|
|
|
def test_changed_controller_is_fresh_and_preloaded_dependency_restarts_snapshot(backend: CacheClient) -> None:
|
|
api, call = backend.api, backend.export
|
|
first = call()
|
|
controller = api / "controllers/fixture.py"
|
|
controller.write_text("FIELD = 'number'\n")
|
|
second = call()
|
|
assert second["field"] == "number"
|
|
assert second["pid"] != first["pid"]
|
|
assert second["snapshot"] == first["snapshot"]
|
|
# Same-size edit with unchanged mtime must not revive a stale .pyc or snapshot.
|
|
model = api / "models.py"
|
|
stat = model.stat()
|
|
model.write_text(model.read_text().replace("'v1'", "'v2'"))
|
|
os.utime(model, ns=(stat.st_atime_ns, stat.st_mtime_ns))
|
|
third = call()
|
|
assert third["model"] == "v2"
|
|
assert third["snapshot"] != second["snapshot"]
|
|
assert third["field"] == "number"
|
|
|
|
|
|
def test_export_errors_never_return_old_contracts_and_recover_after_fix(backend: CacheClient) -> None:
|
|
api, call = backend.api, backend.export
|
|
first = call()
|
|
controller = api / "controllers/fixture.py"
|
|
controller.write_text("FIELD = syntax error\n")
|
|
failure = backend.request(check=False)
|
|
assert failure.returncode != 0
|
|
assert not failure.stdout
|
|
assert "SyntaxError" in failure.stderr
|
|
controller.write_text("FIELD = 'string'\n")
|
|
fixed = call()
|
|
assert fixed["field"] == "string"
|
|
assert fixed["pid"] != first["pid"]
|
|
|
|
|
|
def test_environment_and_file_additions_invalidate_memory_cache(backend: CacheClient) -> None:
|
|
api, call = backend.api, backend.export
|
|
first = call()
|
|
variant = call(setting="variant")
|
|
assert variant["setting"] == "variant"
|
|
assert variant["snapshot"] != first["snapshot"]
|
|
(api / "new_dependency.py").write_text("VALUE = 1\n")
|
|
added = call()
|
|
assert added["snapshot"] != first["snapshot"]
|
|
(api / "new_dependency.py").unlink()
|
|
assert call()["snapshot"] != added["snapshot"]
|
|
|
|
|
|
def test_sibling_python_models_refresh_cached_exports_without_stale_bytecode(backend: CacheClient) -> None:
|
|
package = backend.api.parent / "dify-agent/src/citation_fixture"
|
|
package.mkdir(parents=True)
|
|
(package / "__init__.py").touch()
|
|
model = package / "model.py"
|
|
model.write_text("FIELD = 'string'\n")
|
|
compiled = py_compile.compile(str(model), doraise=True)
|
|
assert compiled is not None
|
|
bytecode = Path(compiled).read_bytes()
|
|
# This module is imported by the export worker, not by the preloaded snapshot.
|
|
(backend.api / "controllers/fixture.py").write_text("from citation_fixture.model import FIELD\n")
|
|
backend.env["PYTHONPATH"] = os.pathsep.join([str(backend.api), str(package.parent)])
|
|
first = backend.export()
|
|
assert first["field"] == "string"
|
|
assert backend.export() == first
|
|
stat = model.stat()
|
|
model.write_text("FIELD = 'number'\n")
|
|
os.utime(model, ns=(stat.st_atime_ns, stat.st_mtime_ns))
|
|
changed = backend.export()
|
|
assert changed["field"] == "number"
|
|
assert changed["pid"] != first["pid"]
|
|
added = package / "new_model.py"
|
|
added.write_text("VALUE = 1\n")
|
|
with_added = backend.export()
|
|
assert with_added["pid"] != changed["pid"]
|
|
added.unlink()
|
|
assert backend.export()["pid"] != with_added["pid"]
|
|
assert Path(compiled).read_bytes() == bytecode
|
|
|
|
|
|
def test_native_threads_use_fresh_exports_and_propagate_failures(backend: CacheClient) -> None:
|
|
model = backend.api / "models.py"
|
|
model.write_text(
|
|
model.read_text()
|
|
+ "import threading, time\n"
|
|
+ "threading.Thread(target=time.sleep, args=(60,), daemon=True).start()\n"
|
|
)
|
|
first = backend.export()
|
|
controller = backend.api / "controllers/fixture.py"
|
|
controller.write_text("FIELD = 'string'\n")
|
|
changed = backend.export()
|
|
assert changed["field"] == "string"
|
|
assert changed["snapshot"] != first["snapshot"]
|
|
controller.write_text("FIELD = syntax error\n")
|
|
failure = backend.request(check=False)
|
|
assert failure.returncode != 0
|
|
assert not failure.stdout
|
|
assert "SyntaxError" in failure.stderr
|
|
|
|
|
|
def test_invalid_export_payload_does_not_replace_a_valid_contract(backend: CacheClient) -> None:
|
|
first = backend.export()
|
|
exporter = backend.api / "dev/export_console_openapi.py"
|
|
original = exporter.read_text()
|
|
exporter.write_text("def main(): print('{\"unexpected\": {}}')\n")
|
|
failure = backend.request(check=False)
|
|
assert failure.returncode != 0
|
|
assert not failure.stdout
|
|
assert "Invalid Console export response" in failure.stderr
|
|
exporter.write_text(original)
|
|
assert backend.export()["field"] == first["field"]
|
|
|
|
|
|
def test_failed_preload_falls_back_to_fresh_exports(backend: CacheClient) -> None:
|
|
(backend.api / "core/mcp/types.py").write_text("raise RuntimeError('Cannot preload this dependency')\n")
|
|
first = backend.export()
|
|
(backend.api / "controllers/fixture.py").write_text("FIELD = 'string'\n")
|
|
changed = backend.export()
|
|
assert changed["field"] == "string"
|
|
assert changed["snapshot"] != first["snapshot"]
|
|
|
|
|
|
def test_socket_directory_must_be_private(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from dev.cached_console_openapi import socket_path
|
|
|
|
# Keep this short enough for the Unix socket path limit on macOS.
|
|
with tempfile.TemporaryDirectory(prefix="dify-ipc-", dir="/tmp") as directory:
|
|
monkeypatch.setattr(tempfile, "tempdir", directory)
|
|
path = socket_path()
|
|
assert path.parent.stat().st_mode & 0o077 == 0
|
|
path.parent.chmod(0o755)
|
|
with pytest.raises(PermissionError, match="private to the current user"):
|
|
socket_path()
|