Skip to content

Commit 0f3cea5

Browse files
committed
fix(mcp): report an absent integrity verdict instead of a clean one (#2290)
sqlite_integrity_errors answers [] when chroma.sqlite3 is not there, and the MCP startup gate published that as checked: true, ok: true. A clean bill of health and an unopened database were the same answer, and /statusz built its health flag from it. os.path.exists cannot separate the two: it is genericpath.exists, which calls os.stat inside a try whose except folds every OSError and ValueError into False, and os.stat follows symlinks. ENOENT from lstat is now the only answer accepted as proof of absence; everything else falls through to the open attempt, where SQLite reports it. An absent database gets the not-applicable shape #1931 introduced, checked: false / ok: null plus a reason. ENOENT does not say which path component was missing, so it proves something about the path and not about the palace: a palace directory that is itself a dangling symlink, and an unmounted mount point, both report no verdict rather than a failed probe. That is the answer develop gives too, without develop's claim that the check passed. Every state that is not proven absent reaches the probe. Measured against develop, six palace states out of nine answered checked: true, ok: true: an absent database, a palace directory that does not exist, an intact database under a mode-000 directory, a dangling symlink, a symlink loop, and a path whose parent is a file. The last four now report PRAGMA quick_check failed and so trip the existing -32002 refusal, as do an over-long name and an embedded NUL. A database file at mode 000 already reported ok: false, because os.path.exists is true there and the probe ran. The absence question is asked once per call. Reaching the probe through sqlite_integrity_errors would ask it again, and a database unlinked between the two would answer [] the second time, which is the clean verdict this change exists to withhold. The quick_check body moved to _quick_check_errors, which has no absence gate, and both callers use it. /statusz reads an absent verdict as healthy rather than unhealthy. develop already answered ok: true for a palace with no database, through the clean verdict this removes, so the change is what keeps the new ok: null from turning a fresh install red; the visible flip is the non-chroma backends, unhealthy since the #1931 fix. The default in integrity.get("ok", False) keeps a payload with no ok key failing closed. SqliteIntegrityStatus carries its errors as a tuple. With a list field the generated __hash__ raised TypeError on every call and a caller could append to a verdict it had been handed, so frozen=True described nothing. Windows raises ENOENT where POSIX raises ENOTDIR for a file used as a directory component, so there the palace path is proven absent and reported as no verdict rather than as a failed probe. Both answers are safe; the difference belongs to the platform, not to the gate. The ENOTDIR test is scoped to POSIX for that reason. The refresh clears the no-verdict reason on every one of its five exits, including the two that return before any probe runs, so a server pointed at a new palace cannot keep naming the old one's database. The payload still reads those globals one at a time without the lock, and the comments there no longer claim otherwise: a refresh landing between two reads can publish ok: true for a palace with no database, and snapshotting into locals moves that window rather than closing it. Closing it means publishing the verdict as one value, which is a change to what the gate stores.
1 parent 639c69a commit 0f3cea5

6 files changed

Lines changed: 727 additions & 31 deletions

File tree

CHANGELOG.md

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

1111
### Bug Fixes
1212

13+
- **A palace with no database is no longer reported as one that passed its integrity check.** `sqlite_integrity_errors` answers `[]` when `chroma.sqlite3` is absent, and the MCP gate published that as `checked: true, ok: true`. Absence is now decided by `ENOENT` alone, which proves that nothing resolves under the path, and reported as the not-applicable shape #1931 introduced, `checked: false`/`ok: null` plus a reason. Every state that is not proven absent reaches the probe, and a probe that cannot open the file reports `PRAGMA quick_check failed`, which trips the existing `-32002` refusal: a dangling symlink, a database under an unreadable directory, a symlink loop, a name the filesystem rejects, an embedded NUL in the path, and, on POSIX, a palace path whose parent is a file. `/statusz` reads an absent verdict as healthy, so the new `ok: null` does not turn a fresh install red, and non-chroma backends stop reporting themselves unhealthy, which they had done since the #1931 fix. The size-limited startup skip still publishes a clean verdict; the only change there is that it no longer inherits the previous probe's absence reason. (#2290)
1314
- **`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)
1415
- **`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)
1516
- **`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: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,13 @@ def _parse_args():
352352
_sqlite_integrity_checked = False
353353
_sqlite_integrity_errors: list[str] = []
354354
_sqlite_integrity_check_error = ""
355+
# Why no verdict exists, when none does. An empty _sqlite_integrity_errors is
356+
# ambiguous on its own: quick_check found nothing wrong, or it never ran. Four
357+
# exits in the refresh leave the list empty and only one of them means the
358+
# database came back clean. This names one of the others, the palace with no
359+
# chroma.sqlite3, so the status payload can report an absence rather than a
360+
# clean bill of health.
361+
_sqlite_integrity_no_verdict_reason = ""
355362
# Serializes quick_check runs between the async startup preflight thread and
356363
# lazy consumers on the protocol thread (double-checked in
357364
# _ensure_sqlite_integrity_status) so the O(database size) probe never runs
@@ -838,10 +845,11 @@ def _startup_integrity_size_limit_bytes() -> int:
838845
def _refresh_sqlite_integrity_status() -> None:
839846
"""Refresh the MCP startup SQLite/FTS5 integrity gate.
840847
841-
Uses repair.sqlite_integrity_errors(), which is read-only and already backs
842-
repair preflight. A failure here is treated as an integrity failure so the
843-
server does not proceed silently after a malformed FTS5 index or other
844-
SQLite-layer corruption (#1818).
848+
Uses repair.sqlite_integrity_status(), which wraps the read-only
849+
quick_check backing repair preflight and adds whether a verdict exists at
850+
all. A failure here is treated as an integrity failure so the server does
851+
not proceed silently after a malformed FTS5 index or other SQLite-layer
852+
corruption (#1818).
845853
"""
846854

847855
with _sqlite_integrity_refresh_lock:
@@ -853,11 +861,13 @@ def _refresh_sqlite_integrity_status_locked() -> None:
853861
global _sqlite_integrity_checked
854862
global _sqlite_integrity_errors
855863
global _sqlite_integrity_check_error
864+
global _sqlite_integrity_no_verdict_reason
856865

857866
if not _config.palace_path or not _is_chroma_backend():
858867
_sqlite_integrity_checked = True
859868
_sqlite_integrity_errors = []
860869
_sqlite_integrity_check_error = ""
870+
_sqlite_integrity_no_verdict_reason = ""
861871
return
862872

863873
max_bytes = _startup_integrity_size_limit_bytes()
@@ -871,6 +881,12 @@ def _refresh_sqlite_integrity_status_locked() -> None:
871881
_sqlite_integrity_checked = True
872882
_sqlite_integrity_errors = []
873883
_sqlite_integrity_check_error = ""
884+
# This exit is its own kind of "no verdict" and does not yet name
885+
# itself (#2240). It must at least not inherit the previous
886+
# probe's reason: a palace that had no database when the server
887+
# started, and has an oversized one now, would otherwise be
888+
# described as having no database at all.
889+
_sqlite_integrity_no_verdict_reason = ""
874890
logger.warning(
875891
"SQLite startup integrity check skipped: %s is %.0f MB "
876892
"(> %.0f MB limit); PRAGMA quick_check would block MCP "
@@ -884,16 +900,35 @@ def _refresh_sqlite_integrity_status_locked() -> None:
884900
return
885901

886902
try:
887-
from .repair import sqlite_integrity_errors
903+
from .repair import sqlite_integrity_status
888904

889-
errors = sqlite_integrity_errors(_config.palace_path)
905+
status = sqlite_integrity_status(_config.palace_path)
890906
except Exception as exc:
891907
_sqlite_integrity_check_error = (
892908
f"sqlite integrity probe failed: {type(exc).__name__}: {exc}"
893909
)
894910
_sqlite_integrity_errors = [_sqlite_integrity_check_error]
911+
_sqlite_integrity_no_verdict_reason = ""
895912
else:
896-
_sqlite_integrity_errors = [str(error) for error in errors if str(error)]
913+
fresh_errors = [str(error) for error in status.errors if str(error)]
914+
# _sqlite_integrity_payload reads these globals without the lock, so
915+
# the order within each branch is chosen to keep that branch's own
916+
# window on the safer side: entering "no verdict" records the reason
917+
# before the list it explains, and leaving it clears the reason only
918+
# once the fresh errors are in place.
919+
#
920+
# No write order makes the payload safe, and this one does not claim
921+
# to. That reader loads the error list more than once, so a refresh
922+
# landing between two of its loads can still publish `ok: true` for a
923+
# palace with no database. Closing that means publishing the verdict as
924+
# one value, which is a change to what this gate stores rather than to
925+
# the order it stores it in.
926+
if status.checked:
927+
_sqlite_integrity_errors = fresh_errors
928+
_sqlite_integrity_no_verdict_reason = ""
929+
else:
930+
_sqlite_integrity_no_verdict_reason = status.reason
931+
_sqlite_integrity_errors = fresh_errors
897932
_sqlite_integrity_check_error = ""
898933

899934
_sqlite_integrity_checked = True
@@ -920,6 +955,17 @@ def _ensure_sqlite_integrity_status() -> None:
920955
def _sqlite_integrity_payload() -> dict:
921956
_ensure_sqlite_integrity_status()
922957

958+
# These globals are read one at a time, without the refresh lock, as they
959+
# have always been, and a refresh landing between two of those reads can
960+
# make this function publish a verdict the gate never held. Measured, both
961+
# directions produce one: reading through, as here, can answer `ok: true`
962+
# for a palace with no database; snapshotting into locals first can answer
963+
# `ok: true` for a palace whose corruption the refresh had just found,
964+
# because separate loads stay separate moments either way. A snapshot
965+
# therefore trades one window for another of the same class rather than
966+
# closing anything, which is why it is not taken here. Closing it means
967+
# publishing the verdict as one value, a change to what the gate stores.
968+
#
923969
# The integrity gate only knows how to check chroma.sqlite3, and
924970
# _refresh_sqlite_integrity_status short-circuits for non-chroma backends,
925971
# so on a non-chroma backend no quick_check runs. Reporting checked/ok true
@@ -947,6 +993,24 @@ def _sqlite_integrity_payload() -> dict:
947993
f"{backend_name or 'unknown'!r}"
948994
),
949995
}
996+
# Same shape, second way this payload reports an absent verdict: the
997+
# backend is chroma, but there was no database to open. Reporting
998+
# checked/ok true here would claim a quick_check that never ran. A
999+
# palace whose file is unreachable rather than absent does not arrive
1000+
# here at all: its probe recorded an error, so it falls through to the
1001+
# verdict payload below with `ok: false`.
1002+
if _sqlite_integrity_no_verdict_reason:
1003+
return {
1004+
"checked": False,
1005+
"ok": None,
1006+
"palace": _config.palace_path or "",
1007+
"sqlite_path": os.path.join(_config.palace_path, "chroma.sqlite3")
1008+
if _config.palace_path
1009+
else "",
1010+
"error_count": 0,
1011+
"errors": [],
1012+
"reason": _sqlite_integrity_no_verdict_reason,
1013+
}
9501014

9511015
payload = {
9521016
"checked": _sqlite_integrity_checked,
@@ -6979,7 +7043,12 @@ def _http_status_payload(httpd) -> dict:
69797043
os.path.abspath(os.path.expanduser(_config.palace_path)) if _config.palace_path else ""
69807044
)
69817045
return {
6982-
"ok": bool(integrity.get("ok")),
7046+
# `ok` is None in the two cases the payload reports as an absent
7047+
# verdict: a non-chroma backend (#1931), and a chroma palace with no
7048+
# database file yet. That is an absence, not a failure, and collapsing
7049+
# it with bool() would report a freshly installed server as unhealthy.
7050+
# A missing key is not one of those cases and still fails closed.
7051+
"ok": integrity.get("ok", False) is not False,
69837052
"server": {
69847053
"name": "mempalace",
69857054
"version": __version__,

mempalace/repair.py

Lines changed: 121 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import time
3939
from collections import defaultdict
4040
from contextlib import closing
41+
from dataclasses import dataclass
4142
from datetime import datetime
4243
import re
4344
from typing import Callable, Iterator, Optional
@@ -732,23 +733,71 @@ def sqlite_drawer_count(palace_path: str, collection_name: Optional[str] = None)
732733
_SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS = 15.0
733734

734735

735-
def sqlite_integrity_errors(palace_path: str) -> list[str]:
736-
"""Return SQLite quick_check errors for chroma.sqlite3.
736+
@dataclass(frozen=True)
737+
class SqliteIntegrityStatus:
738+
"""Whether a quick_check verdict exists for a palace, and what it says.
737739
738-
The repair rebuild path eventually calls Chroma's delete_collection().
739-
If the SQLite layer has corrupt secondary indexes or FTS5 shadow pages,
740-
Chroma can raise an opaque SQLITE_CORRUPT_INDEX / code 779 error before
741-
repair reaches the HNSW rebuild.
740+
``checked`` False means no probe ran, so an empty ``errors`` says nothing
741+
about the database. That is the distinction :func:`sqlite_integrity_errors`
742+
cannot express: it answers ``[]`` both for a database quick_check found
743+
intact and for a palace that has none to open.
742744
743-
Run a direct SQLite quick_check first so repair can fail with a clear,
744-
actionable message before invoking Chroma's destructive collection-delete
745-
path.
745+
``errors`` is a tuple rather than a list so ``frozen=True`` means what it
746+
says: a list field would leave the generated ``__hash__`` raising
747+
``TypeError`` on every call, and would let a caller append to a verdict it
748+
was handed.
746749
"""
747750

748-
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
749-
if not os.path.exists(sqlite_path):
750-
return []
751-
751+
checked: bool
752+
errors: tuple[str, ...]
753+
reason: str
754+
755+
756+
def _integrity_target_is_absent(sqlite_path: str) -> bool:
757+
"""Return True only when nothing resolves under ``sqlite_path``.
758+
759+
``ENOENT`` is the one errno this accepts as proof, because it cannot mean
760+
anything else: no file answered to that path. It does not say which
761+
component was missing, so it is proof about the path and not about the
762+
palace. A palace directory that is itself a dangling symlink, and a mount
763+
point with nothing mounted on it, both reach here as ``ENOENT`` and are
764+
reported as no verdict. That is the same answer ``develop`` gives, only
765+
without ``develop``'s claim that the check passed.
766+
767+
``ENOTDIR`` would be proof too and is deliberately left to the open
768+
attempt, which reports it rather than silently treating it as nothing to
769+
do. Windows raises ``ENOENT`` for that case, so it is proven absent there;
770+
both answers are safe. Every other failure, a parent directory this process
771+
may not enter for one, leaves the question open and takes the same route.
772+
False therefore means "not proven absent", not "the file is there".
773+
774+
``os.path.exists`` cannot draw that line: it follows symlinks and folds
775+
every ``OSError`` into False, so a dangling link and an unreadable
776+
directory both read as "no database here". ``ValueError`` (an embedded NUL
777+
in the path) is caught for the same reason as any ``OSError``: it does not
778+
prove absence either, and callers that never handled it still never see
779+
it. What they do see changes, as it does for the other unreadable paths:
780+
the open attempt reports the failure instead of returning nothing.
781+
"""
782+
try:
783+
os.lstat(sqlite_path)
784+
except FileNotFoundError:
785+
return True
786+
except (OSError, ValueError):
787+
return False
788+
return False
789+
790+
791+
def _quick_check_errors(sqlite_path: str) -> list[str]:
792+
"""Run ``PRAGMA quick_check`` against ``sqlite_path`` and report what it says.
793+
794+
There is no absence gate here on purpose. A caller that has already
795+
established the file's state passes the path straight through, so the answer
796+
always comes from an open attempt. Re-testing for absence would reopen the
797+
hole this module exists to close: a file that vanishes between the two tests
798+
would come back as an empty error list, which reads as a clean verdict for a
799+
database nobody opened.
800+
"""
752801
try:
753802
# A writer holding SQLite's lock is contention, not corruption. The
754803
# sqlite3 module defaults to five seconds, which is shorter than
@@ -775,6 +824,65 @@ def sqlite_integrity_errors(palace_path: str) -> list[str]:
775824
return errors
776825

777826

827+
def sqlite_integrity_status(palace_path: str) -> SqliteIntegrityStatus:
828+
"""Run the quick_check probe and report whether it produced a verdict.
829+
830+
Callers that state an integrity result to an operator want this rather
831+
than :func:`sqlite_integrity_errors`, whose empty list cannot separate a
832+
clean database from one that was never opened.
833+
"""
834+
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
835+
if _integrity_target_is_absent(sqlite_path):
836+
return SqliteIntegrityStatus(
837+
checked=False,
838+
errors=(),
839+
reason=(
840+
f"no quick_check ran: {sqlite_path} does not exist, so there "
841+
"was no SQLite database to open"
842+
),
843+
)
844+
# _quick_check_errors, not sqlite_integrity_errors: that one gates on
845+
# absence again, and a file unlinked between the two gates would come back
846+
# as an empty list, which is the clean verdict this function exists to
847+
# withhold. Past the gate above, only an open attempt may answer.
848+
return SqliteIntegrityStatus(
849+
checked=True,
850+
errors=tuple(_quick_check_errors(sqlite_path)),
851+
reason="",
852+
)
853+
854+
855+
def sqlite_integrity_errors(palace_path: str) -> list[str]:
856+
"""Return SQLite quick_check errors for chroma.sqlite3.
857+
858+
The repair rebuild path eventually calls Chroma's delete_collection().
859+
If the SQLite layer has corrupt secondary indexes or FTS5 shadow pages,
860+
Chroma can raise an opaque SQLITE_CORRUPT_INDEX / code 779 error before
861+
repair reaches the HNSW rebuild.
862+
863+
Run a direct SQLite quick_check first so repair can fail with a clear,
864+
actionable message before invoking Chroma's destructive collection-delete
865+
path.
866+
867+
An empty list means one of two things and cannot tell them apart: the
868+
check ran and found nothing, or the palace provably has no database to
869+
check. Callers stating a result to an operator want
870+
:func:`sqlite_integrity_status` instead. A path that resolves to a
871+
directory entry but cannot be opened — a dangling symlink, a database
872+
under a directory this process may not enter — is reported here as an
873+
error, since the probe did fail.
874+
"""
875+
876+
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
877+
# Absence is the one state with nothing to report; anything else that
878+
# cannot be read is reported by the open attempt below. Callers that need
879+
# to tell an absent database from a clean one use sqlite_integrity_status.
880+
if _integrity_target_is_absent(sqlite_path):
881+
return []
882+
883+
return _quick_check_errors(sqlite_path)
884+
885+
778886
def print_sqlite_integrity_abort(palace_path: str, errors: list[str]) -> None:
779887
"""Print a clear repair abort message for SQLite-layer corruption."""
780888

0 commit comments

Comments
 (0)