contextos-memory-runtime 1.0.0rc2__py3-none-any.whl
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.
- contextos/__init__.py +3 -0
- contextos/__main__.py +6 -0
- contextos/api/__init__.py +1 -0
- contextos/api/routes/__init__.py +1 -0
- contextos/api/routes/desktop.py +322 -0
- contextos/api/routes/ingest.py +17 -0
- contextos/api/routes/memories.py +84 -0
- contextos/api/routes/models.py +81 -0
- contextos/api/routes/retrieval.py +89 -0
- contextos/api/routes/system.py +216 -0
- contextos/api/server.py +195 -0
- contextos/benchmarks/__init__.py +1 -0
- contextos/benchmarks/compilation.py +245 -0
- contextos/benchmarks/connectors.py +423 -0
- contextos/benchmarks/explainability.py +103 -0
- contextos/benchmarks/final.py +406 -0
- contextos/benchmarks/graph.py +310 -0
- contextos/benchmarks/graph_adversarial.py +525 -0
- contextos/benchmarks/mcp.py +324 -0
- contextos/benchmarks/model_routing.py +203 -0
- contextos/benchmarks/optimization.py +305 -0
- contextos/benchmarks/rescue_integration.py +127 -0
- contextos/benchmarks/retrieval.py +266 -0
- contextos/benchmarks/temporal.py +377 -0
- contextos/benchmarks/temporal_hotpath.py +76 -0
- contextos/benchmarks/terminal.py +62 -0
- contextos/cli/__init__.py +1 -0
- contextos/cli/app.py +932 -0
- contextos/cli/dashboard.py +174 -0
- contextos/cli/formatters.py +299 -0
- contextos/config/__init__.py +1 -0
- contextos/config/settings.py +160 -0
- contextos/connectors/__init__.py +6 -0
- contextos/connectors/fake.py +11 -0
- contextos/connectors/json_import.py +125 -0
- contextos/connectors/local_files.py +102 -0
- contextos/connectors/manager.py +293 -0
- contextos/connectors/models.py +62 -0
- contextos/connectors/protocols.py +11 -0
- contextos/core/__init__.py +103 -0
- contextos/core/enums.py +489 -0
- contextos/core/exceptions.py +293 -0
- contextos/core/models.py +1147 -0
- contextos/core/protocols.py +549 -0
- contextos/daemon/__init__.py +1 -0
- contextos/daemon/manager.py +510 -0
- contextos/daemon/state.py +127 -0
- contextos/daemon/wiring.py +296 -0
- contextos/demo.py +217 -0
- contextos/embedding/__init__.py +1 -0
- contextos/embedding/deterministic.py +76 -0
- contextos/embedding/sentence_transformers.py +80 -0
- contextos/mcp/__init__.py +5 -0
- contextos/mcp/server.py +269 -0
- contextos/providers/__init__.py +13 -0
- contextos/providers/fake.py +217 -0
- contextos/providers/ollama.py +297 -0
- contextos/providers/openai_compatible.py +337 -0
- contextos/services/__init__.py +1 -0
- contextos/services/compilation.py +535 -0
- contextos/services/explainability.py +553 -0
- contextos/services/extraction.py +311 -0
- contextos/services/graph.py +524 -0
- contextos/services/graph_retrieval.py +143 -0
- contextos/services/ingestion.py +143 -0
- contextos/services/inspection.py +174 -0
- contextos/services/memory.py +291 -0
- contextos/services/model_service.py +409 -0
- contextos/services/optimization.py +426 -0
- contextos/services/privacy.py +331 -0
- contextos/services/retrieval.py +302 -0
- contextos/services/retrieval_index.py +88 -0
- contextos/services/router.py +302 -0
- contextos/services/secret_scanner.py +207 -0
- contextos/services/telemetry_query.py +102 -0
- contextos/services/temporal.py +500 -0
- contextos/services/token_counter.py +222 -0
- contextos/storage/__init__.py +1 -0
- contextos/storage/connector_repo.py +67 -0
- contextos/storage/database.py +497 -0
- contextos/storage/event_repo.py +137 -0
- contextos/storage/graph_repo.py +228 -0
- contextos/storage/lexical/__init__.py +1 -0
- contextos/storage/lexical/bm25.py +134 -0
- contextos/storage/memory_repo.py +589 -0
- contextos/storage/relation_repo.py +80 -0
- contextos/storage/telemetry_repo.py +481 -0
- contextos/storage/vector/__init__.py +1 -0
- contextos/storage/vector/in_memory.py +162 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Serialized daemon lifecycle with recoverable registration and OS identity checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import contextmanager, suppress
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import psutil
|
|
16
|
+
import uvicorn
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from contextos.config.settings import Settings, load_settings
|
|
20
|
+
from contextos.core.exceptions import DaemonAlreadyRunningError, DaemonNotRunningError
|
|
21
|
+
from contextos.daemon.state import StartupState, atomic_write, file_lock, regular_path
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
import subprocess
|
|
25
|
+
from collections.abc import Iterable, Iterator
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _pid_file(settings: Settings) -> Path:
|
|
30
|
+
return settings.daemon.data_dir / "contextos.pid"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _lock_file(settings: Settings) -> Path:
|
|
34
|
+
return settings.daemon.data_dir / "contextos.lock"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _state_file(settings: Settings) -> Path:
|
|
38
|
+
return settings.daemon.data_dir / "contextos.state.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def lifecycle_lock(settings: Settings, timeout: float | None = None) -> Iterator[None]:
|
|
43
|
+
"""Serialize start, stop and recovery; never unlink the kernel lock file."""
|
|
44
|
+
duration = settings.daemon.lock_timeout if timeout is None else timeout
|
|
45
|
+
with file_lock(_lock_file(settings), duration):
|
|
46
|
+
yield
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def _registration_lock(settings: Settings) -> Iterator[None]:
|
|
51
|
+
with file_lock(
|
|
52
|
+
settings.daemon.data_dir / "contextos.registration.lock",
|
|
53
|
+
settings.daemon.lock_timeout,
|
|
54
|
+
):
|
|
55
|
+
yield
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_state(settings: Settings) -> StartupState | None:
|
|
59
|
+
path = _state_file(settings)
|
|
60
|
+
regular_path(path)
|
|
61
|
+
try:
|
|
62
|
+
return StartupState.model_validate_json(path.read_text(encoding="utf-8"))
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
return None
|
|
65
|
+
except ValidationError as exc:
|
|
66
|
+
raise RuntimeError("Invalid daemon startup state; refusing unsafe recovery") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_state(settings: Settings, state: StartupState) -> None:
|
|
70
|
+
atomic_write(_state_file(settings), state.model_dump_json() + "\n")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _write_pid_file_atomic(settings: Settings, pid: int) -> None:
|
|
74
|
+
if type(pid) is not int or pid <= 0:
|
|
75
|
+
raise ValueError("Daemon PID must be a positive integer")
|
|
76
|
+
atomic_write(_pid_file(settings), f"{pid}\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe_delete_pid_file(settings: Settings, expected_pids: int | Iterable[int]) -> bool:
|
|
80
|
+
"""Caller holds registration lock (or lifecycle lock for legacy-only state)."""
|
|
81
|
+
expected = {expected_pids} if isinstance(expected_pids, int) else set(expected_pids)
|
|
82
|
+
path = _pid_file(settings)
|
|
83
|
+
regular_path(path)
|
|
84
|
+
try:
|
|
85
|
+
if int(path.read_text(encoding="utf-8").strip()) in expected:
|
|
86
|
+
path.unlink()
|
|
87
|
+
return True
|
|
88
|
+
except (ValueError, FileNotFoundError):
|
|
89
|
+
pass
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _delete_generation(settings: Settings, startup_id: str, owned_pids: Iterable[int]) -> None:
|
|
94
|
+
"""Compare generation and PIDs under registration lock, preserving replacements."""
|
|
95
|
+
with _registration_lock(settings):
|
|
96
|
+
state = _read_state(settings)
|
|
97
|
+
if state is not None and state.startup_id == startup_id:
|
|
98
|
+
_state_file(settings).unlink()
|
|
99
|
+
_safe_delete_pid_file(settings, owned_pids)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _verified_process(pid: int, created: float | None = None) -> psutil.Process | None:
|
|
103
|
+
"""Retain PID/creation identity and require the daemon's exact module command."""
|
|
104
|
+
if type(pid) is not int or pid <= 0:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
proc = psutil.Process(pid)
|
|
108
|
+
args = proc.cmdline()
|
|
109
|
+
is_daemon = args[1:] == ["-m", "contextos", "start", "--foreground"]
|
|
110
|
+
if created is not None and proc.create_time() != created:
|
|
111
|
+
return None
|
|
112
|
+
if proc.is_running() and is_daemon:
|
|
113
|
+
return proc
|
|
114
|
+
except psutil.Error:
|
|
115
|
+
pass
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _running_state(settings: Settings) -> tuple[bool, int | None]:
|
|
120
|
+
"""Recover identity from authoritative state, with RC1 PID-file compatibility."""
|
|
121
|
+
with _registration_lock(settings):
|
|
122
|
+
state = _read_state(settings)
|
|
123
|
+
if state is not None:
|
|
124
|
+
if state.pid is not None and state.created is not None:
|
|
125
|
+
proc = _verified_process(state.pid, state.created)
|
|
126
|
+
if proc is not None:
|
|
127
|
+
return True, state.pid
|
|
128
|
+
_state_file(settings).unlink()
|
|
129
|
+
_safe_delete_pid_file(settings, state.pid)
|
|
130
|
+
elif (
|
|
131
|
+
abs(state.boot_time - psutil.boot_time()) < 2
|
|
132
|
+
and time.monotonic()
|
|
133
|
+
< state.deadline
|
|
134
|
+
<= time.monotonic() + settings.daemon.readiness_timeout
|
|
135
|
+
):
|
|
136
|
+
return False, None
|
|
137
|
+
else:
|
|
138
|
+
_state_file(settings).unlink()
|
|
139
|
+
path = _pid_file(settings)
|
|
140
|
+
regular_path(path)
|
|
141
|
+
try:
|
|
142
|
+
content = path.read_text(encoding="utf-8").strip()
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
return False, None
|
|
145
|
+
try:
|
|
146
|
+
pid = int(content)
|
|
147
|
+
except ValueError:
|
|
148
|
+
pid = None
|
|
149
|
+
if pid is not None and _verified_process(pid) is not None:
|
|
150
|
+
return True, pid
|
|
151
|
+
if path.read_text(encoding="utf-8").strip() == content:
|
|
152
|
+
path.unlink()
|
|
153
|
+
return False, None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _recover_registration(settings: Settings) -> tuple[bool, int | None]:
|
|
157
|
+
"""Allow a detached child to claim its intent after its starter crashes.
|
|
158
|
+
|
|
159
|
+
Expiry invalidation and child claiming use the same registration lock. A late
|
|
160
|
+
child exits before initializing services rather than replacing a newer state.
|
|
161
|
+
A pending intent is never treated as permission to spawn another daemon.
|
|
162
|
+
"""
|
|
163
|
+
recovery_deadline = time.monotonic() + settings.daemon.readiness_timeout
|
|
164
|
+
while True:
|
|
165
|
+
running, pid = _running_state(settings)
|
|
166
|
+
if running:
|
|
167
|
+
return running, pid
|
|
168
|
+
with _registration_lock(settings):
|
|
169
|
+
state = _read_state(settings)
|
|
170
|
+
if state is None:
|
|
171
|
+
return False, None
|
|
172
|
+
if time.monotonic() >= recovery_deadline and state.phase == "pending":
|
|
173
|
+
_state_file(settings).unlink()
|
|
174
|
+
return False, None
|
|
175
|
+
time.sleep(min(0.05, max(0, state.deadline - time.monotonic())))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def is_running(settings: Settings | None = None) -> tuple[bool, int | None]:
|
|
179
|
+
settings = settings or load_settings()
|
|
180
|
+
with lifecycle_lock(settings):
|
|
181
|
+
return _recover_registration(settings)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def start_daemon(settings: Settings | None = None, foreground: bool = False) -> None:
|
|
185
|
+
settings = settings or load_settings()
|
|
186
|
+
settings.daemon.data_dir.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
if foreground:
|
|
188
|
+
serialized = os.environ.get("CONTEXTOS_DAEMON_SETTINGS")
|
|
189
|
+
if serialized:
|
|
190
|
+
settings = Settings.model_validate_json(serialized)
|
|
191
|
+
_run_server(settings)
|
|
192
|
+
return
|
|
193
|
+
with lifecycle_lock(settings):
|
|
194
|
+
running, pid = _recover_registration(settings)
|
|
195
|
+
if running and pid is not None:
|
|
196
|
+
wait_until_ready(settings, expected_pid=pid)
|
|
197
|
+
raise DaemonAlreadyRunningError(pid)
|
|
198
|
+
_spawn_background(settings)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _child_processes(pid: int) -> list[psutil.Process]:
|
|
202
|
+
try:
|
|
203
|
+
return psutil.Process(pid).children(recursive=True)
|
|
204
|
+
except psutil.Error:
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _spawn_identity(pid: int) -> psutil.Process:
|
|
209
|
+
return psutil.Process(pid)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _terminate_tree(processes: Iterable[psutil.Process], timeout: float = 2) -> None:
|
|
213
|
+
retained = list(dict.fromkeys(processes))
|
|
214
|
+
for proc in reversed(retained):
|
|
215
|
+
with suppress(psutil.NoSuchProcess):
|
|
216
|
+
proc.terminate()
|
|
217
|
+
_, alive = psutil.wait_procs(retained, timeout=timeout)
|
|
218
|
+
for proc in alive:
|
|
219
|
+
with suppress(psutil.NoSuchProcess):
|
|
220
|
+
proc.kill()
|
|
221
|
+
_, survivors = psutil.wait_procs(alive, timeout=2)
|
|
222
|
+
if survivors:
|
|
223
|
+
raise RuntimeError("Could not stop owned daemon process tree")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def stop_daemon(settings: Settings | None = None) -> None:
|
|
227
|
+
settings = settings or load_settings()
|
|
228
|
+
with lifecycle_lock(settings):
|
|
229
|
+
running, pid = _recover_registration(settings)
|
|
230
|
+
if not running or pid is None:
|
|
231
|
+
raise DaemonNotRunningError()
|
|
232
|
+
with _registration_lock(settings):
|
|
233
|
+
state = _read_state(settings)
|
|
234
|
+
proc = _verified_process(pid, state.created if state else None)
|
|
235
|
+
if proc is None:
|
|
236
|
+
raise DaemonNotRunningError()
|
|
237
|
+
tree = [proc, *proc.children(recursive=True)]
|
|
238
|
+
if state and state.root_pid and state.root_created:
|
|
239
|
+
root = _verified_process(state.root_pid, state.root_created)
|
|
240
|
+
if root and root.pid != pid and any(p == root for p in proc.parents()):
|
|
241
|
+
tree = [root, *root.children(recursive=True)]
|
|
242
|
+
_terminate_tree(tree, timeout=10)
|
|
243
|
+
if state:
|
|
244
|
+
_delete_generation(settings, state.startup_id, [p.pid for p in tree])
|
|
245
|
+
else:
|
|
246
|
+
with _registration_lock(settings):
|
|
247
|
+
_safe_delete_pid_file(settings, [p.pid for p in tree])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _new_intent(settings: Settings) -> StartupState:
|
|
251
|
+
starter = psutil.Process()
|
|
252
|
+
return StartupState(
|
|
253
|
+
startup_id=uuid4().hex,
|
|
254
|
+
starter_pid=starter.pid,
|
|
255
|
+
starter_created=starter.create_time(),
|
|
256
|
+
deadline=time.monotonic() + settings.daemon.readiness_timeout,
|
|
257
|
+
boot_time=psutil.boot_time(),
|
|
258
|
+
host=settings.daemon.host,
|
|
259
|
+
port=settings.daemon.port,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _claim_startup(settings: Settings) -> str:
|
|
264
|
+
"""Child publishes durable identity before it can bind or advertise health."""
|
|
265
|
+
token = os.environ.get("CONTEXTOS_DAEMON_STARTUP_ID")
|
|
266
|
+
if not token:
|
|
267
|
+
with lifecycle_lock(settings):
|
|
268
|
+
running, pid = _recover_registration(settings)
|
|
269
|
+
if running and pid is not None:
|
|
270
|
+
raise DaemonAlreadyRunningError(pid)
|
|
271
|
+
state = _new_intent(settings)
|
|
272
|
+
with _registration_lock(settings):
|
|
273
|
+
_write_state(settings, state)
|
|
274
|
+
token = state.startup_id
|
|
275
|
+
current = psutil.Process()
|
|
276
|
+
with _registration_lock(settings):
|
|
277
|
+
state = _read_state(settings)
|
|
278
|
+
if (
|
|
279
|
+
state is None
|
|
280
|
+
or state.startup_id != token
|
|
281
|
+
or state.phase != "pending"
|
|
282
|
+
or time.monotonic() >= state.deadline
|
|
283
|
+
or abs(state.boot_time - psutil.boot_time()) >= 2
|
|
284
|
+
or state.host != settings.daemon.host
|
|
285
|
+
or state.port != settings.daemon.port
|
|
286
|
+
):
|
|
287
|
+
raise RuntimeError("Daemon startup handoff expired or was replaced")
|
|
288
|
+
if state.root_pid and state.root_created:
|
|
289
|
+
root = _verified_process(state.root_pid, state.root_created)
|
|
290
|
+
if root is None or (root != current and root not in current.parents()):
|
|
291
|
+
raise RuntimeError("Daemon startup root identity does not match")
|
|
292
|
+
state = state.model_copy(
|
|
293
|
+
update={
|
|
294
|
+
"phase": "registered",
|
|
295
|
+
"pid": current.pid,
|
|
296
|
+
"created": current.create_time(),
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
_write_state(settings, state)
|
|
300
|
+
_write_pid_file_atomic(settings, current.pid)
|
|
301
|
+
return token
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _run_server(settings: Settings) -> None:
|
|
305
|
+
token = _claim_startup(settings)
|
|
306
|
+
state = _read_state(settings)
|
|
307
|
+
if state is None or state.startup_id != token:
|
|
308
|
+
raise RuntimeError("Daemon registration disappeared before initialization")
|
|
309
|
+
startup_finished = threading.Event()
|
|
310
|
+
|
|
311
|
+
def enforce_deadline() -> None:
|
|
312
|
+
if not startup_finished.wait(max(0, state.deadline - time.monotonic())):
|
|
313
|
+
# Own process only. Even synchronous initialization cannot leave an
|
|
314
|
+
# indefinitely unhealthy detached daemon after starter death.
|
|
315
|
+
os._exit(1)
|
|
316
|
+
|
|
317
|
+
threading.Thread(target=enforce_deadline, daemon=True).start()
|
|
318
|
+
try:
|
|
319
|
+
asyncio.run(_async_run_server(settings, startup_finished))
|
|
320
|
+
finally:
|
|
321
|
+
startup_finished.set()
|
|
322
|
+
_delete_generation(settings, token, [os.getpid()])
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def _async_run_server(settings: Settings, startup_finished: threading.Event) -> None:
|
|
326
|
+
from contextos.api.server import create_app, set_services
|
|
327
|
+
from contextos.daemon.wiring import wire_services
|
|
328
|
+
|
|
329
|
+
services = await wire_services(settings)
|
|
330
|
+
set_services(services)
|
|
331
|
+
|
|
332
|
+
class RegisteredServer(uvicorn.Server):
|
|
333
|
+
async def startup(self, sockets=None):
|
|
334
|
+
await super().startup(sockets=sockets)
|
|
335
|
+
if self.started:
|
|
336
|
+
startup_finished.set()
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
server = RegisteredServer(
|
|
340
|
+
uvicorn.Config(
|
|
341
|
+
create_app(),
|
|
342
|
+
host=settings.daemon.host,
|
|
343
|
+
port=settings.daemon.port,
|
|
344
|
+
log_level=settings.daemon.log_level,
|
|
345
|
+
access_log=False,
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
await server.serve()
|
|
349
|
+
finally:
|
|
350
|
+
await services["database"].close()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _spawn_background(settings: Settings) -> None:
|
|
354
|
+
"""Starter owns serialization; child independently owns durable publication."""
|
|
355
|
+
import subprocess
|
|
356
|
+
|
|
357
|
+
state = _new_intent(settings)
|
|
358
|
+
with _registration_lock(settings):
|
|
359
|
+
_write_state(settings, state)
|
|
360
|
+
process = None
|
|
361
|
+
root = None
|
|
362
|
+
children: list[psutil.Process] = []
|
|
363
|
+
try:
|
|
364
|
+
kwargs = (
|
|
365
|
+
{"creationflags": 0x08000200}
|
|
366
|
+
if sys.platform == "win32"
|
|
367
|
+
else {"start_new_session": True}
|
|
368
|
+
)
|
|
369
|
+
process = subprocess.Popen(
|
|
370
|
+
[sys.executable, "-m", "contextos", "start", "--foreground"],
|
|
371
|
+
env={
|
|
372
|
+
**os.environ,
|
|
373
|
+
"CONTEXTOS_DAEMON_SETTINGS": settings.model_dump_json(),
|
|
374
|
+
"CONTEXTOS_DAEMON_STARTUP_ID": state.startup_id,
|
|
375
|
+
},
|
|
376
|
+
stdin=subprocess.DEVNULL,
|
|
377
|
+
stdout=subprocess.DEVNULL,
|
|
378
|
+
stderr=subprocess.DEVNULL,
|
|
379
|
+
**kwargs,
|
|
380
|
+
)
|
|
381
|
+
root = _spawn_identity(process.pid)
|
|
382
|
+
with _registration_lock(settings):
|
|
383
|
+
current = _read_state(settings)
|
|
384
|
+
if current is None or current.startup_id != state.startup_id:
|
|
385
|
+
raise RuntimeError("Daemon startup exited before becoming ready")
|
|
386
|
+
_write_state(
|
|
387
|
+
settings,
|
|
388
|
+
current.model_copy(
|
|
389
|
+
update={
|
|
390
|
+
"root_pid": root.pid,
|
|
391
|
+
"root_created": root.create_time(),
|
|
392
|
+
}
|
|
393
|
+
),
|
|
394
|
+
)
|
|
395
|
+
wait_until_ready(
|
|
396
|
+
settings,
|
|
397
|
+
process=process,
|
|
398
|
+
expected_pid=process.pid,
|
|
399
|
+
startup_id=state.startup_id,
|
|
400
|
+
descendants=children,
|
|
401
|
+
)
|
|
402
|
+
except BaseException:
|
|
403
|
+
owned = {process.pid} if process is not None else set()
|
|
404
|
+
if process is not None:
|
|
405
|
+
if root is not None and root.is_running():
|
|
406
|
+
with suppress(psutil.NoSuchProcess):
|
|
407
|
+
children.extend(root.children(recursive=True))
|
|
408
|
+
with _registration_lock(settings):
|
|
409
|
+
current = _read_state(settings)
|
|
410
|
+
if current and current.startup_id == state.startup_id and current.pid:
|
|
411
|
+
child = _verified_process(current.pid, current.created)
|
|
412
|
+
if child is not None:
|
|
413
|
+
children.append(child)
|
|
414
|
+
owned.update(child.pid for child in children)
|
|
415
|
+
_terminate_tree(children)
|
|
416
|
+
if process.poll() is None:
|
|
417
|
+
process.terminate()
|
|
418
|
+
try:
|
|
419
|
+
process.wait(timeout=2)
|
|
420
|
+
except subprocess.TimeoutExpired:
|
|
421
|
+
process.kill()
|
|
422
|
+
process.wait(timeout=2)
|
|
423
|
+
_delete_generation(settings, state.startup_id, owned)
|
|
424
|
+
raise
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _owns_listener(proc: psutil.Process, settings: Settings) -> bool:
|
|
428
|
+
"""Fail closed unless the retained process owns this exact loopback listener."""
|
|
429
|
+
addresses = (
|
|
430
|
+
{"127.0.0.1"}
|
|
431
|
+
if settings.daemon.host == "localhost"
|
|
432
|
+
else {
|
|
433
|
+
settings.daemon.host,
|
|
434
|
+
}
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
if not proc.is_running():
|
|
438
|
+
return False
|
|
439
|
+
connections = proc.net_connections(kind="tcp")
|
|
440
|
+
owns = any(
|
|
441
|
+
conn.status == psutil.CONN_LISTEN
|
|
442
|
+
and conn.laddr
|
|
443
|
+
and conn.laddr.port == settings.daemon.port
|
|
444
|
+
and conn.laddr.ip in addresses
|
|
445
|
+
for conn in connections
|
|
446
|
+
)
|
|
447
|
+
return owns and proc.is_running()
|
|
448
|
+
except psutil.Error:
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def wait_until_ready(
|
|
453
|
+
settings: Settings,
|
|
454
|
+
*,
|
|
455
|
+
expected_pid: int,
|
|
456
|
+
process: subprocess.Popen | None = None,
|
|
457
|
+
timeout: float | None = None,
|
|
458
|
+
startup_id: str | None = None,
|
|
459
|
+
descendants: list[psutil.Process] | None = None,
|
|
460
|
+
) -> int:
|
|
461
|
+
"""Bounded polling; HTTP PID, birth identity, lineage and listener must agree."""
|
|
462
|
+
if type(expected_pid) is not int or expected_pid <= 0:
|
|
463
|
+
raise ValueError("wait_until_ready requires a positive expected_pid")
|
|
464
|
+
if process is not None and expected_pid != process.pid:
|
|
465
|
+
raise ValueError("expected_pid must match the spawned process")
|
|
466
|
+
owner = _verified_process(expected_pid)
|
|
467
|
+
if owner is None:
|
|
468
|
+
raise RuntimeError("Expected PID is not a live ContextOS daemon")
|
|
469
|
+
timeout = settings.daemon.readiness_timeout if timeout is None else timeout
|
|
470
|
+
deadline = time.monotonic() + timeout
|
|
471
|
+
host = "127.0.0.1" if settings.daemon.host == "localhost" else settings.daemon.host
|
|
472
|
+
host = f"[{host}]" if ":" in host else host
|
|
473
|
+
with httpx.Client(trust_env=False) as client:
|
|
474
|
+
while time.monotonic() < deadline:
|
|
475
|
+
if (process is not None and process.poll() is not None) or not owner.is_running():
|
|
476
|
+
raise RuntimeError("ContextOS daemon exited before becoming ready")
|
|
477
|
+
candidates = {expected_pid: owner}
|
|
478
|
+
if process is not None:
|
|
479
|
+
try:
|
|
480
|
+
children = owner.children(recursive=True)
|
|
481
|
+
except psutil.NoSuchProcess as exc:
|
|
482
|
+
raise RuntimeError("ContextOS daemon exited before becoming ready") from exc
|
|
483
|
+
candidates.update({child.pid: child for child in children})
|
|
484
|
+
if descendants is not None:
|
|
485
|
+
descendants.extend(child for child in children if child not in descendants)
|
|
486
|
+
try:
|
|
487
|
+
response = client.get(
|
|
488
|
+
f"http://{host}:{settings.daemon.port}/api/v1/status",
|
|
489
|
+
timeout=min(0.5, max(0.001, deadline - time.monotonic())),
|
|
490
|
+
)
|
|
491
|
+
data = response.json() if response.status_code == 200 else None
|
|
492
|
+
pid = data.get("pid") if isinstance(data, dict) else None
|
|
493
|
+
if isinstance(data, dict) and data.get("daemon_running") is True:
|
|
494
|
+
responder = candidates.get(pid) if type(pid) is int and pid > 0 else None
|
|
495
|
+
if responder is not None and _owns_listener(responder, settings):
|
|
496
|
+
with _registration_lock(settings):
|
|
497
|
+
state = _read_state(settings)
|
|
498
|
+
registered = startup_id is None or (
|
|
499
|
+
state is not None
|
|
500
|
+
and state.startup_id == startup_id
|
|
501
|
+
and state.phase == "registered"
|
|
502
|
+
and state.pid == pid
|
|
503
|
+
and state.created == responder.create_time()
|
|
504
|
+
)
|
|
505
|
+
if registered and owner.is_running() and responder.is_running():
|
|
506
|
+
return pid
|
|
507
|
+
except (httpx.HTTPError, ValueError, psutil.NoSuchProcess):
|
|
508
|
+
pass
|
|
509
|
+
time.sleep(min(0.05, max(0, deadline - time.monotonic())))
|
|
510
|
+
raise RuntimeError(f"ContextOS daemon did not become ready within {timeout:g} seconds")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Durable startup identity and crash-released locks for the local daemon.
|
|
2
|
+
|
|
3
|
+
Lock order is lifecycle, then registration. The child only takes registration,
|
|
4
|
+
so it can publish recoverable identity while its starter waits for readiness.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import errno
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import contextmanager, suppress
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Literal
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
from contextos.core.exceptions import DaemonLockTimeoutError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
|
|
26
|
+
_thread_locks = {
|
|
27
|
+
"contextos.lock": threading.Lock(),
|
|
28
|
+
"contextos.registration.lock": threading.Lock(),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StartupState(BaseModel):
|
|
33
|
+
"""One generation; creation times prevent stored PIDs authorizing PID reuse."""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
|
|
36
|
+
startup_id: str = Field(pattern=r"^[a-f0-9]{32}$")
|
|
37
|
+
phase: Literal["pending", "registered"] = "pending"
|
|
38
|
+
starter_pid: int = Field(gt=0)
|
|
39
|
+
starter_created: float = Field(gt=0)
|
|
40
|
+
deadline: float = Field(gt=0)
|
|
41
|
+
boot_time: float = Field(gt=0)
|
|
42
|
+
host: Literal["127.0.0.1", "localhost", "::1"]
|
|
43
|
+
port: int = Field(gt=0, le=65535)
|
|
44
|
+
root_pid: int | None = Field(default=None, gt=0)
|
|
45
|
+
root_created: float | None = Field(default=None, gt=0)
|
|
46
|
+
pid: int | None = Field(default=None, gt=0)
|
|
47
|
+
created: float | None = Field(default=None, gt=0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def regular_path(path: Path) -> None:
|
|
51
|
+
"""Fail closed on redirected state/lock paths, including Windows junctions."""
|
|
52
|
+
if path.is_symlink() or path.is_junction():
|
|
53
|
+
raise RuntimeError(f"Refusing redirected daemon state path: {path.name}")
|
|
54
|
+
if path.exists() and not path.is_file():
|
|
55
|
+
raise RuntimeError(f"Daemon state path is not a regular file: {path.name}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def atomic_write(path: Path, content: str) -> None:
|
|
59
|
+
"""Exclusive same-directory temporary file, flush, then atomic replacement."""
|
|
60
|
+
regular_path(path)
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
fd, temporary = tempfile.mkstemp(prefix=path.name + ".tmp.", dir=path.parent)
|
|
63
|
+
try:
|
|
64
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
65
|
+
stream.write(content)
|
|
66
|
+
stream.flush()
|
|
67
|
+
os.fsync(stream.fileno())
|
|
68
|
+
regular_path(path)
|
|
69
|
+
os.replace(temporary, path)
|
|
70
|
+
finally:
|
|
71
|
+
with suppress(FileNotFoundError):
|
|
72
|
+
Path(temporary).unlink()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@contextmanager
|
|
76
|
+
def file_lock(path: Path, timeout: float) -> Iterator[None]:
|
|
77
|
+
"""Persistent inode, kernel ownership, bounded monotonic acquisition."""
|
|
78
|
+
regular_path(path)
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
deadline = time.monotonic() + timeout
|
|
81
|
+
thread_lock = _thread_locks[path.name]
|
|
82
|
+
if not thread_lock.acquire(timeout=max(0, deadline - time.monotonic())):
|
|
83
|
+
raise DaemonLockTimeoutError("Timed out waiting for in-process lifecycle lock")
|
|
84
|
+
try:
|
|
85
|
+
# O_NOFOLLOW adds race protection where the OS supports it. State is
|
|
86
|
+
# stored in the user's private local data directory on all platforms.
|
|
87
|
+
flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)
|
|
88
|
+
fd = os.open(path, flags, 0o600)
|
|
89
|
+
with os.fdopen(fd, "r+b") as stream:
|
|
90
|
+
acquired = False
|
|
91
|
+
try:
|
|
92
|
+
while True:
|
|
93
|
+
try:
|
|
94
|
+
stream.seek(0)
|
|
95
|
+
if sys.platform == "win32":
|
|
96
|
+
import msvcrt
|
|
97
|
+
|
|
98
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
99
|
+
else:
|
|
100
|
+
import fcntl
|
|
101
|
+
|
|
102
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
103
|
+
acquired = True
|
|
104
|
+
break
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
|
|
107
|
+
raise
|
|
108
|
+
if time.monotonic() >= deadline:
|
|
109
|
+
raise DaemonLockTimeoutError(
|
|
110
|
+
f"Timed out waiting for ContextOS lifecycle lock after {timeout:g}s"
|
|
111
|
+
) from exc
|
|
112
|
+
time.sleep(min(0.02, max(0, deadline - time.monotonic())))
|
|
113
|
+
yield
|
|
114
|
+
finally:
|
|
115
|
+
if acquired:
|
|
116
|
+
with suppress(OSError):
|
|
117
|
+
stream.seek(0)
|
|
118
|
+
if sys.platform == "win32":
|
|
119
|
+
import msvcrt
|
|
120
|
+
|
|
121
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
122
|
+
else:
|
|
123
|
+
import fcntl
|
|
124
|
+
|
|
125
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
|
126
|
+
finally:
|
|
127
|
+
thread_lock.release()
|