Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts: # .gitignore # Dockerfile # agent/onboarding.py # apps/desktop/electron/main.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/src/components/model-picker.test.tsx # apps/desktop/src/store/updates.ts # apps/desktop/vite.config.ts # datagen-config-examples/run_browser_tasks.sh # docs/rca-ssl-cacert-post-git-pull.md # gateway/run.py # hermes_cli/backup.py # hermes_cli/credential_lifecycle.py # hermes_cli/dashboard_procs.py # hermes_cli/doctor_state.py # hermes_cli/env_loader.py # hermes_cli/gateway_windows.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/psutil_android.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_windows.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_server_config.py # hermes_cli/web_server_cron.py # plugins/memory/hindsight/__init__.py # plugins/memory/holographic/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/mem0/__init__.py # plugins/platforms/google_chat/oauth.py # plugins/platforms/photon/adapter.py # scripts/ci/list_os_marked_tests.py # scripts/run_tests.sh # tests/agent/test_compression_stall_fallback.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/gateway/test_google_chat_oauth_dependencies.py # tests/hermes_cli/conftest.py # tests/hermes_cli/test_cli_init.py # tests/hermes_cli/test_gateway_migrate_multiplex.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_relaunch.py # tests/hermes_cli/test_update_check.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_worktree_gc.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_autostash_conflict_recovery.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_commit_pin_rollback.py # tests/scripts/install/test_install_diverged_update.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_macos_launcher.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_ps1_ascii_only.py # tests/scripts/install/test_install_ps1_browser_install.py # tests/scripts/install/test_install_ps1_managed_node_swap.py # tests/scripts/install/test_install_ps1_native_stderr_eap.py # tests/scripts/install/test_install_ps1_node_path_for_npm.py # tests/scripts/install/test_install_ps1_python_fallback_venv.py # tests/scripts/install/test_install_ps1_resolver_strictmode.py # tests/scripts/install/test_install_ps1_uv_install_fallback.py # tests/scripts/install/test_install_ps1_uv_powershell_host.py # tests/scripts/install/test_install_ps1_venv_process_tree.py # tests/scripts/install/test_install_ps1_venv_recreate_safety.py # tests/scripts/install/test_install_ps1_venv_rename_abort.py # tests/scripts/install/test_install_ps1_venv_transaction_boundary.py # tests/scripts/install/test_install_ps1_web_server_syntax_probe.py # tests/scripts/install/test_install_scripts_computer_use.py # tests/scripts/install/test_install_sh_acp_launcher.py # tests/scripts/install/test_install_sh_bootstrap_marker.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_install_method_stamp.py # tests/scripts/install/test_install_sh_node_deps_failure.py # tests/scripts/install/test_install_sh_node_deps_workspaces.py # tests/scripts/install/test_install_sh_node_global_prefix.py # tests/scripts/install/test_install_sh_node_npm_check.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_node_probe.py # tests/scripts/install/test_install_sh_node_tarball_without_xz.py # tests/scripts/install/test_install_sh_pythonpath_sanitization.py # tests/scripts/install/test_install_sh_reuse_supported_python.py # tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py # tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_termux_network_prereqs.py # tests/scripts/install/test_install_sh_termux_python_bounds.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_run_tests_parallel.py # tests/test_managed_runtime_resolution.py # tests/test_project_metadata.py # tests/tools/test_browser_use_cli.py # tests/tools/test_tts_pythonpath_fallback.py # tests/tui_gateway/test_hosted_room_driver_runtime.py # tests/tui_gateway/test_tui_gateway_server.py # tools/lazy_deps.py # tools/voice_mode.py # uv.lock # website/docs/developer-guide/macos-bundle-updates.md # website/docs/developer-guide/pm-audit-status.md # website/docs/developer-guide/shared-bundle-builds.md # website/docs/developer-guide/source-update-completion.md # website/docs/developer-guide/stable-releases.md
This commit is contained in:
@@ -15,6 +15,7 @@ import queue
|
||||
import random
|
||||
import re
|
||||
import sqlite3
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -23,7 +24,6 @@ from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from agent.message_sanitization import _sanitize_surrogates
|
||||
from hermes_constants import get_hermes_home, mkdir_under_hermes_home
|
||||
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar, cast
|
||||
|
||||
@@ -45,14 +45,16 @@ from hermes_state_portability import SessionPortabilityMixin
|
||||
from hermes_state_telegram import SessionTelegramTopicsMixin
|
||||
from hermes_state_schema import SessionSchemaMixin
|
||||
import hermes_state_holders as _state_holders
|
||||
import hermes_state_lockguard as _lockguard
|
||||
from hermes_state_dbfile import (
|
||||
_canonical_sqlite_path, _connect_tracked_db, _fd_is_truly_unlinked, _prepare_connection_retirement,
|
||||
_connect_tracked_db, _fd_is_truly_unlinked, _prepare_connection_retirement,
|
||||
_read_sqlite_application_id, _stat_sqlite_sidecar_identity,
|
||||
_watched_sqlite_sidecar_paths, has_invalid_sqlite_header_preopen, is_zeroed_state_db, quarantine_cross_process_lock,
|
||||
quarantine_invalid_state_db,
|
||||
RetiredGenerationCaptureError, capture_retired_wal_generation, refuse_deleted_wal_generation,
|
||||
)
|
||||
from hermes_state_messages import SessionMessagesMixin
|
||||
from hermes_state_rewind import SessionRewindMixin
|
||||
from hermes_state_wal import (
|
||||
_WAL_INCOMPAT_MARKERS, _on_disk_journal_mode, apply_database_pragmas, apply_wal_with_fallback,
|
||||
)
|
||||
@@ -143,11 +145,6 @@ def _compression_lock_holder_process_is_dead(holder: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _scrub_surrogates(value: Any) -> Any:
|
||||
"""Replace lone surrogates in text (sqlite3 raises UnicodeEncodeError, aborting the whole write)."""
|
||||
return _sanitize_surrogates(value) if isinstance(value, str) else value
|
||||
|
||||
|
||||
# Billing buckets that aren't a routable provider identity: a session that persisted only
|
||||
# one of these (never ran /model) falls back to the config default. Shared by
|
||||
# session_gateway_runtime and tui_gateway.server so they cannot drift.
|
||||
@@ -218,6 +215,65 @@ def _ensure_test_isolation(db_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _secure_state_db_files(db_path: Path, *, create_main: bool = False) -> None:
|
||||
"""Create/tighten a writable state database and its sidecars to 0600.
|
||||
|
||||
SQLite otherwise creates ``state.db``, ``-wal``, and ``-shm`` according to
|
||||
the process umask (commonly 0644 under 0022). Read-only SessionDB
|
||||
attachments never call this helper and remain observational.
|
||||
|
||||
Existing files are tightened with ``chmod(2)`` on the path: opening the
|
||||
file and closing that descriptor would drop every POSIX ``fcntl`` lock the
|
||||
process holds on its inode — including the locks of an already-open SQLite
|
||||
connection to the same database. A lock-losing close in one process lets a
|
||||
sibling's connection take the shared-memory DMS exclusively at its own
|
||||
close, checkpoint, and unlink the sidecars while long-lived holders
|
||||
(gateway, desktop ``hermes serve``) keep using the deleted inodes.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return
|
||||
|
||||
main_path = db_path
|
||||
if create_main:
|
||||
# O_EXCL: only a brand-new inode gets a descriptor. Opening an existing
|
||||
# file here and closing it would drop this process's POSIX locks on it.
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
if hasattr(os, "O_CLOEXEC"):
|
||||
flags |= os.O_CLOEXEC
|
||||
try:
|
||||
fd = os.open(main_path, flags, 0o600)
|
||||
except FileExistsError:
|
||||
pass
|
||||
except IsADirectoryError:
|
||||
# Not a database file at all; sqlite3.connect() raises the
|
||||
# canonical error for this, and a directory leaks no row data.
|
||||
return
|
||||
else:
|
||||
os.close(fd)
|
||||
|
||||
for path in (
|
||||
main_path,
|
||||
db_path.with_name(db_path.name + "-wal"),
|
||||
db_path.with_name(db_path.name + "-shm"),
|
||||
):
|
||||
# fchmod on an fd of a pre-existing file cannot be used here: close(fd)
|
||||
# would release this process's POSIX locks on that inode, stripping the
|
||||
# locks of any live SQLite connection to the same database. chmod(2)
|
||||
# never opens the file, so it leaves the lock state untouched.
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
# Refuse a planted symlink exactly like O_NOFOLLOW would.
|
||||
continue
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
continue
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
# Openings of the background-review harness prompts (agent/background_review.py).
|
||||
_REVIEW_HARNESS_PREFIXES = (
|
||||
"Review the conversation above and update the skill library",
|
||||
@@ -338,7 +394,7 @@ class SessionDB(
|
||||
SessionSessionsMixin, SessionFtsSetupMixin, SessionSearchMixin, SessionSchemaMixin,
|
||||
SessionPortabilityMixin, SessionTelegramTopicsMixin, SessionCompressionMixin,
|
||||
SessionGatewayMixin, SessionMaintenanceMixin, SessionUsageMixin, SessionTitlesMixin,
|
||||
SessionMessagesMixin,
|
||||
SessionMessagesMixin, SessionRewindMixin,
|
||||
):
|
||||
"""SQLite-backed session storage with FTS5 search; many reader threads, one writer (WAL)."""
|
||||
|
||||
@@ -466,6 +522,7 @@ class SessionDB(
|
||||
self._retired_capture_lock = threading.Lock()
|
||||
self._retire_connection: Optional[Callable[[Any], None]] = None
|
||||
self._connection_pinned = False # one unmatched C reference taken at most once per handle
|
||||
self._wal_lock_guard: dict = {} # hermes_state_lockguard.hold() record, see _open_writer
|
||||
self._db_corrupt, self._db_corrupt_reason = False, "" # sticky quarantine (StateDbCorruptError)
|
||||
self._fts_usermerge_floor_applied = False # one-shot usermerge-floor write guard
|
||||
self._fts_enabled = self._fts_stale = self._trigram_available = False
|
||||
@@ -544,6 +601,11 @@ class SessionDB(
|
||||
self._connect_and_init_with_lock_patience()
|
||||
# FTS optimization is OPT-IN (`hermes db optimize`); no background worker races session lifecycle.
|
||||
self._ensure_db_file_generation()
|
||||
if self._wal_active:
|
||||
# OFD copies of the two POSIX locks that keep a sibling's close from unlinking this WAL
|
||||
# generation: any in-process open()/close() of state.db or -shm cancels SQLite's own
|
||||
# (howtocorrupt §2.2); these survive it. Lifted in close().
|
||||
self._wal_lock_guard = _lockguard.hold(self.db_path)
|
||||
|
||||
def _open_read_only(self) -> None:
|
||||
"""Read-only attach for cross-profile aggregation: no schema init, NO write
|
||||
@@ -625,6 +687,9 @@ class SessionDB(
|
||||
# Unknown -> reads queue on the writer lock (slow but correct) instead of racing SQLITE_BUSY
|
||||
# on a file that may really be in rollback-journal mode.
|
||||
self._wal_active = mode == "wal" and _on_disk_journal_mode(conn) == "wal"
|
||||
# Existing WAL/SHM files may predate the main-file hardening;
|
||||
# normalize any sidecars that became visible during WAL setup.
|
||||
_secure_state_db_files(self.db_path)
|
||||
apply_database_pragmas(conn, db_label="state.db")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
self._fts_cjk_loaded = load_fts5_cjk_extension(conn)
|
||||
@@ -637,6 +702,9 @@ class SessionDB(
|
||||
# Refuse before sqlite3.connect (under the startup lock) so we cannot mint
|
||||
# a replacement WAL while a live writer still holds a deleted sidecar inode.
|
||||
refuse_deleted_wal_generation(self.db_path)
|
||||
# Create/tighten the main database before sqlite3.connect() so a
|
||||
# permissive process umask can never expose a fresh profile store.
|
||||
_secure_state_db_files(self.db_path, create_main=True)
|
||||
self._conn = self._open_writer_conn()
|
||||
self._init_schema()
|
||||
|
||||
@@ -797,6 +865,8 @@ class SessionDB(
|
||||
f"in flight (a session-teardown path called close() before "
|
||||
f"this worker finished — #94736) and the automatic reopen failed: {exc}"
|
||||
) from exc
|
||||
if self._wal_active: # a reopened writer is a live generation holder like the first open
|
||||
self._wal_lock_guard = _lockguard.hold(self.db_path)
|
||||
|
||||
def _execute_write(
|
||||
self, fn: Callable[[sqlite3.Connection], T], patience_s: Optional[float] = None,
|
||||
@@ -1044,7 +1114,7 @@ class SessionDB(
|
||||
watched = _watched_sqlite_sidecar_paths(self.db_path)
|
||||
try:
|
||||
for target, fd_path in _proc_fd_targets(os.getpid()):
|
||||
canonical = _canonical_sqlite_path(target)
|
||||
canonical = _state_holders.canonical_sqlite_path(target)
|
||||
if (" (deleted)" in target and canonical in watched
|
||||
and _fd_is_truly_unlinked(fd_path, watched[canonical])):
|
||||
return True
|
||||
@@ -1264,6 +1334,10 @@ class SessionDB(
|
||||
return
|
||||
try:
|
||||
with self._lock:
|
||||
if self._conn is None:
|
||||
return # closed underneath the timer: nothing to checkpoint, nothing to re-guard
|
||||
if self._wal_lock_guard:
|
||||
_lockguard.hold(self.db_path, self._wal_lock_guard) # a -shm minted after open
|
||||
result = self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)").fetchone()
|
||||
if result and result[1] > 0:
|
||||
logger.debug("WAL checkpoint: %d/%d pages checkpointed", result[2], result[1])
|
||||
@@ -1340,6 +1414,7 @@ class SessionDB(
|
||||
self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
except Exception as exc:
|
||||
logger.debug("WAL checkpoint (PASSIVE) at close failed: %s", exc)
|
||||
_lockguard.release(self._wal_lock_guard) # before the close: see release()
|
||||
if retire_without_close:
|
||||
self._pin_connection(self._conn)
|
||||
self._conn = None
|
||||
|
||||
Reference in New Issue
Block a user