Skip to content

Commit db836b7

Browse files
authored
fix(mesh): publish the estate so every transport can report it (#2309)
The peer sync loop is started only by _serve_http, and the estate it builds (_PEER_SYNC_STATE, _KNOWN_PROFILES) is process memory. But mempalace_mesh_peers ships in every transport, including the stdio servers agents connect through, and those processes never run a sync round. So they answered from a permanently empty estate: every peer reduced to a bare name and url with no reachable, last_success_at, remote_version_vector or profile; origin_profiles holding only this node; and configured peers reported as unnamed_origins -- "known only transitively" -- because their replica_id is learned during a sync and nothing else supplies it. The hub next door had all of it. The sync loop now publishes the estate to mesh_state.json in the per-palace server state directory after every round, alongside the token and serverinfo that already use that directory for exactly this "hub records something other local processes read" purpose. 0600, and written to a temp name then renamed so a reader in another process never observes a half-serialized estate. _mesh_peers_payload merges the published estate underneath any in-process state, per peer, so the process that actually syncs keeps reporting its own fresher observation and every other process reports the hub's instead of nothing. The new estate_source field says where the reading came from: in_process, published_at, and whether the publishing hub is still alive. A crashed hub leaves a last-known-good estate, which is worth showing -- "last seen as" beats a blank node -- but must not be read as live. peers.json tokens are not in the estate and never reach the file; both are asserted.
1 parent c860fb1 commit db836b7

4 files changed

Lines changed: 332 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
1111
### Bug Fixes
1212

1313
- **A long-running MCP server no longer grows by ~440 MB per collection open.** `ChromaBackend._client` keys its client cache on `chroma.sqlite3`'s inode and mtime to detect writes by another process, but constructing a `chromadb.PersistentClient` writes to that file itself, and so do the collection opens that follow — so the stamp taken at construction time was already stale by the time the next open compared against it. Every search opens `mempalace_drawers` and then `mempalace_closets`, and the first open moved the mtime the second one checked, so the cache missed on essentially every request: each search rebuilt the client and reloaded both HNSW segments, and the displaced client was dropped from the dict without being closed, so its native index memory was never returned. On a palace of ~165k vectors that was ~650 MB of resident growth per search; a server left running for an afternoon reached 3.9 GB. The freshness stat is now re-baselined once the backend's own operation finishes — including writes through `ChromaCollection`, so the file-a-drawer-then-search cycle stops reloading the index too — which makes the recorded value mean "`chroma.sqlite3` as this backend last left it", and a genuine external change still rebuilds. The rebuild path also closes the client it replaces. Measured over eight searches on the same palace: 951 MB → 3522 MB before, 940 MB → 965 MB after. An external write landing *while* one of our own operations is in flight is absorbed into the new stamp and picked up on the next change; mtime cannot distinguish writers, and `PRAGMA data_version` does not help because chromadb's own connection reads as foreign to a probe connection. (#2307)
14+
- **`mempalace_mesh_peers` answers with the real estate from every transport, not just the hub.** The peer sync loop is started only by `_serve_http`, and the estate it builds (`_PEER_SYNC_STATE`, `_KNOWN_PROFILES`) lives in process memory — but the tool that reports it ships in every transport, including the stdio servers agents actually connect through. Those processes never run a sync round, so they answered from a permanently empty estate: each peer reduced to a bare `name` and `url` with no `reachable`, `last_success_at`, `remote_version_vector` or `profile`, `origin_profiles` holding only this node, and configured peers misreported as `unnamed_origins` because their `replica_id` was never learned — all while the hub next door had the complete picture. The sync loop now publishes the estate to `mesh_state.json` in the per-palace server state directory (0600, write-and-rename) after every round, and `_mesh_peers_payload` merges it underneath any in-process state, so the syncing process still trusts its own fresher reading. A new `estate_source` field says whether the status was observed in this process or published by the hub, when it was published, and whether that hub is still alive — a crashed hub leaves a last-known-good estate that is shown but never read as live. peers.json tokens never reach the file. (#2309)
1415
- **`sweep` books a failed `stat` as a failure again.** The non-regular-file gate added in 3.7.1 reads `stat.S_ISREG(f.stat().st_mode)` inside a `try`, and its `except OSError` printed `SKIP` and moved on. A dangling symlink, a symlink loop and a file unlinked between `rglob` and the gate all raise there, and before the gate existed every one of them reached `sweep()` and was booked in `failures` — so `sweep` went from reporting a transcript it could not read to reporting success. A failed probe is now an error, not a benign file type: it is logged, printed as `WARNING`, and appended to `failures`, while a probe that succeeds and says "not regular" still skips silently. (#2221)
1516
- **`mempalace init` no longer tracebacks on a directory it cannot enter.** `_parse_gradle`'s `is_file()` gate sat in front of the `try` that the parser's own `except OSError` provides, so a manifest under a directory with `r` but no `x` raised `PermissionError` out of a call that used to answer "no manifest name". The gate moved inside that `try`, and `_collect_manifest_names` stats through `os.path.isfile`, which reports rather than raises. (#2221)
1617
- **`split` no longer blocks on a FIFO at its own output name, nor writes through a broken link.** The type gate in `main` covers the files the glob listed; `split_file` builds its output names itself, so a pre-existing named pipe at one of them wedged `write_text` in the kernel waiting for a reader. The check asks about the link itself rather than its target, because a dangling symlink at an output name reads as "nothing there" and `write_text` would create the target — landing a chunk outside the output directory. Output names that are anything but a regular file are now skipped with a `SKIP` line. (#2221)

mempalace/mcp_server.py

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4854,7 +4854,7 @@ def tool_patch_submit(
48544854
"handler": tool_graph_stats,
48554855
},
48564856
"mempalace_mesh_peers": {
4857-
"description": "Mesh estate snapshot (RFC 004): this replica's identity, version vector and node profile; each configured peer's reachability, last sync outcome, remote version vector and advertised profile; origins known only transitively; and origin_profiles keyed by replica_id. Exactly the GET /sync/peers payload — tokens are never included.",
4857+
"description": "Mesh estate snapshot (RFC 004): this replica's identity, version vector and node profile; each configured peer's reachability, last sync outcome, remote version vector and advertised profile; origins known only transitively; origin_profiles keyed by replica_id; and estate_source saying whether the peer status was observed in this process or published by the palace's hub (with published_at and whether that hub is still alive). Exactly the GET /sync/peers payload — tokens are never included.",
48584858
"input_schema": {"type": "object", "properties": {}},
48594859
"handler": tool_mesh_peers,
48604860
},
@@ -7234,17 +7234,60 @@ def _merge_known_profiles(profiles: dict) -> None:
72347234
_KNOWN_PROFILES[origin] = profile
72357235

72367236

7237-
def _known_profiles_snapshot() -> dict:
7238-
"""Every origin profile this node can vouch for having seen — learned
7239-
ones first, own fresh self-profile last so it always wins for self."""
7240-
snapshot = dict(_KNOWN_PROFILES)
7237+
def _known_profiles_snapshot(published: dict = None) -> dict:
7238+
"""Every origin profile this node can vouch for having seen.
7239+
7240+
Published profiles first (the hub's, when this process is not the one
7241+
syncing), then any learned in this process, then our own fresh
7242+
self-profile last so it always wins for self.
7243+
"""
7244+
if published is None:
7245+
published = _published_mesh_state()
7246+
snapshot = dict(published.get("profiles") or {})
7247+
snapshot.update(_KNOWN_PROFILES)
72417248
try:
72427249
snapshot[_call_logstream(lambda ls: ls.replica_id)] = _node_profile()
72437250
except Exception:
72447251
logger.debug("node profile: self profile unavailable", exc_info=True)
72457252
return snapshot
72467253

72477254

7255+
def _publish_mesh_state(palace_path: str) -> None:
7256+
"""Write this process's estate where other local processes can read it.
7257+
7258+
The sync loop runs only in the HTTP transport, so without this the stdio
7259+
MCP servers agents connect through answer ``mempalace_mesh_peers`` from a
7260+
permanently empty ``_PEER_SYNC_STATE`` -- peers with a name and a url and
7261+
nothing else, and ``origin_profiles`` holding only this node. Never fatal:
7262+
a publish failure costs other processes their estate view, not this
7263+
process's convergence.
7264+
"""
7265+
from . import server_registry
7266+
7267+
try:
7268+
server_registry.write_mesh_state(
7269+
palace_path,
7270+
peers=dict(_PEER_SYNC_STATE),
7271+
profiles=dict(_KNOWN_PROFILES),
7272+
)
7273+
except Exception:
7274+
logger.debug("mesh state publish failed", exc_info=True)
7275+
7276+
7277+
def _published_mesh_state() -> dict:
7278+
"""Read the estate published by this palace's hub, if any."""
7279+
from . import server_registry
7280+
7281+
palace_path = getattr(_config, "palace_path", None)
7282+
if not palace_path:
7283+
return {"peers": {}, "profiles": {}, "written_at": None, "writer_alive": False}
7284+
try:
7285+
return server_registry.read_mesh_state(palace_path)
7286+
except Exception:
7287+
logger.debug("mesh state read failed", exc_info=True)
7288+
return {"peers": {}, "profiles": {}, "written_at": None, "writer_alive": False}
7289+
7290+
72487291
def _record_peer_sync(stats: dict) -> None:
72497292
"""Fold one peer's round outcome into the estate state."""
72507293
name = stats.get("peer_name") or stats.get("peer_url") or "?"
@@ -7298,11 +7341,18 @@ def _mesh_peers_payload() -> dict:
72987341
configured = load_peers(getattr(_config, "palace_path", None) or "")
72997342
except (ValueError, TypeError):
73007343
configured = []
7344+
# The peer sync loop lives in the HTTP transport, so in every other
7345+
# process _PEER_SYNC_STATE is empty and the only honest source is what
7346+
# the hub published. Prefer our own state when we have it (this process
7347+
# is the one syncing, so it is fresher than anything on disk) and fall
7348+
# back to the published estate per peer.
7349+
published = _published_mesh_state()
7350+
published_peers = published.get("peers") or {}
73017351
named_origins = {replica_id}
73027352
peers = []
73037353
for peer in configured:
73047354
name = peer.get("name") or peer["url"]
7305-
state = dict(_PEER_SYNC_STATE.get(name) or {})
7355+
state = dict(_PEER_SYNC_STATE.get(name) or published_peers.get(name) or {})
73067356
state.pop("url", None) # peers.json is authoritative for the url
73077357
if state.get("replica_id"):
73087358
named_origins.add(state["replica_id"])
@@ -7320,8 +7370,17 @@ def _mesh_peers_payload() -> dict:
73207370
"unnamed_origins": sorted(set(local_vector) - named_origins),
73217371
# Every self-described profile known here, keyed by replica_id —
73227372
# including profiles of unnamed origins relayed through carriers.
7323-
"origin_profiles": _known_profiles_snapshot(),
7373+
"origin_profiles": _known_profiles_snapshot(published),
73247374
"sync_interval_s": _peer_sync_interval_s(),
7375+
# Where the peer status above came from. in_process means this
7376+
# process runs the sync loop; otherwise the estate was published by
7377+
# the hub at published_at, and writer_alive says whether that hub is
7378+
# still running (a dead hub leaves a last-known-good reading).
7379+
"estate_source": {
7380+
"in_process": bool(_PEER_SYNC_STATE),
7381+
"published_at": published.get("written_at"),
7382+
"writer_alive": published.get("writer_alive", False),
7383+
},
73257384
}
73267385

73277386

@@ -7373,6 +7432,9 @@ def _loop():
73737432
stats["pulled_events"],
73747433
stats["pulled_artifacts"],
73757434
)
7435+
# Publish once per round, not per peer: the estate is only
7436+
# coherent after every configured peer has been attempted.
7437+
_publish_mesh_state(palace_path)
73767438
malformed_logged = False
73777439
except ValueError as exc:
73787440
if not malformed_logged:

mempalace/server_registry.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import json
2929
import logging
3030
import os
31+
import time
3132
from pathlib import Path
3233

3334
logger = logging.getLogger(__name__)
@@ -41,6 +42,10 @@ def _canonical(palace_path: str) -> str:
4142
return os.path.abspath(os.path.realpath(os.path.expanduser(palace_path)))
4243

4344

45+
def _utc_now() -> str:
46+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
47+
48+
4449
def server_state_dir(palace_path: str) -> Path:
4550
"""Per-palace state directory shared by the token and the serverinfo.
4651
@@ -88,6 +93,81 @@ def write_serverinfo(palace_path: str, *, host: str, port: int, scheme: str, rea
8893
return path
8994

9095

96+
def mesh_state_path(palace_path: str) -> Path:
97+
return server_state_dir(palace_path) / "mesh_state.json"
98+
99+
100+
def write_mesh_state(palace_path: str, *, peers: dict, profiles: dict) -> Path:
101+
"""Publish the hub's mesh estate so other local processes can read it.
102+
103+
The estate — which peers answered last round, their version vectors and
104+
advertised profiles — is built by the peer sync loop, and that loop only
105+
runs in the HTTP transport. Every other process for this palace (the
106+
stdio MCP servers agents actually connect through, the CLI) has the same
107+
``mempalace_mesh_peers`` tool and an empty in-memory estate behind it, so
108+
without this file they answer "two peers, no status at all" while the hub
109+
next door knows the whole picture.
110+
111+
0600 like the token and the serverinfo. The contents are not secret —
112+
peers.json tokens never reach the estate — but the directory convention
113+
is "private to the user".
114+
"""
115+
path = mesh_state_path(palace_path)
116+
path.parent.mkdir(parents=True, exist_ok=True)
117+
try:
118+
os.chmod(str(path.parent), 0o700)
119+
except OSError:
120+
pass
121+
payload = {
122+
"pid": os.getpid(),
123+
"written_at": _utc_now(),
124+
"peers": peers,
125+
"profiles": profiles,
126+
}
127+
# Write-and-rename: readers in other processes must never observe a
128+
# half-serialized estate, and this file is rewritten every sync round.
129+
tmp = path.with_name(path.name + f".{os.getpid()}.tmp")
130+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
131+
try:
132+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
133+
json.dump(payload, fh)
134+
fh.write("\n")
135+
os.replace(str(tmp), str(path))
136+
except BaseException:
137+
try:
138+
os.unlink(str(tmp))
139+
except OSError:
140+
pass
141+
raise
142+
return path
143+
144+
145+
def read_mesh_state(palace_path: str) -> dict:
146+
"""Return the published estate for this palace.
147+
148+
Always a dict with ``peers``/``profiles`` mappings so callers can merge
149+
without None-checks; empty when no hub has published yet or the file is
150+
unreadable. ``writer_alive`` reports whether the publishing process is
151+
still running — a crashed hub leaves a last-known-good estate that is
152+
worth showing but must not be read as live.
153+
"""
154+
empty = {"peers": {}, "profiles": {}, "written_at": None, "writer_alive": False}
155+
try:
156+
state = json.loads(mesh_state_path(palace_path).read_text(encoding="utf-8"))
157+
except (OSError, ValueError):
158+
return empty
159+
if not isinstance(state, dict):
160+
return empty
161+
peers = state.get("peers")
162+
profiles = state.get("profiles")
163+
return {
164+
"peers": peers if isinstance(peers, dict) else {},
165+
"profiles": profiles if isinstance(profiles, dict) else {},
166+
"written_at": state.get("written_at"),
167+
"writer_alive": _pid_alive(state.get("pid")),
168+
}
169+
170+
91171
def clear_serverinfo(palace_path: str) -> None:
92172
"""Remove this process's serverinfo record, if it is still ours.
93173

0 commit comments

Comments
 (0)