superlocalmemory 3.8.13 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- package/src/superlocalmemory/storage/access_control.py +0 -182
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""mDNS/DNS-SD advertising for the SLM daemon mesh substrate.
|
|
2
|
+
|
|
3
|
+
This module provides :class:`MeshAdvertiser`, which registers the running SLM
|
|
4
|
+
daemon as a ``_slm-mesh._tcp.local.`` service so that other SLM instances on
|
|
5
|
+
the same LAN can discover it via :class:`~superlocalmemory.mesh.remote_sync.RemoteSyncClient`.
|
|
6
|
+
|
|
7
|
+
Advertising is **opt-in and disabled by default** (backward-compatible).
|
|
8
|
+
Existing installations see zero behavior change. Enable via::
|
|
9
|
+
|
|
10
|
+
SLM_MESH_ADVERTISE=1 # also accepts: on, true, yes (case-insensitive)
|
|
11
|
+
|
|
12
|
+
WHY OPT-IN: SLM runs on corporate-managed endpoints where unsolicited mDNS
|
|
13
|
+
multicast traffic may violate network policy or trigger security tooling.
|
|
14
|
+
Advertising fires only when an operator explicitly sets ``SLM_MESH_ADVERTISE``.
|
|
15
|
+
|
|
16
|
+
Service type: ``_slm-mesh._tcp.local.``
|
|
17
|
+
This MUST match the type browsed by :class:`RemoteSyncClient` in
|
|
18
|
+
``superlocalmemory.mesh.remote_sync`` (grep ``_slm-mesh._tcp.local.``).
|
|
19
|
+
|
|
20
|
+
Instance name: ``slm-{node_id}-{port}._slm-mesh._tcp.local.``
|
|
21
|
+
The port suffix prevents :exc:`~zeroconf.NonUniqueNameException` when two
|
|
22
|
+
SLM daemon instances run on the same host (e.g. primary on 8765, test on 8766).
|
|
23
|
+
|
|
24
|
+
Properties TXT record: contains ``node_id`` and any caller-supplied metadata.
|
|
25
|
+
**NEVER include secrets, tokens, or passwords** — TXT records are broadcast in
|
|
26
|
+
plaintext across the LAN and are visible to every host on the subnet.
|
|
27
|
+
|
|
28
|
+
All operations are fail-soft: any error is logged as WARNING and the daemon
|
|
29
|
+
continues. ``stop()`` is idempotent.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
import socket
|
|
37
|
+
import threading
|
|
38
|
+
from socket import inet_aton
|
|
39
|
+
from typing import Any, Optional
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Constants
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
_SERVICE_TYPE: str = "_slm-mesh._tcp.local."
|
|
48
|
+
"""DNS-SD service type — must match what RemoteSyncClient browses."""
|
|
49
|
+
|
|
50
|
+
_ADVERTISE_TRUTHY: frozenset[str] = frozenset({"1", "on", "true", "yes"})
|
|
51
|
+
"""Accepted values for SLM_MESH_ADVERTISE (matched case-insensitively)."""
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Optional zeroconf dependency guard
|
|
55
|
+
# Mirrors the exact pattern used in remote_sync.py so both modules behave
|
|
56
|
+
# identically when zeroconf is not installed.
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
try:
|
|
59
|
+
from zeroconf import ServiceInfo, Zeroconf
|
|
60
|
+
|
|
61
|
+
ZEROCONF_AVAILABLE: bool = True
|
|
62
|
+
except ImportError:
|
|
63
|
+
ZEROCONF_AVAILABLE = False
|
|
64
|
+
Zeroconf = None # type: ignore[assignment,misc]
|
|
65
|
+
ServiceInfo = None # type: ignore[assignment]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Internal helpers
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_advertising_enabled() -> bool:
|
|
74
|
+
"""Return True only when ``SLM_MESH_ADVERTISE`` is set to a truthy value.
|
|
75
|
+
|
|
76
|
+
Case-insensitive: ``'1'``, ``'on'``, ``'true'``, ``'yes'`` are accepted.
|
|
77
|
+
Everything else (including unset) returns False (default-OFF, BC-safe).
|
|
78
|
+
"""
|
|
79
|
+
return (
|
|
80
|
+
os.environ.get("SLM_MESH_ADVERTISE", "").strip().lower()
|
|
81
|
+
in _ADVERTISE_TRUTHY
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _resolve_advertise_ip(injected_ip: Optional[str] = None) -> str:
|
|
86
|
+
"""Return the best local non-loopback IPv4 address for mDNS advertising.
|
|
87
|
+
|
|
88
|
+
Uses a UDP connect trick (no packet is sent — the kernel consults the
|
|
89
|
+
routing table to decide which source address would be used to reach a
|
|
90
|
+
well-known public address). Falls back to ``127.0.0.1`` when:
|
|
91
|
+
|
|
92
|
+
* No routable interface is found.
|
|
93
|
+
* The resolved address is itself a loopback address.
|
|
94
|
+
* Any :exc:`OSError` is raised (e.g. no network at all).
|
|
95
|
+
|
|
96
|
+
The ``127.0.0.1`` fallback allows two-daemon integration tests on the
|
|
97
|
+
loopback interface to work without a real LAN.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
injected_ip: When provided, skip resolution entirely and return this
|
|
101
|
+
value. Used by tests to avoid real socket calls.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A dotted-decimal IPv4 address string.
|
|
105
|
+
"""
|
|
106
|
+
if injected_ip is not None:
|
|
107
|
+
return injected_ip
|
|
108
|
+
# Operator override for multi-homed / VPN hosts where the auto-detected
|
|
109
|
+
# route would advertise the wrong NIC (audit P2). Explicit wins.
|
|
110
|
+
env_ip = os.environ.get("SLM_MESH_ADVERTISE_IP", "").strip()
|
|
111
|
+
if env_ip:
|
|
112
|
+
return env_ip
|
|
113
|
+
try:
|
|
114
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
115
|
+
sock.connect(("8.8.8.8", 80))
|
|
116
|
+
ip: str = sock.getsockname()[0]
|
|
117
|
+
if ip and not ip.startswith("127."):
|
|
118
|
+
return ip
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
# No routable non-loopback interface — peers off this host cannot reach a
|
|
122
|
+
# 127.0.0.1 advertisement. Warn so the operator can set SLM_MESH_ADVERTISE_IP.
|
|
123
|
+
logger.warning(
|
|
124
|
+
"MeshAdvertiser: no routable non-loopback IPv4 found; advertising "
|
|
125
|
+
"127.0.0.1 (off-host peers cannot reach this node). Set "
|
|
126
|
+
"SLM_MESH_ADVERTISE_IP to advertise a reachable address."
|
|
127
|
+
)
|
|
128
|
+
return "127.0.0.1"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# MeshAdvertiser
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class MeshAdvertiser:
|
|
137
|
+
"""Register this SLM daemon as a discoverable mDNS service on the LAN.
|
|
138
|
+
|
|
139
|
+
Advertising is **disabled by default** (opt-in via ``SLM_MESH_ADVERTISE``).
|
|
140
|
+
|
|
141
|
+
Lifecycle::
|
|
142
|
+
|
|
143
|
+
advertiser = MeshAdvertiser(service_port=8765, node_id="hostname")
|
|
144
|
+
advertiser.start() # no-op unless SLM_MESH_ADVERTISE is truthy
|
|
145
|
+
...
|
|
146
|
+
advertiser.stop() # idempotent; safe to call even if never started
|
|
147
|
+
|
|
148
|
+
Thread safety:
|
|
149
|
+
``start()`` and ``stop()`` are protected by a :class:`threading.Lock`.
|
|
150
|
+
The lock prevents double-registration and eliminates the race between
|
|
151
|
+
a slow ``register_service`` call and a concurrent ``stop()`` call:
|
|
152
|
+
``start()`` holds the lock for the entire registration so ``stop()``
|
|
153
|
+
sees a consistent, fully-initialised state.
|
|
154
|
+
|
|
155
|
+
CRIT fixes applied:
|
|
156
|
+
1. **Instance-name collision** — port is embedded in the instance label
|
|
157
|
+
(``slm-{node_id}-{port}``), so two daemons on the same host register
|
|
158
|
+
distinct names and avoid :exc:`~zeroconf.NonUniqueNameException`.
|
|
159
|
+
2. **Event-loop blocking** — ``register_service`` probes for ~750 ms.
|
|
160
|
+
Callers on an async startup path MUST wrap ``start()`` in
|
|
161
|
+
``asyncio.to_thread(advertiser.start)`` (see lifespan wiring in
|
|
162
|
+
``unified_daemon.py``). ``MeshAdvertiser`` itself remains sync so
|
|
163
|
+
it is trivially testable without an event loop.
|
|
164
|
+
3. **stop() / register race** — ``_lock`` is held for the entire
|
|
165
|
+
duration of ``_start_locked()``, so a concurrent ``stop()`` blocks
|
|
166
|
+
until registration completes. ``stop()`` clears state under the
|
|
167
|
+
lock before releasing it, preventing double-close.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
service_port: int,
|
|
173
|
+
node_id: str,
|
|
174
|
+
properties: Optional[dict[str, str]] = None,
|
|
175
|
+
*,
|
|
176
|
+
_injected_ip: Optional[str] = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Create a MeshAdvertiser.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
service_port: TCP port the SLM HTTP daemon is bound to.
|
|
182
|
+
node_id: Stable unique identifier for this node (hostname is a
|
|
183
|
+
good choice). Must be safe for DNS labels.
|
|
184
|
+
properties: Optional extra metadata broadcast in the mDNS TXT
|
|
185
|
+
record. Keep tiny. **NEVER include secrets** — TXT
|
|
186
|
+
records are visible in plaintext to every host on the
|
|
187
|
+
LAN.
|
|
188
|
+
_injected_ip: Test hook — override IP resolution. Not part of the
|
|
189
|
+
public API.
|
|
190
|
+
"""
|
|
191
|
+
self._service_port: int = service_port
|
|
192
|
+
self._node_id: str = node_id
|
|
193
|
+
# Defensive copy — immutable style; caller can't mutate our dict later.
|
|
194
|
+
self._properties: dict[str, str] = dict(properties) if properties else {}
|
|
195
|
+
self._injected_ip: Optional[str] = _injected_ip
|
|
196
|
+
|
|
197
|
+
# Mutable state — all mutations happen under _lock.
|
|
198
|
+
self._zeroconf: Any = None
|
|
199
|
+
self._info: Any = None
|
|
200
|
+
self._advertising: bool = False
|
|
201
|
+
self._lock: threading.Lock = threading.Lock()
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# Public API
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def start(self) -> None:
|
|
208
|
+
"""Register the SLM service in mDNS.
|
|
209
|
+
|
|
210
|
+
No-op when:
|
|
211
|
+
* zeroconf is not installed (``ZEROCONF_AVAILABLE is False``).
|
|
212
|
+
* ``SLM_MESH_ADVERTISE`` is unset or not in the truthy set.
|
|
213
|
+
|
|
214
|
+
Any exception from the zeroconf stack is caught, logged as WARNING,
|
|
215
|
+
and swallowed — the daemon must NEVER fail to start because of
|
|
216
|
+
optional mDNS advertising.
|
|
217
|
+
|
|
218
|
+
Note:
|
|
219
|
+
``register_service`` blocks for ~750 ms (mDNS probe phase).
|
|
220
|
+
Call this from an async context via ``asyncio.to_thread(advertiser.start)``.
|
|
221
|
+
"""
|
|
222
|
+
if not ZEROCONF_AVAILABLE:
|
|
223
|
+
logger.debug(
|
|
224
|
+
"MeshAdvertiser.start: zeroconf not installed; skipping mDNS advertising"
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
if not _is_advertising_enabled():
|
|
228
|
+
logger.debug(
|
|
229
|
+
"MeshAdvertiser.start: SLM_MESH_ADVERTISE not set (or not truthy); "
|
|
230
|
+
"mDNS advertising remains disabled (default-OFF, BC-safe)"
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
with self._lock:
|
|
235
|
+
if self._advertising:
|
|
236
|
+
# Idempotent — already registered; nothing to do.
|
|
237
|
+
return
|
|
238
|
+
try:
|
|
239
|
+
self._start_locked()
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
logger.warning(
|
|
242
|
+
"MeshAdvertiser.start failed (non-fatal, daemon continues): %s", exc
|
|
243
|
+
)
|
|
244
|
+
# Clean up any partial state so stop() is still safe.
|
|
245
|
+
self._zeroconf = None
|
|
246
|
+
self._info = None
|
|
247
|
+
self._advertising = False
|
|
248
|
+
|
|
249
|
+
def stop(self) -> None:
|
|
250
|
+
"""Unregister the mDNS service and close Zeroconf.
|
|
251
|
+
|
|
252
|
+
Idempotent — safe to call multiple times or when ``start()`` was never
|
|
253
|
+
called. Any exception is caught and logged as WARNING.
|
|
254
|
+
|
|
255
|
+
CRIT note: state is cleared under ``_lock`` *before* calling
|
|
256
|
+
``unregister_service`` / ``close``, so a second concurrent ``stop()``
|
|
257
|
+
sees ``_advertising=False`` and exits immediately without attempting
|
|
258
|
+
to close an already-closed Zeroconf instance.
|
|
259
|
+
"""
|
|
260
|
+
with self._lock:
|
|
261
|
+
if not self._advertising:
|
|
262
|
+
return
|
|
263
|
+
# Capture and clear state under the lock atomically.
|
|
264
|
+
zc: Any = self._zeroconf
|
|
265
|
+
info: Any = self._info
|
|
266
|
+
self._advertising = False
|
|
267
|
+
self._zeroconf = None
|
|
268
|
+
self._info = None
|
|
269
|
+
|
|
270
|
+
# Perform I/O outside the lock so a concurrent stop() call (highly
|
|
271
|
+
# unlikely given daemon lifecycle, but possible in tests) can't
|
|
272
|
+
# deadlock waiting for us.
|
|
273
|
+
# Audit P1: close() MUST run even when unregister_service raises,
|
|
274
|
+
# otherwise the Zeroconf multicast sockets + threads leak. Best-effort
|
|
275
|
+
# unregister, then close in a finally.
|
|
276
|
+
try:
|
|
277
|
+
if zc is not None and info is not None:
|
|
278
|
+
try:
|
|
279
|
+
zc.unregister_service(info)
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
logger.warning(
|
|
282
|
+
"MeshAdvertiser.stop: unregister failed (closing anyway): %s",
|
|
283
|
+
exc,
|
|
284
|
+
)
|
|
285
|
+
finally:
|
|
286
|
+
if zc is not None:
|
|
287
|
+
try:
|
|
288
|
+
zc.close()
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
logger.warning(
|
|
291
|
+
"MeshAdvertiser.stop: close failed (non-fatal): %s", exc
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def is_advertising(self) -> bool:
|
|
296
|
+
"""True when the service is currently registered in mDNS."""
|
|
297
|
+
return self._advertising
|
|
298
|
+
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
# Internal
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def _start_locked(self) -> None:
|
|
304
|
+
"""Build :class:`~zeroconf.ServiceInfo` and register it.
|
|
305
|
+
|
|
306
|
+
Called under ``self._lock``. Raises on any error; the caller
|
|
307
|
+
(``start()``) catches and logs.
|
|
308
|
+
"""
|
|
309
|
+
ip: str = _resolve_advertise_ip(self._injected_ip)
|
|
310
|
+
|
|
311
|
+
# Instance label carries port AND pid so the mDNS name is unique:
|
|
312
|
+
# - port disambiguates two daemons on the SAME host (CRIT fix #1);
|
|
313
|
+
# - pid disambiguates two machines that share a short hostname on the
|
|
314
|
+
# segment (audit P2 — cross-machine NonUniqueNameException).
|
|
315
|
+
# The TXT node_id stays hostname-only (see _properties below).
|
|
316
|
+
instance_label: str = f"slm-{self._node_id}-{self._service_port}-{os.getpid()}"
|
|
317
|
+
service_name: str = f"{instance_label}.{_SERVICE_TYPE}"
|
|
318
|
+
|
|
319
|
+
# Build bytes-keyed properties dict (zeroconf API requirement).
|
|
320
|
+
# SECURITY: only whitelisted string content — never mirror env vars.
|
|
321
|
+
# TXT records are broadcast in plaintext to the entire LAN subnet.
|
|
322
|
+
safe_props: dict[str, str] = {
|
|
323
|
+
"node_id": self._node_id,
|
|
324
|
+
**self._properties,
|
|
325
|
+
}
|
|
326
|
+
byte_props: dict[bytes, bytes] = {
|
|
327
|
+
k.encode("utf-8"): v.encode("utf-8") for k, v in safe_props.items()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
info = ServiceInfo(
|
|
331
|
+
type_=_SERVICE_TYPE,
|
|
332
|
+
name=service_name,
|
|
333
|
+
addresses=[inet_aton(ip)],
|
|
334
|
+
port=self._service_port,
|
|
335
|
+
properties=byte_props,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
zc = Zeroconf()
|
|
339
|
+
# register_service sends mDNS probe + announce packets.
|
|
340
|
+
# This call blocks for ~750 ms (3 × 250 ms probe interval).
|
|
341
|
+
# Callers on the async event loop MUST use asyncio.to_thread().
|
|
342
|
+
try:
|
|
343
|
+
zc.register_service(info)
|
|
344
|
+
except Exception:
|
|
345
|
+
# Audit P1: register_service failed AFTER Zeroconf() opened its
|
|
346
|
+
# multicast sockets + background threads. Close it before
|
|
347
|
+
# propagating so those resources are released instead of leaking
|
|
348
|
+
# for the daemon's lifetime (start() catches and logs).
|
|
349
|
+
try:
|
|
350
|
+
zc.close()
|
|
351
|
+
except Exception: # pragma: no cover — best-effort cleanup
|
|
352
|
+
pass
|
|
353
|
+
raise
|
|
354
|
+
|
|
355
|
+
# Only update state after successful registration.
|
|
356
|
+
self._zeroconf = zc
|
|
357
|
+
self._info = info
|
|
358
|
+
self._advertising = True
|
|
359
|
+
|
|
360
|
+
logger.info(
|
|
361
|
+
"MeshAdvertiser: registered '%s' on %s:%d",
|
|
362
|
+
service_name,
|
|
363
|
+
ip,
|
|
364
|
+
self._service_port,
|
|
365
|
+
)
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SLM Mesh — leaderless cross-node lock coordination (3c-2).
|
|
6
|
+
|
|
7
|
+
SAFETY MODEL (never overclaim):
|
|
8
|
+
``resolve()`` converges the ADVISORY lock VIEW across nodes using a
|
|
9
|
+
deterministic ``(fencing_token, node_id)`` total order. Higher wins.
|
|
10
|
+
When the remote node wins, the local ``mesh_locks`` row is deleted so
|
|
11
|
+
the local node's token becomes stale.
|
|
12
|
+
|
|
13
|
+
The FENCING TOKEN (``broker.validate_lock_fence``) is the single-writer
|
|
14
|
+
SAFETY guarantee: even during a brief split where both nodes held the
|
|
15
|
+
lock, any write attempt with the yielded node's old token is REJECTED
|
|
16
|
+
by the storage layer — because either:
|
|
17
|
+
(a) the row is gone → "no lock held for this resource", or
|
|
18
|
+
(b) the winning node has since acquired with a higher token →
|
|
19
|
+
"fencing token X is stale; current token is Y".
|
|
20
|
+
|
|
21
|
+
**The advisory lock alone does NOT guarantee mutual exclusion; only the
|
|
22
|
+
fence does.** This is NOT linearizable consensus (which requires a
|
|
23
|
+
quorum).
|
|
24
|
+
|
|
25
|
+
All new behavior is ADDITIVE and only active when ``resolve()`` is called
|
|
26
|
+
by the sync layer. Node-local ``lock_action`` behavior is UNCHANGED.
|
|
27
|
+
|
|
28
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import sqlite3
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from typing import TYPE_CHECKING
|
|
37
|
+
|
|
38
|
+
from superlocalmemory.mesh.broker import _NEVER_EXPIRES
|
|
39
|
+
from superlocalmemory.mesh.node_identity import get_node_id
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from superlocalmemory.mesh.broker import MeshBroker
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger("superlocalmemory.mesh.lock_protocol")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LockCoordinator:
|
|
48
|
+
"""Leaderless cross-node advisory lock coordinator.
|
|
49
|
+
|
|
50
|
+
Wraps a ``MeshBroker`` instance and adds the cross-machine protocol
|
|
51
|
+
layer: export local live locks (``local_lock_delta``) and converge
|
|
52
|
+
the lock view against a remote peer's claims (``resolve``).
|
|
53
|
+
|
|
54
|
+
All database operations are fail-soft: errors are logged and an empty
|
|
55
|
+
/ no-op result is returned so callers (including the daemon sync loop)
|
|
56
|
+
are never interrupted.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, broker: "MeshBroker") -> None:
|
|
60
|
+
self._broker = broker
|
|
61
|
+
self._db_path: str = broker._db_path
|
|
62
|
+
self._node_id: str = get_node_id(self._db_path)
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
# Public API
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def local_lock_delta(
|
|
69
|
+
self,
|
|
70
|
+
profile_id: str = "default",
|
|
71
|
+
now_iso: str | None = None,
|
|
72
|
+
) -> list[dict]:
|
|
73
|
+
"""Return live local locks for a profile, annotated with this node's id.
|
|
74
|
+
|
|
75
|
+
A lock is *live* iff its ``expires_at`` is set, not the legacy
|
|
76
|
+
``_NEVER_EXPIRES`` sentinel, and not yet elapsed.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
profile_id: Tenant scope (default ``"default"``).
|
|
80
|
+
now_iso: UTC ISO timestamp; injectable for test determinism.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of ``{"file_path","locked_by","locked_at","expires_at",
|
|
84
|
+
"fencing_token","node_id"}`` dicts. ``[]`` on DB error (fail-soft).
|
|
85
|
+
"""
|
|
86
|
+
now: str = now_iso or datetime.now(timezone.utc).isoformat()
|
|
87
|
+
conn: sqlite3.Connection | None = None
|
|
88
|
+
try:
|
|
89
|
+
conn = sqlite3.connect(self._db_path, timeout=5.0)
|
|
90
|
+
conn.row_factory = sqlite3.Row
|
|
91
|
+
rows = conn.execute(
|
|
92
|
+
"SELECT file_path, locked_by, locked_at, expires_at,"
|
|
93
|
+
" COALESCE(fencing_token, 0) AS fencing_token"
|
|
94
|
+
" FROM mesh_locks WHERE profile_id=?",
|
|
95
|
+
(profile_id,),
|
|
96
|
+
).fetchall()
|
|
97
|
+
except sqlite3.Error as exc:
|
|
98
|
+
logger.error("local_lock_delta: DB error for profile %r: %s", profile_id, exc)
|
|
99
|
+
return []
|
|
100
|
+
finally:
|
|
101
|
+
if conn is not None:
|
|
102
|
+
conn.close()
|
|
103
|
+
|
|
104
|
+
result: list[dict] = []
|
|
105
|
+
for row in rows:
|
|
106
|
+
if not self._row_is_live(row["expires_at"], now):
|
|
107
|
+
continue
|
|
108
|
+
result.append({
|
|
109
|
+
"file_path": row["file_path"],
|
|
110
|
+
"locked_by": row["locked_by"],
|
|
111
|
+
"locked_at": row["locked_at"],
|
|
112
|
+
"expires_at": row["expires_at"],
|
|
113
|
+
"fencing_token": int(row["fencing_token"]),
|
|
114
|
+
"node_id": self._node_id,
|
|
115
|
+
})
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
def resolve(
|
|
119
|
+
self,
|
|
120
|
+
profile_id: str,
|
|
121
|
+
remote_locks: list[dict],
|
|
122
|
+
) -> dict:
|
|
123
|
+
"""Converge the local lock view against a remote peer's live claims.
|
|
124
|
+
|
|
125
|
+
For each *live* remote lock, compare it to the local ``mesh_locks``
|
|
126
|
+
row using ``(fencing_token DESC, node_id DESC)``. Remote wins →
|
|
127
|
+
delete local row (token goes stale; fence will reject it). Only
|
|
128
|
+
``file_path`` values in ``remote_locks`` are examined (others untouched).
|
|
129
|
+
Idempotent: a second call after a yield is a no-op.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
profile_id: Tenant scope.
|
|
133
|
+
remote_locks: Dicts from the remote peer's ``local_lock_delta``
|
|
134
|
+
(must include ``file_path``, ``fencing_token``,
|
|
135
|
+
``node_id``, ``expires_at``).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
``{"yielded": [paths], "kept": N}``.
|
|
139
|
+
``{"yielded": [], "kept": 0}`` on any error (fail-soft).
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
return self._resolve_inner(profile_id, remote_locks)
|
|
143
|
+
except Exception as exc: # broad catch — must never crash daemon
|
|
144
|
+
logger.error(
|
|
145
|
+
"resolve: unexpected error for profile %r: %s",
|
|
146
|
+
profile_id, exc,
|
|
147
|
+
exc_info=True,
|
|
148
|
+
)
|
|
149
|
+
return {"yielded": [], "kept": 0}
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# Internal helpers
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def _resolve_inner(
|
|
156
|
+
self,
|
|
157
|
+
profile_id: str,
|
|
158
|
+
remote_locks: list[dict],
|
|
159
|
+
) -> dict:
|
|
160
|
+
"""Core resolve logic — separated so the outer method can catch all errors."""
|
|
161
|
+
now: str = datetime.now(timezone.utc).isoformat()
|
|
162
|
+
|
|
163
|
+
# Build a map of file_path → strongest LIVE remote claim.
|
|
164
|
+
# Audit P2: a non-dict entry must never crash resolve (per-entry skip).
|
|
165
|
+
# Audit P2: if a caller ever concatenates multiple peers' deltas, keep
|
|
166
|
+
# the winner under the SAME total order (token, node_id) rather than
|
|
167
|
+
# letting a later, weaker claim overwrite a stronger one.
|
|
168
|
+
live_remote: dict[str, dict] = {}
|
|
169
|
+
for rlock in remote_locks:
|
|
170
|
+
if not isinstance(rlock, dict):
|
|
171
|
+
continue
|
|
172
|
+
fp = rlock.get("file_path", "")
|
|
173
|
+
if not fp or not self._dict_is_live(rlock, now):
|
|
174
|
+
continue
|
|
175
|
+
existing = live_remote.get(fp)
|
|
176
|
+
if existing is None or self._lock_key(rlock) > self._lock_key(existing):
|
|
177
|
+
live_remote[fp] = rlock
|
|
178
|
+
|
|
179
|
+
if not live_remote:
|
|
180
|
+
return {"yielded": [], "kept": 0}
|
|
181
|
+
|
|
182
|
+
# Fetch local rows for the contested file_paths only — including
|
|
183
|
+
# expires_at (audit P1: an EXPIRED local row must not win over a live
|
|
184
|
+
# remote claim just because it carries a higher token).
|
|
185
|
+
file_paths: tuple[str, ...] = tuple(live_remote.keys())
|
|
186
|
+
placeholders = ",".join("?" * len(file_paths))
|
|
187
|
+
conn: sqlite3.Connection = sqlite3.connect(self._db_path, timeout=5.0)
|
|
188
|
+
try:
|
|
189
|
+
conn.row_factory = sqlite3.Row
|
|
190
|
+
try:
|
|
191
|
+
local_rows = conn.execute(
|
|
192
|
+
f"SELECT file_path, expires_at,"
|
|
193
|
+
f" COALESCE(fencing_token, 0) AS fencing_token"
|
|
194
|
+
f" FROM mesh_locks WHERE profile_id=? AND file_path IN ({placeholders})",
|
|
195
|
+
(profile_id, *file_paths),
|
|
196
|
+
).fetchall()
|
|
197
|
+
except sqlite3.Error as exc:
|
|
198
|
+
logger.error(
|
|
199
|
+
"resolve: failed to query local locks for profile %r: %s",
|
|
200
|
+
profile_id, exc,
|
|
201
|
+
)
|
|
202
|
+
return {"yielded": [], "kept": 0}
|
|
203
|
+
|
|
204
|
+
local_by_path: dict[str, dict] = {
|
|
205
|
+
row["file_path"]: {
|
|
206
|
+
"fencing_token": int(row["fencing_token"]),
|
|
207
|
+
"expires_at": row["expires_at"],
|
|
208
|
+
}
|
|
209
|
+
for row in local_rows
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
yielded: list[str] = []
|
|
213
|
+
kept: int = 0
|
|
214
|
+
|
|
215
|
+
for fp, rlock in live_remote.items():
|
|
216
|
+
local = local_by_path.get(fp)
|
|
217
|
+
if local is None:
|
|
218
|
+
continue # No local row — nothing to yield or keep.
|
|
219
|
+
|
|
220
|
+
snapshot_token: int = local["fencing_token"]
|
|
221
|
+
local_live: bool = self._row_is_live(local["expires_at"], now)
|
|
222
|
+
|
|
223
|
+
# Yield when: (a) the local row is EXPIRED garbage while a live
|
|
224
|
+
# remote claim exists (audit P1 — clean it up so its stale token
|
|
225
|
+
# can't pass the fence), or (b) the live remote strictly wins the
|
|
226
|
+
# total order.
|
|
227
|
+
if local_live and not self._remote_wins(rlock, local):
|
|
228
|
+
kept += 1
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
# Token-CONDITIONAL delete (audit P1/TOCTOU): only delete the
|
|
232
|
+
# row we compared against. If the local node reacquired to a new
|
|
233
|
+
# (higher) token between snapshot and now, the predicate misses
|
|
234
|
+
# and we do NOT wipe the fresher live lock.
|
|
235
|
+
try:
|
|
236
|
+
cur = conn.execute(
|
|
237
|
+
"DELETE FROM mesh_locks WHERE profile_id=? AND file_path=?"
|
|
238
|
+
" AND COALESCE(fencing_token, 0)=?",
|
|
239
|
+
(profile_id, fp, snapshot_token),
|
|
240
|
+
)
|
|
241
|
+
conn.commit()
|
|
242
|
+
if cur.rowcount and cur.rowcount > 0:
|
|
243
|
+
logger.info(
|
|
244
|
+
"resolve: yielded lock %r to remote node %r "
|
|
245
|
+
"(remote (tok=%s,node=%s) vs local (tok=%s), local_live=%s)",
|
|
246
|
+
fp, rlock.get("node_id", "?"), self._safe_token(rlock),
|
|
247
|
+
rlock.get("node_id", "?"), snapshot_token, local_live,
|
|
248
|
+
)
|
|
249
|
+
yielded.append(fp)
|
|
250
|
+
# rowcount==0 → lost the race (reacquired); leave it, don't count.
|
|
251
|
+
except sqlite3.Error as exc:
|
|
252
|
+
logger.error(
|
|
253
|
+
"resolve: failed to yield lock %r for profile %r: %s",
|
|
254
|
+
fp, profile_id, exc,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return {"yielded": yielded, "kept": kept}
|
|
258
|
+
finally:
|
|
259
|
+
conn.close()
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _row_is_live(expires_at: str | None, now_iso: str) -> bool:
|
|
263
|
+
"""Return True iff a DB row's expires_at represents a live lock."""
|
|
264
|
+
if not expires_at:
|
|
265
|
+
return False
|
|
266
|
+
if expires_at == _NEVER_EXPIRES:
|
|
267
|
+
return False
|
|
268
|
+
return expires_at > now_iso
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def _dict_is_live(lock: dict, now_iso: str) -> bool:
|
|
272
|
+
"""Return True iff a remote lock dict represents a live claim."""
|
|
273
|
+
exp = lock.get("expires_at", "") or ""
|
|
274
|
+
if not exp:
|
|
275
|
+
return False
|
|
276
|
+
if exp == _NEVER_EXPIRES:
|
|
277
|
+
return False
|
|
278
|
+
return exp > now_iso
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _safe_token(lock: dict) -> int:
|
|
282
|
+
"""Cast fencing_token to int defensively (remote dicts may carry strings)."""
|
|
283
|
+
try:
|
|
284
|
+
return int(lock.get("fencing_token", 0))
|
|
285
|
+
except (TypeError, ValueError):
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
def _lock_key(self, lock: dict) -> tuple[int, str]:
|
|
289
|
+
"""Total-order key ``(fencing_token, node_id)`` for comparing claims."""
|
|
290
|
+
return (self._safe_token(lock), str(lock.get("node_id", "")))
|
|
291
|
+
|
|
292
|
+
def _remote_wins(self, remote: dict, local: dict) -> bool:
|
|
293
|
+
"""Total order: ``(fencing_token DESC, node_id DESC)``.
|
|
294
|
+
|
|
295
|
+
Compare as ``int`` for the token (critical — JSON may deliver strings,
|
|
296
|
+
and string comparison ``"10" > "9"`` is ``False``, which would invert
|
|
297
|
+
the order). Tie-break via lexicographic ``node_id`` comparison; both
|
|
298
|
+
sides use consistent lowercase hex from ``get_node_id``, so the order
|
|
299
|
+
is stable across nodes.
|
|
300
|
+
|
|
301
|
+
Returns True iff the remote lock should be considered the effective
|
|
302
|
+
holder over the local lock.
|
|
303
|
+
"""
|
|
304
|
+
r_tok: int = self._safe_token(remote)
|
|
305
|
+
l_tok: int = local.get("fencing_token", 0)
|
|
306
|
+
|
|
307
|
+
if r_tok != l_tok:
|
|
308
|
+
return r_tok > l_tok
|
|
309
|
+
|
|
310
|
+
# Token tie — use node_id as deterministic tie-breaker.
|
|
311
|
+
r_nid: str = str(remote.get("node_id", ""))
|
|
312
|
+
l_nid: str = self._node_id
|
|
313
|
+
return r_nid > l_nid
|