mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-05 07:39:47 +00:00
Some checks failed
CI / Python quality / Pre-commit (push) Waiting to run
CI / Python tests / Unit Tests - py3.11 (push) Waiting to run
CI / Python tests / Unit Tests - py3.12 (push) Waiting to run
CI / Windows / CLI smoke - py3.11 (push) Waiting to run
Deploy / Documentation / deploy (push) Blocked by required conditions
CI and Release / Docker / Build and test / amd64 (push) Waiting to run
CI and Release / Docker / Build and test / arm64 (push) Waiting to run
CI and Release / Docker / Publish multi-platform tags (push) Blocked by required conditions
CI / Python packages / Build and verify distributions (push) Waiting to run
CI / Python quality / GitHub Actions (push) Waiting to run
CI / Python tests / Unit Tests - py3.13 (push) Waiting to run
CI / Python tests / Unit Tests - py3.14 (push) Waiting to run
Deploy / Documentation / Build documentation (push) Waiting to run
Security / CodeQL / Analyze javascript-typescript (push) Waiting to run
Security / CodeQL / Analyze python (push) Waiting to run
CI / ReMe Studio / Studio checks (push) Has been cancelled
* fix(memory): keep writes and startup indexes consistent * fix(release): verify current Auto Fin plugin backends * fix(memory): preserve write ownership and deduplicate startup changes * fix(compaction): offload decision client initialization safely * fix(file-io): preserve cancellation origin and file permissions
184 lines
6.7 KiB
Python
184 lines
6.7 KiB
Python
"""Memory writers publish complete files to concurrent index readers."""
|
|
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from reme.steps.file_io._file_io import write_file_safe
|
|
from reme.components.application_context import ApplicationContext
|
|
from reme.steps.file_io.save import SaveStep
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_overwrite_preserves_complete_old_content_until_commit(tmp_path, monkeypatch):
|
|
"""Readers retain the prior complete revision until atomic publication."""
|
|
target = tmp_path / "memory.md"
|
|
target.write_text("old complete memory", encoding="utf-8")
|
|
target.chmod(0o640)
|
|
replace = os.replace
|
|
|
|
def observe_commit(source, destination):
|
|
assert target.read_text(encoding="utf-8") == "old complete memory"
|
|
assert source != destination
|
|
replace(source, destination)
|
|
|
|
monkeypatch.setattr(os, "replace", observe_commit)
|
|
await write_file_safe(target, "new complete memory")
|
|
|
|
assert target.read_text(encoding="utf-8") == "new complete memory"
|
|
assert stat.S_IMODE(target.stat().st_mode) == 0o640
|
|
assert sorted(path.name for path in tmp_path.iterdir()) == ["memory.md"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_commit_preserves_original_and_cleans_temporary_file(tmp_path, monkeypatch):
|
|
"""Failed publication leaves source bytes intact and no temporary assets."""
|
|
target = tmp_path / "memory.md"
|
|
target.write_bytes(b"original memory")
|
|
|
|
def fail_commit(_source, _destination):
|
|
raise OSError("controlled commit failure")
|
|
|
|
monkeypatch.setattr(os, "replace", fail_commit)
|
|
with pytest.raises(OSError, match="controlled commit failure"):
|
|
await write_file_safe(target, "replacement")
|
|
|
|
assert target.read_bytes() == b"original memory"
|
|
assert sorted(path.name for path in tmp_path.iterdir()) == ["memory.md"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_new_encoded_file_and_binary_payload(tmp_path):
|
|
"""Atomic writes preserve the existing encoding and binary contracts."""
|
|
target = tmp_path / "nested" / "memory.csv"
|
|
await write_file_safe(target, "中文", encoding="gbk")
|
|
assert target.read_bytes() == "中文".encode("gbk")
|
|
await write_file_safe(target, b"\x00\xff")
|
|
assert target.read_bytes() == b"\x00\xff"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_atomic_write_accepts_long_valid_filename(tmp_path):
|
|
"""Temporary names must not grow with the destination's filename."""
|
|
target = tmp_path / ("a" * 241 + ".md")
|
|
target.write_text("original", encoding="utf-8")
|
|
|
|
await write_file_safe(target, "replacement")
|
|
|
|
assert target.read_text(encoding="utf-8") == "replacement"
|
|
assert list(tmp_path.iterdir()) == [target]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_save_keeps_path_lock_until_publication(tmp_path, monkeypatch):
|
|
"""A repeatedly cancelled writer cannot overwrite a later successful save."""
|
|
target = tmp_path / "memory.md"
|
|
target.write_text("original", encoding="utf-8")
|
|
context = ApplicationContext(workspace_dir=str(tmp_path))
|
|
started, release = threading.Event(), threading.Event()
|
|
replace = os.replace
|
|
|
|
def delayed_replace(source, destination):
|
|
if not started.is_set():
|
|
started.set()
|
|
assert release.wait(5)
|
|
replace(source, destination)
|
|
|
|
monkeypatch.setattr(os, "replace", delayed_replace)
|
|
first = asyncio.create_task(SaveStep(app_context=context)(path="memory.md", content="cancelled write"))
|
|
second = None
|
|
try:
|
|
assert await asyncio.to_thread(started.wait, 5)
|
|
for _ in range(3):
|
|
first.cancel()
|
|
await asyncio.sleep(0)
|
|
second = asyncio.create_task(SaveStep(app_context=context)(path="memory.md", content="successful write"))
|
|
await asyncio.sleep(0)
|
|
assert not first.done()
|
|
assert not second.done()
|
|
finally:
|
|
release.set()
|
|
results = await asyncio.gather(first, *([second] if second is not None else []), return_exceptions=True)
|
|
|
|
assert isinstance(results[0], asyncio.CancelledError)
|
|
assert results[1].success
|
|
assert target.read_text(encoding="utf-8") == "successful write"
|
|
assert list(tmp_path.iterdir()) == [target]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions")
|
|
async def test_save_rejects_read_only_target_without_replacing_it(tmp_path):
|
|
"""A writable parent must not let saving bypass the target's access rights."""
|
|
if os.geteuid() == 0:
|
|
pytest.skip("Root bypasses ordinary file write permissions")
|
|
target = tmp_path / "readonly.md"
|
|
target.write_bytes(b"original")
|
|
target.chmod(0o444)
|
|
try:
|
|
response = await SaveStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(
|
|
path=target.name,
|
|
content="replacement",
|
|
)
|
|
assert response.success is False
|
|
assert target.read_bytes() == b"original"
|
|
assert stat.S_IMODE(target.stat().st_mode) == 0o444
|
|
assert list(tmp_path.iterdir()) == [target]
|
|
finally:
|
|
target.chmod(0o644)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions")
|
|
async def test_atomic_write_does_not_require_target_read_access(tmp_path):
|
|
"""Permission checks retain the existing write-only file contract."""
|
|
target = tmp_path / "writeonly.md"
|
|
target.write_bytes(b"original")
|
|
target.chmod(0o200)
|
|
try:
|
|
await write_file_safe(target, b"replacement")
|
|
assert stat.S_IMODE(target.stat().st_mode) == 0o200
|
|
finally:
|
|
target.chmod(0o600)
|
|
assert target.read_bytes() == b"replacement"
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "posix", reason="POSIX umask semantics")
|
|
@pytest.mark.parametrize("mask, expected", [(0o002, 0o664), (0o077, 0o600)])
|
|
def test_new_save_respects_process_umask(tmp_path, mask, expected):
|
|
"""Check real creation modes without changing the test process's umask."""
|
|
script = """
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import sys
|
|
from reme.components.application_context import ApplicationContext
|
|
from reme.steps.file_io.save import SaveStep
|
|
|
|
workspace = Path(sys.argv[1])
|
|
os.umask(int(sys.argv[2]))
|
|
response = asyncio.run(SaveStep(app_context=ApplicationContext(workspace_dir=str(workspace)))(
|
|
path="new.md", content="memory"
|
|
))
|
|
assert response.success, response.answer
|
|
target = workspace / "new.md"
|
|
assert target.read_text() == "memory"
|
|
assert list(workspace.iterdir()) == [target]
|
|
print(stat.S_IMODE(target.stat().st_mode))
|
|
"""
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", script, str(tmp_path), str(mask)],
|
|
cwd=Path(__file__).resolve().parents[2],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
timeout=15,
|
|
)
|
|
assert int(result.stdout.splitlines()[-1]) == expected
|