c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
909 lines
34 KiB
Python
909 lines
34 KiB
Python
import os
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from _chroma_palace_helper import make_minimal_chroma_sqlite
|
|
|
|
from mempalace import daemon
|
|
from mempalace import service
|
|
|
|
# POSIX file-mode bits (0600/0700) are not representable on Windows: os.chmod
|
|
# can only toggle the read-only attribute, so a "private" file still reports
|
|
# 0o666. The daemon relies on the user-profile directory ACLs for privacy
|
|
# there, so the owner-only assertions only make sense on POSIX.
|
|
_posix_only_perms = pytest.mark.skipif(
|
|
os.name == "nt",
|
|
reason="POSIX 0600/0700 file-mode bits are not representable on Windows (ACL-based privacy)",
|
|
)
|
|
|
|
# Env keys run_server mutates from its background thread, plus umask. If a
|
|
# lifecycle test times out before the server comes up, run_server's finally
|
|
# never runs and those mutations leak into the rest of the suite — every later
|
|
# test that reads MempalaceConfig().palace_path sees a stale deleted tmp path and
|
|
# fails (the 60+ test cascade seen on slow CI runners). The fixtures below force a
|
|
# clean baseline around every daemon test so a leaked thread can't poison the
|
|
# process for tests/test_mcp_server.py and friends (which have no such guard).
|
|
_LEAK_ENV_KEYS = ("MEMPALACE_PALACE_PATH", "MEMPALACE_BACKEND", "MEMPALACE_BACKEND_EXPLICIT")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def _clean_env_snapshot():
|
|
"""Capture the true pre-suite values once, before any daemon test runs."""
|
|
return {key: os.environ.get(key) for key in _LEAK_ENV_KEYS}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_process_global_state(_clean_env_snapshot):
|
|
"""Restore the process-global env + umask to the pre-suite baseline after every
|
|
daemon test, even if a leaked run_server thread is still holding them mutated.
|
|
"""
|
|
prev_umask = os.umask(0o022)
|
|
os.umask(prev_umask) # read current umask without changing it
|
|
yield
|
|
for key, value in _clean_env_snapshot.items():
|
|
if value is None:
|
|
os.environ.pop(key, None)
|
|
else:
|
|
os.environ[key] = value
|
|
os.umask(prev_umask)
|
|
|
|
|
|
def _raise_not_ready(*a, **kw):
|
|
"""Stand-in for DaemonClient when the spawned daemon must never come up."""
|
|
raise daemon.DaemonError("not ready")
|
|
|
|
|
|
def test_prune_terminal_drops_old_terminal_jobs_keeps_active(tmp_path, monkeypatch):
|
|
"""Terminal jobs older than the retention window are pruned; queued/running
|
|
and fresh terminal jobs are untouched. Bounded queue growth for the DB that
|
|
holds verbatim payloads."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
|
|
old_term = store.enqueue("mine", {"source": "old"})
|
|
store.finish(old_term.id, state="succeeded", result={"success": True})
|
|
fresh_term = store.enqueue("mine", {"source": "fresh"})
|
|
store.finish(fresh_term.id, state="succeeded", result={"success": True})
|
|
queued = store.enqueue("mine", {"source": "queued"})
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
|
|
with store._lock, store._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE jobs SET finished_at = ? WHERE id = ?",
|
|
(cutoff, old_term.id),
|
|
)
|
|
|
|
pruned = store.prune_terminal(older_than_days=7)
|
|
assert pruned == 1
|
|
# The old terminal job is gone; the fresh terminal and queued jobs survive.
|
|
with pytest.raises(daemon.DaemonError):
|
|
store.get(old_term.id)
|
|
assert store.get(fresh_term.id).state == "succeeded"
|
|
assert store.get(queued.id).state == "queued"
|
|
|
|
|
|
def test_queue_dedupes_and_recovers_running_jobs(tmp_path, monkeypatch):
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
first = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
|
|
second = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
|
|
|
|
assert second.id == first.id
|
|
|
|
claimed = store.claim_next()
|
|
assert claimed.id == first.id
|
|
assert claimed.state == "running"
|
|
|
|
recovered = store.recover_running()
|
|
assert recovered == 1
|
|
assert store.get(first.id).state == "queued"
|
|
|
|
|
|
def test_daemon_http_lifecycle_executes_job(tmp_path, monkeypatch):
|
|
calls = []
|
|
|
|
def fake_execute(kind, payload):
|
|
calls.append((kind, payload))
|
|
return {"success": True, "exit_code": 0, "stdout": "done\n"}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
|
|
health = client.health()
|
|
assert health["ok"] is True
|
|
assert health["palace_path"] == daemon.canonical_palace_path(str(palace))
|
|
|
|
job = client.submit("mine", {"source": "src"}, dedupe_key="job")
|
|
finished = client.wait(job["id"], timeout=5)
|
|
|
|
assert finished["state"] == "succeeded"
|
|
assert finished["result"]["stdout"] == "done\n"
|
|
assert calls == [("mine", {"source": "src", "palace_path": str(palace.resolve())})]
|
|
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_submit_job_uses_client_and_waits(monkeypatch, tmp_path):
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
|
|
class DummyClient:
|
|
def __init__(self):
|
|
self.submitted = None
|
|
|
|
def submit(self, kind, payload, dedupe_key=None, priority=0):
|
|
self.submitted = (kind, payload, dedupe_key, priority)
|
|
return {"id": "job-1", "state": "queued"}
|
|
|
|
def wait(self, job_id, timeout=daemon.DEFAULT_WAIT_TIMEOUT):
|
|
assert job_id == "job-1"
|
|
return {
|
|
"id": "job-1",
|
|
"state": "succeeded",
|
|
"result": {"success": True, "exit_code": 0},
|
|
}
|
|
|
|
dummy = DummyClient()
|
|
monkeypatch.setattr(daemon, "ensure_client", lambda *a, **kw: dummy)
|
|
|
|
job = daemon.submit_job(
|
|
"mine",
|
|
{"source": "src"},
|
|
palace_path=str(palace),
|
|
dedupe_key="dedupe",
|
|
wait=True,
|
|
)
|
|
|
|
assert job["state"] == "succeeded"
|
|
assert dummy.submitted[0] == "mine"
|
|
# palace_path is overridden (not trusted from the payload), never appended.
|
|
assert dummy.submitted[1]["palace_path"] == daemon.canonical_palace_path(str(palace))
|
|
assert dummy.submitted[2] == "dedupe"
|
|
|
|
|
|
def test_service_tool_classification():
|
|
assert service.classify_tool("mempalace_search") == "read"
|
|
assert service.classify_tool("mempalace_add_drawer") == "write"
|
|
assert service.classify_tool("mempalace_checkpoint") == "write"
|
|
assert service.classify_tool("mempalace_delete_by_source") == "write"
|
|
assert service.classify_tool("mempalace_mine") == "maintenance"
|
|
assert service.classify_tool("unknown") == "unknown"
|
|
|
|
|
|
# --- helpers for HTTP-lifecycle tests ---
|
|
|
|
|
|
def _capture_httpd(monkeypatch):
|
|
"""Capture the httpd instance run_server creates.
|
|
|
|
run_server defines a local ``class _Server(ThreadingHTTPServer)``; by
|
|
monkeypatching ``daemon.ThreadingHTTPServer`` before run_server runs, that
|
|
subclass inherits from a capturing base that records each instance. The
|
|
httpd can then be force-stopped from the test thread (see _stop_server) so a
|
|
slow/failed ``client.shutdown()`` POST can never leave the server thread
|
|
alive — which on Windows hangs the interpreter at process exit on an open
|
|
listening socket.
|
|
"""
|
|
holders: list = []
|
|
base = daemon.ThreadingHTTPServer
|
|
|
|
class _CapturingServer(base):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
holders.append(self)
|
|
|
|
monkeypatch.setattr(daemon, "ThreadingHTTPServer", _CapturingServer)
|
|
return holders
|
|
|
|
|
|
def _stop_server(client, thread, holders, *, join_timeout=5.0):
|
|
"""Shut the daemon down deterministically and assert the thread died.
|
|
|
|
First try the normal path (POST /shutdown). If the server thread is still
|
|
alive afterwards — the POST was slow, lost, or the drain overran the join —
|
|
call httpd.shutdown() directly from this thread (stdlib-safe: it is a
|
|
different thread than serve_forever) to force serve_forever to return, then
|
|
re-join. The assert turns a leak into a visible failure instead of a silent
|
|
interpreter-exit hang.
|
|
"""
|
|
try:
|
|
client.shutdown()
|
|
except Exception: # noqa: BLE001 - best-effort; we force-shutdown below
|
|
pass
|
|
thread.join(timeout=join_timeout)
|
|
if thread.is_alive() and holders:
|
|
httpd = holders[-1]
|
|
try:
|
|
httpd.shutdown()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
try:
|
|
httpd.server_close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
thread.join(timeout=join_timeout)
|
|
assert not thread.is_alive(), "daemon server thread did not shut down"
|
|
|
|
|
|
def _start_server(tmp_path, monkeypatch, execute_fn):
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
monkeypatch.setattr(service, "execute_job", execute_fn)
|
|
holders = _capture_httpd(monkeypatch)
|
|
|
|
# Capture any exception run_server raises in its thread. Without this a
|
|
# startup crash is invisible: the poll below would just spin for 30s and
|
|
# fail with a bare ``assert client is not None`` giving no cause.
|
|
server_error: list = []
|
|
|
|
def _serve():
|
|
try:
|
|
daemon.run_server(palace_path=str(palace), port=0)
|
|
except BaseException as exc: # noqa: BLE001 - re-surfaced to the test thread
|
|
server_error.append(exc)
|
|
|
|
thread = threading.Thread(target=_serve, name="test-daemon-server", daemon=True)
|
|
thread.start()
|
|
client = None
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
if server_error:
|
|
raise AssertionError(f"run_server crashed during startup: {server_error[0]!r}")
|
|
client = daemon.get_client_if_running(str(palace))
|
|
if client is not None:
|
|
break
|
|
time.sleep(0.05)
|
|
if client is None:
|
|
raise AssertionError(
|
|
"daemon did not become ready within 30s "
|
|
f"(thread_alive={thread.is_alive()}, httpd_bound={bool(holders)}, "
|
|
f"endpoint_exists={daemon.endpoint_path(str(palace)).exists()})"
|
|
)
|
|
return client, thread, palace, holders
|
|
|
|
|
|
# --- ship-blocker regressions ---
|
|
|
|
|
|
def test_systemexit_in_job_does_not_kill_worker(tmp_path, monkeypatch):
|
|
"""A SystemExit (BaseException, not Exception) must be caught, the job
|
|
marked failed, and the worker kept alive for the next job. Regression for
|
|
the critical worker-death bug."""
|
|
state = {"first": True}
|
|
|
|
def fake_execute(kind, payload):
|
|
if state["first"]:
|
|
state["first"] = False
|
|
raise SystemExit("boom")
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
first = client.submit("mine", {"source": "src"})
|
|
finished_first = client.wait(first["id"], timeout=5)
|
|
assert finished_first["state"] == "failed"
|
|
assert finished_first["error"]["error_class"] == "SystemExit"
|
|
|
|
# Worker must still be alive — health reports it and a second job runs.
|
|
assert client.health()["worker_alive"] is True
|
|
second = client.submit("mine", {"source": "src2"})
|
|
finished_second = client.wait(second["id"], timeout=5)
|
|
assert finished_second["state"] == "succeeded"
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_shutdown_cancels_active_job(tmp_path, monkeypatch):
|
|
"""POST /shutdown must not leave an in-flight job 'running' for blind
|
|
re-queue on next start. The worker is drained (bounded), then the active
|
|
job is marked 'cancelled' so recover_running won't re-run it.
|
|
|
|
In production the serve process exits immediately after run_server returns,
|
|
killing the daemon worker thread before it can overwrite the cancelled
|
|
state. The test mirrors that by asserting the cancelled state *before*
|
|
releasing the blocked worker.
|
|
"""
|
|
block = threading.Event()
|
|
|
|
def fake_execute(kind, payload):
|
|
# Simulate a long-running job that never finishes on its own.
|
|
block.wait(30)
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
monkeypatch.setattr(daemon, "SHUTDOWN_DRAIN_SECONDS", 0.2)
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
job = client.submit("mine", {"source": "src"}, dedupe_key="x")
|
|
# Wait until the worker has claimed it (state flips to running).
|
|
deadline = time.monotonic() + 5
|
|
while time.monotonic() < deadline:
|
|
if client.get_job(job["id"])["state"] == "running":
|
|
break
|
|
time.sleep(0.02)
|
|
assert client.get_job(job["id"])["state"] == "running"
|
|
|
|
_stop_server(client, thread, holders)
|
|
|
|
# The interrupted job must be cancelled (terminal), not left running.
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
final = store.get(job["id"])
|
|
assert final.state == "cancelled"
|
|
# And recover_running must not re-queue a cancelled job.
|
|
assert store.recover_running() == 0
|
|
|
|
# Release the blocked worker so it (and the daemon thread) can exit.
|
|
block.set()
|
|
|
|
|
|
def test_recover_running_dead_letters_exhausted_jobs(tmp_path, monkeypatch):
|
|
"""A job that has crashed MAX_ATTEMPTS times must be dead-lettered to
|
|
'failed', not re-queued — non-idempotent kinds (diary_write) would
|
|
otherwise duplicate verbatim content on every restart."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("diary_write", {"entry": "x"})
|
|
# Simulate MAX_ATTEMPTS claims that each crashed (running, attempts=MAX).
|
|
with store._lock, store._connect() as conn:
|
|
conn.execute(
|
|
"UPDATE jobs SET state='running', attempts=? WHERE id=?",
|
|
(daemon.MAX_ATTEMPTS, job.id),
|
|
)
|
|
|
|
recovered = store.recover_running()
|
|
assert recovered == 0 # not re-queued
|
|
final = store.get(job.id)
|
|
assert final.state == "failed"
|
|
assert final.attempts == daemon.MAX_ATTEMPTS
|
|
|
|
|
|
def test_claim_next_does_not_reclaim_running_job(tmp_path, monkeypatch):
|
|
"""The conditional UPDATE (WHERE state='queued') means a job already
|
|
flipped to 'running' cannot be claimed again — the cross-process
|
|
double-execution guard."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
job = store.enqueue("mine", {"source": "src"})
|
|
first = store.claim_next()
|
|
assert first.id == job.id
|
|
# Manually re-mark it queued but leave a second claim attempt: claim_next
|
|
# should still only ever return one running job per claim. After finishing
|
|
# the first, the next claim returns None (queue empty).
|
|
store.finish(first.id, state="succeeded", result={"success": True})
|
|
assert store.claim_next() is None
|
|
|
|
|
|
@_posix_only_perms
|
|
def test_queue_db_file_is_owner_only(tmp_path, monkeypatch):
|
|
"""The queue DB holds verbatim payloads — it must be 0600, not the sqlite
|
|
default 0644. Regression for the privacy-principle violation."""
|
|
import os as _os
|
|
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
store = daemon.QueueStore(daemon.queue_path(str(palace)))
|
|
store.enqueue("diary_write", {"entry": "secret verbatim content"})
|
|
mode = _os.stat(str(store.path)).st_mode & 0o777
|
|
assert mode == 0o600, f"queue.sqlite3 is {oct(mode)}, expected 0600"
|
|
|
|
|
|
@_posix_only_perms
|
|
def test_token_file_is_owner_only(tmp_path, monkeypatch):
|
|
import os as _os
|
|
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
daemon.ensure_token(str(palace))
|
|
token_path = daemon.state_dir(str(palace)) / "token"
|
|
assert (_os.stat(str(token_path)).st_mode & 0o777) == 0o600
|
|
|
|
|
|
def test_health_rejects_missing_and_wrong_token(tmp_path, monkeypatch):
|
|
from urllib import error as urlerror
|
|
from urllib import request as urlrequest
|
|
|
|
client, thread, palace, holders = _start_server(
|
|
tmp_path, monkeypatch, lambda k, p: {"success": True}
|
|
)
|
|
try:
|
|
base = f"http://127.0.0.1:{client.port}"
|
|
# No Authorization header → 401.
|
|
with pytest.raises(urlerror.HTTPError):
|
|
urlrequest.urlopen(urlrequest.Request(base + "/health"), timeout=3)
|
|
# Wrong token → 401.
|
|
with pytest.raises(urlerror.HTTPError):
|
|
urlrequest.urlopen(
|
|
urlrequest.Request(base + "/health", headers={"Authorization": "Bearer wrong"}),
|
|
timeout=3,
|
|
)
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_worker_overrides_client_palace_path(tmp_path, monkeypatch):
|
|
"""An authenticated client must not be able to retarget the daemon at a
|
|
different palace by stuffing palace_path into the payload."""
|
|
seen = {}
|
|
|
|
def fake_execute(kind, payload):
|
|
seen["palace_path"] = payload.get("palace_path")
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
|
|
try:
|
|
job = client.submit(
|
|
"mine", {"source": "src", "palace_path": "/tmp/other-palace"}, dedupe_key="p"
|
|
)
|
|
client.wait(job["id"], timeout=5)
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
assert seen["palace_path"] == daemon.canonical_palace_path(str(palace))
|
|
assert seen["palace_path"] != "/tmp/other-palace"
|
|
|
|
|
|
def test_mcp_tool_allowlist_rejects_non_write_tools(tmp_path, monkeypatch):
|
|
"""The daemon queue is a durable write surface; read/maintenance/unknown
|
|
tools must be rejected so verbatim content can't be exfiltrated into the
|
|
queue or retried destructively."""
|
|
# read tool → rejected
|
|
out = service.run_mcp_tool({"name": "mempalace_search", "arguments": {}})
|
|
assert out["success"] is False
|
|
assert "only accepts write tools" in out["error"]
|
|
# maintenance tool → rejected (has its own kinds: mine/sync)
|
|
out = service.run_mcp_tool({"name": "mempalace_mine", "arguments": {}})
|
|
assert out["success"] is False
|
|
# unknown tool → rejected
|
|
out = service.run_mcp_tool({"name": "mempalace_bogus", "arguments": {}})
|
|
assert out["success"] is False
|
|
# write tool → passes the allowlist (handler not called here since TOOLS
|
|
# won't have it under the test name; but classification must let it through)
|
|
assert service.classify_tool("mempalace_add_drawer") == "write"
|
|
|
|
|
|
def test_execute_job_isolates_env_per_job(monkeypatch):
|
|
"""A job that mutates MEMPALACE_BACKEND must not leak into the next job's
|
|
env. Regression for the per-job isolation bug (_apply_backend poisoning)."""
|
|
import os as _os
|
|
|
|
monkeypatch.delenv("MEMPALACE_BACKEND", raising=False)
|
|
monkeypatch.delenv("MEMPALACE_PALACE_PATH", raising=False)
|
|
|
|
def fake_mine(payload):
|
|
_os.environ["MEMPALACE_BACKEND"] = "leaked-backend"
|
|
return {"success": True, "exit_code": 0}
|
|
|
|
monkeypatch.setattr(service, "run_mine", fake_mine)
|
|
service.execute_job("mine", {"palace_path": "/tmp/p", "source": "s"})
|
|
assert _os.environ.get("MEMPALACE_BACKEND") is None
|
|
|
|
|
|
def test_daemon_client_raises_on_endpoint_missing_port(tmp_path, monkeypatch):
|
|
"""A malformed endpoint.json must raise DaemonError, not a bare KeyError."""
|
|
import json as _json
|
|
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
daemon.ensure_token(str(palace))
|
|
# endpoint with no port
|
|
daemon.state_dir(str(palace)).mkdir(parents=True, exist_ok=True)
|
|
(daemon.state_dir(str(palace)) / "endpoint.json").write_text(
|
|
_json.dumps({"host": "127.0.0.1", "pid": 1}) + "\n", encoding="utf-8"
|
|
)
|
|
|
|
with pytest.raises(daemon.DaemonError):
|
|
daemon.DaemonClient(str(palace))
|
|
|
|
|
|
def test_pid_alive_probe_is_signal_free_and_correct():
|
|
"""``_pid_alive`` must be a pure liveness probe.
|
|
|
|
On Windows ``os.kill(pid, 0)`` is NOT harmless — signal 0 is
|
|
``CTRL_C_EVENT``, so it emits a console Ctrl-C to the target's process
|
|
group. The daemon client polls a same-process endpoint, so that Ctrl-C was
|
|
delivered back to the interpreter and surfaced as a spurious
|
|
``KeyboardInterrupt`` that hung the whole test session on CI runners (which,
|
|
unlike a detached dev shell, have an attached console). Assert the probe is
|
|
both correct and emits no SIGINT even when hammered like the poll loop.
|
|
"""
|
|
import signal
|
|
|
|
assert daemon._pid_alive(os.getpid()) is True
|
|
assert daemon._pid_alive(0) is False
|
|
assert daemon._pid_alive(-1) is False
|
|
# A pid that is almost certainly not running.
|
|
assert daemon._pid_alive(2_000_000_000) is False
|
|
|
|
# pytest runs tests on the main thread, so installing a SIGINT handler is
|
|
# allowed. If the probe regresses to os.kill(pid, 0) on Windows, the repeated
|
|
# calls below deliver CTRL_C_EVENT and this handler fires.
|
|
fired = []
|
|
previous = signal.getsignal(signal.SIGINT)
|
|
signal.signal(signal.SIGINT, lambda *_: fired.append(1))
|
|
try:
|
|
for _ in range(25):
|
|
daemon._pid_alive(os.getpid())
|
|
time.sleep(0.25)
|
|
finally:
|
|
signal.signal(signal.SIGINT, previous)
|
|
assert fired == [], "_pid_alive delivered a console control event (CTRL_C_EVENT)"
|
|
|
|
|
|
def test_start_daemon_kills_orphan_on_readiness_timeout(tmp_path, monkeypatch):
|
|
"""If the spawned daemon never becomes ready, start_daemon must kill and
|
|
reap the orphaned subprocess rather than leaking it with the port/token."""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
daemon.ensure_token(str(palace))
|
|
|
|
monkeypatch.setattr(daemon, "get_client_if_running", lambda *a, **kw: None)
|
|
|
|
class FakeProc:
|
|
def __init__(self):
|
|
self.killed = False
|
|
self.returncode = None
|
|
|
|
def poll(self):
|
|
return self.returncode # None == still alive
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
def wait(self):
|
|
self.returncode = -9
|
|
return self.returncode
|
|
|
|
fake = FakeProc()
|
|
|
|
def fake_popen(*a, **kw):
|
|
return fake
|
|
|
|
monkeypatch.setattr(daemon.subprocess, "Popen", fake_popen)
|
|
monkeypatch.setattr(daemon, "DaemonClient", _raise_not_ready)
|
|
monkeypatch.setattr(daemon.time, "sleep", lambda *a, **kw: None)
|
|
|
|
with pytest.raises(daemon.DaemonError):
|
|
daemon.start_daemon(str(palace), timeout=0.05)
|
|
assert fake.killed is True
|
|
|
|
|
|
# --- service.run_* happy-path coverage ---
|
|
# These close the draft PR's follow-up ("Add focused happy-path tests for
|
|
# service.run_mine / run_diary_write / run_mcp_tool") and, now that the daemon
|
|
# tests complete reliably, keep service.py's coverage above the CI gate. The
|
|
# capsys-using tests come first; the two that import mempalace.mcp_server (which
|
|
# rebinds sys.stdout) come last and do not use capsys, so the rebind can't break
|
|
# capture in this file or later files (capsys activates after the rebind).
|
|
|
|
|
|
def test_print_job_result_replays_stdout_stderr_and_returns_exit_code(capsys):
|
|
from mempalace import service
|
|
|
|
code = service.print_job_result(
|
|
{"success": False, "error": "boom", "stdout": "out\n", "stderr": "err\n", "exit_code": 3}
|
|
)
|
|
assert code == 3
|
|
captured = capsys.readouterr()
|
|
assert "out" in captured.out
|
|
assert "err" in captured.err
|
|
|
|
|
|
def test_print_job_result_prints_error_to_stderr_when_no_stderr(capsys):
|
|
from mempalace import service
|
|
|
|
code = service.print_job_result({"success": False, "error": "boom", "exit_code": 1})
|
|
assert code == 1
|
|
captured = capsys.readouterr()
|
|
assert "mempalace: boom" in captured.err
|
|
|
|
|
|
def test_run_sync_returns_success_when_palace_dir_missing(tmp_path):
|
|
from mempalace import service
|
|
|
|
result = service.run_sync({"palace_path": str(tmp_path / "nope"), "dry_run": True})
|
|
assert result["success"] is True
|
|
assert result["exit_code"] == 0
|
|
|
|
|
|
def test_run_sync_returns_success_when_palace_has_no_backend_artifact(tmp_path):
|
|
from mempalace import service
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
result = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
|
assert result["success"] is True
|
|
assert result["exit_code"] == 0
|
|
|
|
|
|
def test_run_mine_invalid_mode_returns_structured_error(tmp_path):
|
|
from mempalace import service
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
out = service.run_mine({"palace_path": str(palace), "mode": "bogus"})
|
|
assert out["success"] is False
|
|
assert "invalid mine mode" in out["error"]
|
|
assert out["exit_code"] == 2
|
|
|
|
|
|
def test_run_mcp_tool_rejects_non_dict_arguments():
|
|
from mempalace import service
|
|
|
|
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": "nope"})
|
|
assert out["success"] is False
|
|
assert "must be an object" in out["error"]
|
|
assert out["exit_code"] == 2
|
|
|
|
|
|
def test_run_mcp_tool_dispatches_write_tool(monkeypatch):
|
|
import mempalace.mcp_server as mcp
|
|
from mempalace import service
|
|
|
|
captured = {}
|
|
|
|
def fake_handler(**arguments):
|
|
captured["arguments"] = arguments
|
|
return {"success": True, "written": True}
|
|
|
|
monkeypatch.setattr(mcp, "TOOLS", {"mempalace_add_drawer": {"handler": fake_handler}})
|
|
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": {"x": 1}})
|
|
assert out["success"] is True
|
|
assert out["written"] is True
|
|
assert out["exit_code"] == 0
|
|
assert captured["arguments"] == {"x": 1}
|
|
|
|
|
|
def test_run_diary_write_forwards_args_and_sets_exit_code(monkeypatch):
|
|
import mempalace.mcp_server as mcp
|
|
from mempalace import service
|
|
|
|
captured = {}
|
|
|
|
def fake_diary(agent_name, entry, topic, wing):
|
|
captured.update(agent_name=agent_name, entry=entry, topic=topic, wing=wing)
|
|
return {"success": True}
|
|
|
|
monkeypatch.setattr(mcp, "tool_diary_write", fake_diary)
|
|
out = service.run_diary_write(
|
|
{"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
|
|
)
|
|
assert out["success"] is True
|
|
assert out["exit_code"] == 0
|
|
assert captured == {"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
|
|
|
|
|
|
def test_run_mine_applies_backend_before_mode_validation(tmp_path):
|
|
"""Covers _apply_backend (env set + get_backend_class validation) on the daemon
|
|
path; the invalid mode short-circuits before any mining runs."""
|
|
from mempalace import service
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
out = service.run_mine({"palace_path": str(palace), "mode": "bogus", "backend": "chroma"})
|
|
assert out["success"] is False
|
|
assert out["exit_code"] == 2
|
|
|
|
|
|
def test_execute_job_dispatches_diary_write_mcp_tool_and_unknown(monkeypatch):
|
|
"""Covers execute_job's kind dispatch for diary_write, mcp_tool, and the
|
|
unknown-kind fallback."""
|
|
import mempalace.mcp_server as mcp
|
|
from mempalace import service
|
|
|
|
monkeypatch.setattr(mcp, "tool_diary_write", lambda **kw: {"success": True})
|
|
monkeypatch.setattr(
|
|
mcp, "TOOLS", {"mempalace_add_drawer": {"handler": lambda **kw: {"success": True}}}
|
|
)
|
|
assert service.execute_job("diary_write", {"entry": "x"})["success"] is True
|
|
assert (
|
|
service.execute_job("mcp_tool", {"name": "mempalace_add_drawer", "arguments": {}})[
|
|
"success"
|
|
]
|
|
is True
|
|
)
|
|
unknown = service.execute_job("bogus_kind", {})
|
|
assert unknown["success"] is False
|
|
assert unknown["exit_code"] == 2
|
|
|
|
|
|
def test_run_sync_structured_errors_on_sync_failures(tmp_path, monkeypatch):
|
|
"""Covers run_sync's three exception handlers (MineAlreadyRunning, ValueError,
|
|
generic Exception) so a failing sync_palace returns a structured error instead
|
|
of propagating."""
|
|
import mempalace.sync as sync_module
|
|
from mempalace import service
|
|
from mempalace.palace import MineAlreadyRunning
|
|
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
make_minimal_chroma_sqlite(palace)
|
|
|
|
def _raise(exc):
|
|
def fn(**kw):
|
|
raise exc
|
|
|
|
return fn
|
|
|
|
monkeypatch.setattr(sync_module, "sync_palace", _raise(MineAlreadyRunning("locked")))
|
|
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
|
assert r["success"] is False
|
|
assert r["error_class"] == "LockHeldByOtherProcess"
|
|
|
|
monkeypatch.setattr(sync_module, "sync_palace", _raise(ValueError("bad scope")))
|
|
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
|
assert r["success"] is False
|
|
assert r["exit_code"] == 2
|
|
|
|
monkeypatch.setattr(sync_module, "sync_palace", _raise(RuntimeError("boom")))
|
|
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
|
|
assert r["success"] is False
|
|
assert "sync failed" in r["error"]
|
|
|
|
|
|
# --- post-merge review follow-ups (Copilot review on #1826) ---
|
|
|
|
|
|
@_posix_only_perms
|
|
def test_run_server_tightens_umask_before_building_queue(tmp_path, monkeypatch):
|
|
"""The owner-only umask must be active BEFORE the queue DB is built.
|
|
|
|
SQLite's WAL/SHM sidecars hold un-checkpointed verbatim payloads and are
|
|
created with the process umask, so a loose umask at DaemonRuntime/QueueStore
|
|
construction time would leave them world-readable. Capture the umask at the
|
|
moment DaemonRuntime is constructed and assert it is already 0o077.
|
|
"""
|
|
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
|
|
palace = tmp_path / "palace"
|
|
palace.mkdir()
|
|
|
|
captured = {}
|
|
|
|
def _spy_runtime(*args, **kwargs):
|
|
current = os.umask(0o022)
|
|
os.umask(current) # restore without changing
|
|
captured["umask"] = current
|
|
raise RuntimeError("stop before binding a real socket")
|
|
|
|
monkeypatch.setattr(daemon, "DaemonRuntime", _spy_runtime)
|
|
with pytest.raises(RuntimeError):
|
|
daemon.run_server(str(palace), port=0)
|
|
assert captured["umask"] == 0o077
|
|
|
|
|
|
def test_negative_content_length_is_rejected_without_blocking(tmp_path, monkeypatch):
|
|
"""A POST with Content-Length: -1 must get a prompt 400, not hang the worker.
|
|
|
|
rfile.read(-1) would read until the client closes the socket (an auth-gated
|
|
DoS) and bypass the MAX_BODY_BYTES cap. The recv timeout below turns a
|
|
regression into a failure instead of a hang.
|
|
"""
|
|
import socket
|
|
|
|
client, thread, palace, holders = _start_server(
|
|
tmp_path, monkeypatch, lambda k, p: {"success": True}
|
|
)
|
|
try:
|
|
sock = socket.create_connection((client.host, client.port), timeout=5)
|
|
sock.settimeout(5)
|
|
request = (
|
|
"POST /jobs HTTP/1.1\r\n"
|
|
"Host: daemon\r\n"
|
|
f"Authorization: Bearer {client.token}\r\n"
|
|
"Content-Length: -1\r\n"
|
|
"Connection: close\r\n\r\n"
|
|
)
|
|
sock.sendall(request.encode("ascii"))
|
|
status_line = sock.recv(4096).decode("latin-1").split("\r\n", 1)[0]
|
|
sock.close()
|
|
assert "400" in status_line, f"expected 400, got {status_line!r}"
|
|
finally:
|
|
_stop_server(client, thread, holders)
|
|
|
|
|
|
def test_run_mcp_tool_marks_bare_error_dict_as_failure(monkeypatch):
|
|
"""A write tool that returns {"error": ...} with no success flag must be
|
|
recorded as a failed job, not succeeded (Copilot review)."""
|
|
import mempalace.mcp_server as mcp
|
|
from mempalace import service
|
|
|
|
monkeypatch.setattr(
|
|
mcp,
|
|
"TOOLS",
|
|
{"mempalace_create_tunnel": {"handler": lambda **kw: {"error": "bad endpoint"}}},
|
|
)
|
|
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
|
|
assert out["success"] is False
|
|
assert out["exit_code"] == 1
|
|
assert out["error"] == "bad endpoint"
|
|
|
|
# A result with neither an explicit success flag nor an error is a success.
|
|
monkeypatch.setattr(
|
|
mcp, "TOOLS", {"mempalace_create_tunnel": {"handler": lambda **kw: {"tunnel_id": "t1"}}}
|
|
)
|
|
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
|
|
assert out["success"] is True
|
|
assert out["exit_code"] == 0
|
|
|
|
|
|
def test_get_client_if_running_uses_short_probe_timeout(monkeypatch):
|
|
"""The hook liveness precheck must pass a short health timeout so a wedged
|
|
daemon can't stall the hook past its budget (Copilot review)."""
|
|
captured = {}
|
|
|
|
class _FakeClient:
|
|
def __init__(self, palace_path):
|
|
pass
|
|
|
|
def health(self, *, timeout):
|
|
captured["timeout"] = timeout
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(daemon, "DaemonClient", _FakeClient)
|
|
|
|
assert daemon.HOOK_PROBE_TIMEOUT <= 0.5
|
|
client = daemon.get_client_if_running("/p", health_timeout=daemon.HOOK_PROBE_TIMEOUT)
|
|
assert client is not None
|
|
assert captured["timeout"] == daemon.HOOK_PROBE_TIMEOUT
|
|
|
|
|
|
# --- _detached_kwargs ---
|
|
|
|
|
|
def test_detached_kwargs_posix(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("mempalace.daemon.os.name", "posix")
|
|
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
|
|
fh = kwargs["stdout"]
|
|
try:
|
|
assert kwargs.get("start_new_session") is True
|
|
assert kwargs.get("stdin") is subprocess.DEVNULL
|
|
assert kwargs.get("close_fds") is True
|
|
assert "creationflags" not in kwargs
|
|
finally:
|
|
fh.close()
|
|
|
|
|
|
def test_detached_kwargs_windows(tmp_path, monkeypatch):
|
|
monkeypatch.setattr("mempalace.daemon.os.name", "nt")
|
|
monkeypatch.setattr("mempalace.daemon.subprocess.CREATE_NO_WINDOW", 0x08000000, raising=False)
|
|
monkeypatch.setattr("mempalace.daemon.subprocess.DETACHED_PROCESS", 0x00000008, raising=False)
|
|
monkeypatch.setattr(
|
|
"mempalace.daemon.subprocess.CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
"mempalace.daemon.subprocess.CREATE_BREAKAWAY_FROM_JOB", 0x01000000, raising=False
|
|
)
|
|
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
|
|
fh = kwargs["stdout"]
|
|
try:
|
|
flags = kwargs.get("creationflags", 0)
|
|
assert flags & 0x08000000, "CREATE_NO_WINDOW must be set"
|
|
assert not (flags & 0x00000008), (
|
|
"DETACHED_PROCESS must NOT be set (it suppresses CREATE_NO_WINDOW)"
|
|
)
|
|
assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP preserved (Ctrl-Break group isolation)"
|
|
assert flags & 0x01000000, (
|
|
"CREATE_BREAKAWAY_FROM_JOB preserved (survive parent Job-Object close)"
|
|
)
|
|
assert kwargs.get("stdin") is subprocess.DEVNULL
|
|
assert kwargs.get("close_fds") is True
|
|
# creationflags + start_new_session are mutually exclusive on Windows
|
|
# (Popen raises ValueError); the nt branch must not set the latter.
|
|
assert "start_new_session" not in kwargs
|
|
finally:
|
|
fh.close()
|