Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

1165 lines
45 KiB
Python

"""Long-lived local daemon for queued MemPalace writes.
Daemon mode is strictly opt-in. The default CLI, hooks, and MCP paths still use
their direct execution behavior unless callers explicitly request daemon-backed
execution.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import secrets
import sqlite3
import subprocess
import sys
import threading
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib import error as urlerror
from urllib import request as urlrequest
from urllib.parse import parse_qs, urlparse
from .config import MempalaceConfig
HOST = "127.0.0.1"
STATE_ROOT_ENV = "MEMPALACE_DAEMON_STATE_ROOT"
DEFAULT_WAIT_TIMEOUT = 60.0 * 60.0
# Liveness-probe timeout for the hook "is a daemon already running?" precheck.
# Kept well under the ~500ms hook budget so a wedged daemon can't stall the hook
# (it falls back to the direct/spawn path instead). A healthy local daemon
# answers /health in single-digit ms, so this rarely false-negatives.
HOOK_PROBE_TIMEOUT = 0.5
TERMINAL_STATES = {"succeeded", "failed", "cancelled"}
MAX_ATTEMPTS = 3
MAX_BODY_BYTES = 1 << 20 # 1 MiB cap on request bodies (auth-gated DoS guard)
SHUTDOWN_DRAIN_SECONDS = 10.0
# Terminal jobs are kept for diagnostics then pruned so the queue DB (which
# holds verbatim payloads) doesn't grow without bound across a long-lived
# daemon. Override via env for operators who want a longer/shorter window.
JOB_RETENTION_DAYS = int(os.environ.get("MEMPALACE_DAEMON_RETENTION_DAYS", "7") or "7")
try:
import fcntl as _fcntl # POSIX only; absent on Windows
except ImportError: # pragma: no cover - Windows fallback
_fcntl = None
def _chmod_private(path: Path) -> None:
try:
os.chmod(str(path), 0o600)
except OSError:
pass
def _chmod_dir_private(path: Path) -> None:
try:
os.chmod(str(path), 0o700)
except OSError:
pass
class DaemonError(RuntimeError):
"""Raised when daemon client operations fail."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def canonical_palace_path(path: str | None = None) -> str:
value = path or MempalaceConfig().palace_path
return os.path.abspath(os.path.realpath(os.path.expanduser(value)))
def palace_key(palace_path: str) -> str:
import hashlib
normalized = os.path.normcase(canonical_palace_path(palace_path))
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:24]
def state_root() -> Path:
raw = os.environ.get(STATE_ROOT_ENV)
if raw:
return Path(raw).expanduser()
return Path.home() / ".mempalace" / "daemon"
def state_dir(palace_path: str) -> Path:
return state_root() / palace_key(palace_path)
def _write_private(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
def ensure_token(palace_path: str) -> str:
token_path = state_dir(palace_path) / "token"
if token_path.exists():
token = token_path.read_text(encoding="utf-8").strip()
if token:
return token
token = secrets.token_urlsafe(32)
_write_private(token_path, token + "\n")
return token
def read_token(palace_path: str) -> str:
token_path = state_dir(palace_path) / "token"
try:
return token_path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise DaemonError(f"daemon token not found for {palace_path}") from exc
def endpoint_path(palace_path: str) -> Path:
return state_dir(palace_path) / "endpoint.json"
def pid_path(palace_path: str) -> Path:
return state_dir(palace_path) / "pid"
def queue_path(palace_path: str) -> Path:
return state_dir(palace_path) / "queue.sqlite3"
def _read_endpoint(palace_path: str) -> dict[str, Any]:
try:
with open(endpoint_path(palace_path), encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
raise DaemonError("daemon endpoint not found") from exc
def _pid_alive_windows(pid: int) -> bool:
"""Liveness probe for Windows that never sends a console control event.
``os.kill(pid, 0)`` is NOT a harmless existence check on Windows: signal 0
is ``signal.CTRL_C_EVENT``, so Python routes it to
``GenerateConsoleCtrlEvent`` and sends a Ctrl-C to the target's process
group instead of probing the pid. On a process with an attached console
(e.g. a CI runner) that Ctrl-C is delivered back to *this* interpreter and
surfaces as a spurious ``KeyboardInterrupt`` — exactly the hang seen when
``DaemonClient`` polled a same-process endpoint. Probe via the Win32 process
handle API instead, which has no signalling side effects.
"""
import ctypes
from ctypes import wintypes
SYNCHRONIZE = 0x00100000
WAIT_TIMEOUT = 0x00000102
ERROR_ACCESS_DENIED = 5
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.OpenProcess.restype = wintypes.HANDLE
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
kernel32.WaitForSingleObject.restype = wintypes.DWORD
kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
handle = kernel32.OpenProcess(SYNCHRONIZE, False, int(pid))
if not handle:
# No handle: access-denied means the process exists but isn't ours to
# open; any other error (invalid parameter / not found) means it's gone.
return ctypes.get_last_error() == ERROR_ACCESS_DENIED
try:
# A live process is not signalled, so the zero-timeout wait returns
# WAIT_TIMEOUT; an exited process is signalled and returns WAIT_OBJECT_0.
return kernel32.WaitForSingleObject(handle, 0) == WAIT_TIMEOUT
finally:
kernel32.CloseHandle(handle)
def _pid_alive(pid: int) -> bool:
if pid <= 0:
return False
if os.name == "nt":
try:
return _pid_alive_windows(pid)
except OSError:
# If the Win32 probe itself fails, assume alive rather than risk
# discarding a healthy endpoint — and never fall back to os.kill.
return True
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
@dataclass
class Job:
id: str
kind: str
payload: dict[str, Any]
state: str
priority: int
dedupe_key: str | None
created_at: str
started_at: str | None
finished_at: str | None
result: dict[str, Any] | None
error: dict[str, Any] | None
attempts: int
class QueueStore:
def __init__(self, path: Path):
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._init_db()
@contextlib.contextmanager
def _connect(self):
"""Open a short-lived sqlite3 connection and close it on exit.
The bare ``with sqlite3.connect(...)`` context manager only manages the
transaction (commit/rollback) — it does NOT close the connection, so every
QueueStore call in this long-lived daemon process leaked a connection FD.
In a daemon that runs thousands of jobs that is an unbounded FD leak. This
wrapper closes the connection on exit so each call is self-contained.
"""
conn = sqlite3.connect(str(self.path), timeout=30)
try:
conn.row_factory = sqlite3.Row
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _init_db(self) -> None:
with self._connect() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
state TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
dedupe_key TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
result_json TEXT,
error_json TEXT,
attempts INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state, priority)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_dedupe ON jobs(dedupe_key, state)")
# Unique partial index: at most one queued/running job per dedupe_key.
# Enforces the dedupe invariant across processes (TOCTOU-safe); finished
# jobs drop out of the index so a later identical enqueue is allowed.
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_dedupe_active "
"ON jobs(dedupe_key) WHERE state IN ('queued', 'running')"
)
# The queue DB holds verbatim payloads (diary text, source paths) — lock it
# down to owner-only regardless of the invoking user's umask. The WAL/SHM
# sidecars carry the same un-checkpointed payloads, so harden them too when
# present (the daemon also runs under a 0o077 umask; this covers any
# QueueStore opened outside that scope, e.g. the CLI `daemon jobs` path).
_chmod_private(self.path)
for sidecar_suffix in ("-wal", "-shm"):
sidecar = self.path.with_name(self.path.name + sidecar_suffix)
if sidecar.exists():
_chmod_private(sidecar)
def prune_terminal(self, older_than_days: int = JOB_RETENTION_DAYS) -> int:
"""Delete terminal (succeeded/failed/cancelled) jobs older than the
retention window.
Bounded growth for the queue DB, which holds verbatim payloads. Only
terminal jobs are eligible — queued/running jobs are never touched, so
a crash mid-prune cannot drop in-flight work (incremental-only). The
cutoff uses ``finished_at``; a terminal job is never re-examined by
recover_running, so deleting it is safe.
"""
if older_than_days <= 0:
return 0
cutoff = (datetime.now(timezone.utc) - timedelta(days=older_than_days)).isoformat()
with self._lock, self._connect() as conn:
cur = conn.execute(
"""
DELETE FROM jobs
WHERE state IN ('succeeded', 'failed', 'cancelled')
AND finished_at IS NOT NULL
AND finished_at < ?
""",
(cutoff,),
)
return int(cur.rowcount or 0)
def recover_running(self) -> int:
"""Re-queue jobs left ``running`` by a crashed/killed daemon.
Jobs that have already exhausted ``MAX_ATTEMPTS`` claims are dead-lettered
to ``failed`` instead of being retried — non-idempotent kinds (diary_write
derives its entry_id from wall-clock time) would otherwise duplicate
verbatim palace content on every restart, violating the incremental-only
principle. The last error_json is preserved for diagnostics.
"""
with self._lock, self._connect() as conn:
conn.execute(
"""
UPDATE jobs
SET state = 'failed', finished_at = ?,
error_json = COALESCE(error_json, ?)
WHERE state = 'running' AND attempts >= ?
""",
(
_now(),
json.dumps(
{"error_class": "MaxAttemptsExceeded", "message": "max attempts exceeded"},
ensure_ascii=False,
),
MAX_ATTEMPTS,
),
)
cur = conn.execute(
"""
UPDATE jobs
SET state = 'queued', started_at = NULL
WHERE state = 'running' AND attempts < ?
""",
(MAX_ATTEMPTS,),
)
return int(cur.rowcount or 0)
def enqueue(
self,
kind: str,
payload: dict[str, Any],
*,
dedupe_key: str | None = None,
priority: int = 0,
) -> Job:
payload_json = json.dumps(payload, ensure_ascii=False, sort_keys=True)
with self._lock, self._connect() as conn:
if dedupe_key:
row = conn.execute(
"""
SELECT * FROM jobs
WHERE dedupe_key = ? AND state IN ('queued', 'running')
ORDER BY created_at DESC
LIMIT 1
""",
(dedupe_key,),
).fetchone()
if row is not None:
return self._row_to_job(row)
job_id = uuid.uuid4().hex
try:
conn.execute(
"""
INSERT INTO jobs (
id, kind, payload_json, state, priority, dedupe_key, created_at, attempts
) VALUES (?, ?, ?, 'queued', ?, ?, ?, 0)
""",
(job_id, kind, payload_json, int(priority), dedupe_key, _now()),
)
except sqlite3.IntegrityError:
# Unique partial index beat us in a cross-process race — return the
# job that won. SELECT-then-INSERT is not atomic across processes; the
# index is the source of truth.
if not dedupe_key:
raise
row = conn.execute(
"""
SELECT * FROM jobs
WHERE dedupe_key = ? AND state IN ('queued', 'running')
ORDER BY created_at DESC
LIMIT 1
""",
(dedupe_key,),
).fetchone()
if row is None:
# Index guard fired but the row is already gone — retry the INSERT.
conn.execute(
"""
INSERT INTO jobs (
id, kind, payload_json, state, priority, dedupe_key, created_at, attempts
) VALUES (?, ?, ?, 'queued', ?, ?, ?, 0)
""",
(job_id, kind, payload_json, int(priority), dedupe_key, _now()),
)
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return self._row_to_job(row)
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return self._row_to_job(row)
def claim_next(self) -> Job | None:
# Atomic across processes: the UPDATE only fires if the row is still
# 'queued'. If two daemon processes SELECT the same row, the first to
# UPDATE it flips state to 'running' (rowcount=1); the second's UPDATE
# matches 0 rows (WHERE state='queued' is now false) and we re-loop
# instead of double-executing the job. The in-process RLock does not
# protect against a second OS process — this guard does.
with self._lock, self._connect() as conn:
row = conn.execute(
"""
SELECT * FROM jobs
WHERE state = 'queued'
ORDER BY priority DESC, created_at ASC
LIMIT 1
"""
).fetchone()
if row is None:
return None
cur = conn.execute(
"""
UPDATE jobs
SET state = 'running', started_at = ?, attempts = attempts + 1
WHERE id = ? AND state = 'queued'
""",
(_now(), row["id"]),
)
if cur.rowcount != 1:
# Lost the race to another process — nothing to run this iteration.
return None
claimed = conn.execute("SELECT * FROM jobs WHERE id = ?", (row["id"],)).fetchone()
return self._row_to_job(claimed)
def finish(
self,
job_id: str,
*,
state: str,
result: dict[str, Any] | None = None,
error: dict[str, Any] | None = None,
only_if_running: bool = False,
) -> Job:
# ``only_if_running`` guards the worker's finish against a lost race with
# shutdown's cancel: if the active job was already flipped to 'cancelled'
# by _drain_and_cleanup, a late worker finish must NOT overwrite it back to
# 'succeeded'/'failed' (which would un-cancel a job recover_running must
# not re-run). The conditional UPDATE makes the worker's finish a no-op in
# that window instead of relying on process-exit timing.
where = "WHERE id = ?" + (" AND state = 'running'" if only_if_running else "")
with self._lock, self._connect() as conn:
conn.execute(
f"""
UPDATE jobs
SET state = ?, finished_at = ?, result_json = ?, error_json = ?
{where}
""",
(
state,
_now(),
json.dumps(result or {}, ensure_ascii=False),
json.dumps(error or {}, ensure_ascii=False) if error else None,
job_id,
),
)
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
return self._row_to_job(row)
def get(self, job_id: str) -> Job:
with self._lock, self._connect() as conn:
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
if row is None:
raise DaemonError(f"unknown job id: {job_id}")
return self._row_to_job(row)
def list(self, limit: int = 20) -> list[Job]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?",
(max(1, int(limit)),),
).fetchall()
return [self._row_to_job(row) for row in rows]
def counts(self) -> dict[str, int]:
with self._lock, self._connect() as conn:
rows = conn.execute("SELECT state, COUNT(*) AS n FROM jobs GROUP BY state").fetchall()
return {str(row["state"]): int(row["n"]) for row in rows}
@staticmethod
def _row_to_job(row: sqlite3.Row) -> Job:
def _loads(value):
if not value:
return None
try:
return json.loads(value)
except json.JSONDecodeError:
return None
return Job(
id=str(row["id"]),
kind=str(row["kind"]),
payload=_loads(row["payload_json"]) or {},
state=str(row["state"]),
priority=int(row["priority"]),
dedupe_key=row["dedupe_key"],
created_at=str(row["created_at"]),
started_at=row["started_at"],
finished_at=row["finished_at"],
result=_loads(row["result_json"]),
error=_loads(row["error_json"]),
attempts=int(row["attempts"]),
)
def job_to_dict(job: Job, *, include_payload: bool = True) -> dict[str, Any]:
out = {
"id": job.id,
"kind": job.kind,
"state": job.state,
"priority": job.priority,
"dedupe_key": job.dedupe_key,
"created_at": job.created_at,
"started_at": job.started_at,
"finished_at": job.finished_at,
"result": job.result,
"error": job.error,
"attempts": job.attempts,
}
if include_payload:
out["payload"] = job.payload
return out
class DaemonRuntime:
def __init__(self, palace_path: str, backend: str | None = None):
self.palace_path = canonical_palace_path(palace_path)
self.backend = backend
self.store = QueueStore(queue_path(self.palace_path))
self.shutdown_event = threading.Event()
self.worker_wake = threading.Event()
self.active_job_id: str | None = None
self.worker_thread: threading.Thread | None = None
def start_worker(self) -> threading.Thread:
self.store.recover_running()
# Bounded growth: drop terminal jobs older than the retention window
# before bringing the worker up. Best-effort — a prune failure must not
# block startup.
try:
self.store.prune_terminal()
except Exception: # noqa: BLE001 - retention is best-effort, never fatal
pass
thread = threading.Thread(
target=self._worker_loop, name="mempalace-daemon-worker", daemon=True
)
self.worker_thread = thread
thread.start()
return thread
def worker_alive(self) -> bool:
return self.worker_thread is not None and self.worker_thread.is_alive()
def _safe_finish(self, job_id: str, *, state: str, result: dict, error: dict | None) -> None:
try:
# only_if_running: if shutdown already cancelled this job, don't
# resurrect it. A finish failure must not kill the worker regardless.
self.store.finish(job_id, state=state, result=result, error=error, only_if_running=True)
except Exception: # noqa: BLE001 - a finish failure must not kill the worker
pass
def _worker_loop(self) -> None:
from .service import execute_job
while not self.shutdown_event.is_set():
try:
job = self.store.claim_next()
except Exception: # noqa: BLE001 - sqlite/disk errors must not kill the worker
self.shutdown_event.wait(1.0)
continue
if job is None:
self.worker_wake.wait(0.5)
self.worker_wake.clear()
continue
self.active_job_id = job.id
try:
payload = dict(job.payload)
# Override, never trust the client: an authenticated request for
# palace A must not be able to retarget the daemon at palace B.
payload["palace_path"] = self.palace_path
if self.backend:
payload["backend"] = self.backend
result = execute_job(job.kind, payload)
ok = bool(result.get("success", True))
state = "succeeded" if ok else "failed"
error = None if ok else {"message": result.get("error", "job failed")}
self._safe_finish(job.id, state=state, result=result, error=error)
except (Exception, SystemExit) as exc:
# SystemExit is BaseException, not Exception — catching it here is
# deliberate. Without it, a sys.exit() in a dependency would slip
# past `except Exception`, kill this worker thread, leave the job
# stuck in 'running' forever, and stall every later job while
# /health keeps reporting ok. (See mcp_server.py tool_mine for the
# same BaseException-slip-past semantics, documented in comments.)
self._safe_finish(
job.id,
state="failed",
result={"success": False, "exit_code": 1},
error={"error_class": type(exc).__name__, "message": str(exc)},
)
finally:
self.active_job_id = None
def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Connection", "close")
handler.end_headers()
handler.wfile.write(body)
handler.close_connection = True
def run_server(palace_path: str, *, backend: str | None = None, port: int = 0) -> None:
palace_path = canonical_palace_path(palace_path)
previous_env = {
"MEMPALACE_PALACE_PATH": os.environ.get("MEMPALACE_PALACE_PATH"),
"MEMPALACE_BACKEND_EXPLICIT": os.environ.get("MEMPALACE_BACKEND_EXPLICIT"),
"MEMPALACE_BACKEND": os.environ.get("MEMPALACE_BACKEND"),
}
os.environ["MEMPALACE_PALACE_PATH"] = palace_path
if backend:
os.environ["MEMPALACE_BACKEND_EXPLICIT"] = backend
os.environ["MEMPALACE_BACKEND"] = backend
# Privacy by architecture: tighten the umask to owner-only BEFORE the queue
# DB is created. SQLite's WAL/SHM sidecars hold un-checkpointed verbatim
# payloads and are (re)created with the process umask on every open/close
# cycle, so the umask must already be tight when DaemonRuntime builds the
# QueueStore (its _init_db opens the DB in WAL mode) — not only once the HTTP
# server starts. Restored in the finally at the end of run_server.
prev_umask = os.umask(0o077)
token = ensure_token(palace_path)
runtime = DaemonRuntime(palace_path, backend=backend)
class _Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
timeout = 10
def log_message(self, fmt, *args): # pragma: no cover - stdlib access logging noise
return
def _authorized(self) -> bool:
auth = self.headers.get("Authorization")
if auth and secrets.compare_digest(auth, f"Bearer {token}"):
return True
_json_response(self, 401, {"error": "unauthorized"})
return False
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0") or "0")
# Reject a negative Content-Length explicitly: self.rfile.read(-1)
# would read until the client closes the connection, blocking the
# worker and bypassing the MAX_BODY_BYTES cap (an auth-gated DoS).
if length < 0:
raise ValueError("invalid Content-Length")
if length > MAX_BODY_BYTES:
raise ValueError("request body too large")
raw = self.rfile.read(length)
return json.loads(raw.decode("utf-8")) if raw else {}
def do_GET(self):
if not self._authorized():
return
try:
self._handle_get()
except Exception as exc: # noqa: BLE001 - malformed query/DB error → 400
_json_response(self, 400, {"error": str(exc)})
def _handle_get(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
_json_response(
self,
200,
{
"ok": True,
"worker_alive": runtime.worker_alive(),
"pid": os.getpid(),
"palace_path": runtime.palace_path,
"backend": runtime.backend,
"active_job_id": runtime.active_job_id,
"counts": runtime.store.counts(),
},
)
return
if parsed.path == "/jobs":
qs = parse_qs(parsed.query)
limit = int((qs.get("limit") or ["20"])[0])
jobs = [
job_to_dict(job, include_payload=False) for job in runtime.store.list(limit)
]
_json_response(self, 200, {"jobs": jobs})
return
if parsed.path.startswith("/jobs/"):
job_id = parsed.path.rsplit("/", 1)[-1]
try:
job = runtime.store.get(job_id)
except DaemonError as exc:
_json_response(self, 404, {"error": str(exc)})
return
# Payloads carry verbatim user content (diary text) — do not return
# them over HTTP unless the caller explicitly opts in.
qs = parse_qs(parsed.query)
include_payload = qs.get("include_payload", ["false"])[0].lower() in (
"1",
"true",
"yes",
"on",
)
_json_response(
self, 200, {"job": job_to_dict(job, include_payload=include_payload)}
)
return
_json_response(self, 404, {"error": "not found"})
def do_POST(self):
if not self._authorized():
return
parsed = urlparse(self.path)
if parsed.path == "/jobs":
try:
body = self._read_json()
job = runtime.store.enqueue(
str(body.get("kind") or ""),
body.get("payload") or {},
dedupe_key=body.get("dedupe_key"),
priority=int(body.get("priority") or 0),
)
runtime.worker_wake.set()
except Exception as exc: # noqa: BLE001 - client gets structured failure
_json_response(self, 400, {"error": str(exc)})
return
_json_response(self, 202, {"job": job_to_dict(job)})
return
if parsed.path == "/shutdown":
_json_response(self, 200, {"ok": True})
runtime.shutdown_event.set()
threading.Thread(target=httpd.shutdown, daemon=True).start()
return
_json_response(self, 404, {"error": "not found"})
class _Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def server_bind(self):
# http.server's HTTPServer.server_bind() calls socket.getfqdn(host)
# to set server_name — a reverse-DNS lookup. For our 127.0.0.1 bind
# that lookup is useless, and on a host with slow or absent reverse
# DNS it blocks daemon startup for ~30s (until the resolver times
# out), which looks exactly like the daemon never coming up. Bind via
# TCPServer directly and set the name from the literal host instead.
import socketserver
socketserver.TCPServer.server_bind(self)
host, port = self.server_address[:2]
self.server_name = host
self.server_port = port
# The owner-only umask set above (before DaemonRuntime built the queue DB)
# covers every file this process creates — queue.sqlite3, its WAL/SHM
# sidecars, and any future artifact — and is restored in the finally below.
try:
with _Server((HOST, port), _Handler) as httpd:
actual_port = int(httpd.server_address[1])
sd = state_dir(palace_path)
sd.mkdir(parents=True, exist_ok=True)
_chmod_dir_private(sd)
endpoint = {
"host": HOST,
"port": actual_port,
"pid": os.getpid(),
"palace_path": palace_path,
"started_at": _now(),
}
_write_private(endpoint_path(palace_path), json.dumps(endpoint, indent=2) + "\n")
_write_private(pid_path(palace_path), f"{os.getpid()}\n")
runtime.start_worker()
try:
httpd.serve_forever(poll_interval=0.5)
finally:
_drain_and_cleanup(runtime, palace_path, previous_env)
finally:
os.umask(prev_umask)
def _drain_and_cleanup(
runtime: "DaemonRuntime", palace_path: str, previous_env: dict[str, str | None]
) -> None:
"""Drain the active job, then tear down server-side state.
Killing a daemon thread mid-write (mid mine upsert, mid irreversible sync
DELETE) violates incremental-only. Give the worker a bounded window to
finish, then mark whatever is still running as cancelled so recover_running
won't blindly re-run it on the next start (which would duplicate verbatim
content). Finally restore the env vars run_server mutated.
"""
runtime.shutdown_event.set()
worker = runtime.worker_thread
if worker is not None:
worker.join(timeout=SHUTDOWN_DRAIN_SECONDS)
active = runtime.active_job_id
if active:
runtime._safe_finish(
active,
state="cancelled",
result={"success": False, "exit_code": 1},
error={"message": "cancelled by daemon shutdown"},
)
for stale in (endpoint_path(palace_path), pid_path(palace_path)):
try:
stale.unlink()
except OSError:
pass
for key, value in previous_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
class DaemonClient:
def __init__(self, palace_path: str):
self.palace_path = canonical_palace_path(palace_path)
endpoint = _read_endpoint(self.palace_path)
port = endpoint.get("port")
if port is None:
raise DaemonError("daemon endpoint missing port")
# Don't read the token until we trust the endpoint points at a live
# process we started: a stale endpoint whose pid is dead may have its
# port reused by an unrelated process, and we must not send our bearer
# token there.
pid = endpoint.get("pid")
if pid is not None and not _pid_alive(int(pid)):
raise DaemonError("daemon endpoint pid is not alive")
self.token = read_token(self.palace_path)
self.host = endpoint.get("host") or HOST
self.port = int(port)
# The daemon is always on 127.0.0.1, so a request must never go through
# an HTTP proxy. Building an opener with an empty ProxyHandler bypasses
# urllib's proxy discovery entirely. On macOS that discovery
# (urllib.request._scproxy, via the SystemConfiguration framework) runs
# on the first request to any host and is NOT bounded by the per-request
# timeout — on a CI runner with no network it can hang for tens of
# seconds, which looks exactly like the daemon never came up. A no-proxy
# opener is the correct production choice here and also removes that hang.
self._opener = urlrequest.build_opener(urlrequest.ProxyHandler({}))
@property
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
def request(
self,
method: str,
path: str,
body: dict[str, Any] | None = None,
*,
timeout: float = 5.0,
) -> dict[str, Any]:
data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
req = urlrequest.Request(
self.base_url + path,
data=data,
method=method,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
},
)
try:
with self._opener.open(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
except urlerror.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = {"error": raw or str(exc)}
raise DaemonError(str(payload.get("error", exc))) from exc
except OSError as exc:
raise DaemonError(str(exc)) from exc
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
# A 2xx response with a non-JSON body (empty 200, truncated write,
# proxy HTML) shouldn't surface as a bare JSONDecodeError to callers
# that only know how to handle DaemonError.
raise DaemonError(f"daemon returned non-JSON response: {raw[:200]!r}") from exc
def health(self, *, timeout: float = 5.0) -> dict[str, Any]:
return self.request("GET", "/health", timeout=timeout)
def submit(
self,
kind: str,
payload: dict[str, Any],
*,
dedupe_key: str | None = None,
priority: int = 0,
) -> dict[str, Any]:
return self.request(
"POST",
"/jobs",
{"kind": kind, "payload": payload, "dedupe_key": dedupe_key, "priority": priority},
)["job"]
def get_job(self, job_id: str) -> dict[str, Any]:
return self.request("GET", f"/jobs/{job_id}")["job"]
def list_jobs(self, limit: int = 20) -> list[dict[str, Any]]:
return self.request("GET", f"/jobs?limit={int(limit)}")["jobs"]
def wait(self, job_id: str, *, timeout: float = DEFAULT_WAIT_TIMEOUT) -> dict[str, Any]:
deadline = time.monotonic() + timeout
while True:
job = self.get_job(job_id)
if job["state"] in TERMINAL_STATES:
return job
if time.monotonic() >= deadline:
raise DaemonError(f"timed out waiting for job {job_id}")
time.sleep(0.2)
def shutdown(self) -> dict[str, Any]:
return self.request("POST", "/shutdown", {})
def get_client_if_running(palace_path: str, *, health_timeout: float = 5.0) -> DaemonClient | None:
# health_timeout bounds the liveness probe. Hook callers (subject to the
# ~500ms hook budget) pass a short value via HOOK_PROBE_TIMEOUT so a wedged
# daemon — endpoint present, HTTP server not answering — can't stall the
# hook for the default 5s before it falls back to the direct path.
try:
client = DaemonClient(palace_path)
client.health(timeout=health_timeout)
return client
except DaemonError:
return None
def _detached_kwargs(log_path: Path) -> dict[str, Any]:
"""Kwargs that spawn the daemon with a hidden console, detached from the CLI.
Shares the Windows flag logic with ``hooks_cli._detached_popen_kwargs``
(which takes no args and opens no log file). On Windows we use
``CREATE_NO_WINDOW`` rather than ``DETACHED_PROCESS``: the latter gives the
child no console, so a console-subsystem grandchild later allocates a fresh
*visible* window (#1783); ``CREATE_NO_WINDOW`` gives a hidden console that
descendants inherit instead. Surviving the launching terminal is carried by
``CREATE_BREAKAWAY_FROM_JOB`` (escapes the parent Job Object's kill-on-close)
plus the daemon never being attached to that console -- not by the console
flag -- while ``CREATE_NEW_PROCESS_GROUP`` isolates Ctrl-C/Ctrl-Break routing.
``CREATE_NO_WINDOW`` is ignored when OR'd with ``DETACHED_PROCESS``, so this
replaces that flag rather than adding it. ``stdin=DEVNULL`` and stdout/stderr
redirected to the log avoid the #1268 parent hang.
"""
log_path.parent.mkdir(parents=True, exist_ok=True)
log_fh = open(log_path, "a", encoding="utf-8")
# The daemon log may capture verbatim content in tracebacks — owner-only.
_chmod_private(log_path)
kwargs: dict[str, Any] = {
"stdin": subprocess.DEVNULL,
"stdout": log_fh,
"stderr": log_fh,
"close_fds": True,
}
if os.name == "nt":
flags = 0
for name in ("CREATE_NO_WINDOW", "CREATE_NEW_PROCESS_GROUP", "CREATE_BREAKAWAY_FROM_JOB"):
flags |= getattr(subprocess, name, 0)
if flags:
kwargs["creationflags"] = flags
else:
kwargs["start_new_session"] = True
return kwargs
def start_daemon(
palace_path: str,
*,
backend: str | None = None,
foreground: bool = False,
timeout: float = 15.0,
) -> DaemonClient:
palace_path = canonical_palace_path(palace_path)
ensure_token(palace_path)
existing = get_client_if_running(palace_path)
if existing is not None:
return existing
if foreground:
# Blocks until the daemon stops. A clean stop is a normal exit, not an
# error — return None so the caller (cmd_daemon) exits 0.
run_server(palace_path, backend=backend, port=0)
return None # type: ignore[return-value]
sd = state_dir(palace_path)
sd.mkdir(parents=True, exist_ok=True)
_chmod_dir_private(sd)
# Spawn mutual exclusion: two concurrent `daemon start` callers would both
# observe no running daemon and both spawn a child, double-claiming jobs.
# A non-blocking flock serializes the check-then-spawn; the loser waits for
# the winner to finish coming up, then re-checks and reuses that daemon.
lock_fh = open(sd / "start.lock", "w") if _fcntl is not None else None
if _fcntl is not None and lock_fh is not None:
_chmod_private(sd / "start.lock")
try:
_fcntl.flock(lock_fh.fileno(), _fcntl.LOCK_EX | _fcntl.LOCK_NB)
except OSError:
# Another start is in flight — wait for it, then reuse its daemon.
_fcntl.flock(lock_fh.fileno(), _fcntl.LOCK_EX)
existing = get_client_if_running(palace_path)
if existing is not None:
return existing
# The other starter failed without bringing the daemon up; fall
# through and spawn ourselves (we now hold the lock).
for stale in (endpoint_path(palace_path), pid_path(palace_path)):
try:
stale.unlink()
except OSError:
pass
cmd = [
sys.executable,
"-m",
"mempalace.daemon",
"serve",
"--palace",
palace_path,
]
if backend:
cmd.extend(["--backend", backend])
env = os.environ.copy()
if STATE_ROOT_ENV in os.environ:
env[STATE_ROOT_ENV] = os.environ[STATE_ROOT_ENV]
kwargs = _detached_kwargs(sd / "daemon.log")
proc = None
try:
proc = subprocess.Popen(cmd, env=env, **kwargs)
finally:
log_fh = kwargs.get("stdout")
if hasattr(log_fh, "close"):
log_fh.close()
try:
deadline = time.monotonic() + timeout
last_error = None
while time.monotonic() < deadline:
if proc.poll() is not None:
raise DaemonError(f"daemon exited during startup with code {proc.returncode}")
try:
client = DaemonClient(palace_path)
client.health()
return client
except DaemonError as exc:
last_error = exc
time.sleep(0.1)
raise DaemonError(f"daemon did not become ready: {last_error}")
except BaseException:
# Readiness failed — don't leak an orphaned detached child holding the
# port, token, queue, and log handle. Kill and reap it before raising.
if proc is not None and proc.poll() is None:
try:
proc.kill()
proc.wait()
except Exception: # noqa: BLE001 - cleanup best-effort
pass
raise
finally:
if lock_fh is not None:
try:
lock_fh.close()
except Exception: # noqa: BLE001 - cleanup best-effort
pass
def ensure_client(
palace_path: str, *, backend: str | None = None, auto_start: bool = True
) -> DaemonClient:
palace_path = canonical_palace_path(palace_path)
client = get_client_if_running(palace_path)
if client is not None:
return client
if not auto_start:
raise DaemonError("daemon is not running")
return start_daemon(palace_path, backend=backend)
def submit_job(
kind: str,
payload: dict[str, Any],
*,
palace_path: str | None = None,
backend: str | None = None,
dedupe_key: str | None = None,
priority: int = 0,
wait: bool = True,
auto_start: bool = False,
timeout: float = DEFAULT_WAIT_TIMEOUT,
) -> dict[str, Any]:
# Strictly opt-in: callers that want the daemon auto-started must say so
# explicitly (the CLI --daemon path passes auto_start=True). The default
# refuses to spawn a long-lived process on a background code path.
resolved_palace = canonical_palace_path(palace_path or payload.get("palace_path"))
payload = dict(payload)
payload["palace_path"] = resolved_palace # override, never trust client input
if backend:
payload["backend"] = backend
client = ensure_client(resolved_palace, backend=backend, auto_start=auto_start)
job = client.submit(kind, payload, dedupe_key=dedupe_key, priority=priority)
if not wait:
return job
return client.wait(job["id"], timeout=timeout)
def stop_daemon(palace_path: str) -> bool:
client = get_client_if_running(palace_path)
if client is None:
return False
client.shutdown()
return True
def _cmd_serve(args) -> None:
run_server(args.palace, backend=args.backend, port=args.port)
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="MemPalace daemon internals")
sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve")
serve.add_argument("--palace", required=True)
serve.add_argument("--backend", default=None)
serve.add_argument("--port", type=int, default=0)
args = parser.parse_args(argv)
if args.command == "serve":
_cmd_serve(args)
if __name__ == "__main__":
main()