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.
Files changed (93) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/__main__.py +6 -0
  3. contextos/api/__init__.py +1 -0
  4. contextos/api/routes/__init__.py +1 -0
  5. contextos/api/routes/desktop.py +322 -0
  6. contextos/api/routes/ingest.py +17 -0
  7. contextos/api/routes/memories.py +84 -0
  8. contextos/api/routes/models.py +81 -0
  9. contextos/api/routes/retrieval.py +89 -0
  10. contextos/api/routes/system.py +216 -0
  11. contextos/api/server.py +195 -0
  12. contextos/benchmarks/__init__.py +1 -0
  13. contextos/benchmarks/compilation.py +245 -0
  14. contextos/benchmarks/connectors.py +423 -0
  15. contextos/benchmarks/explainability.py +103 -0
  16. contextos/benchmarks/final.py +406 -0
  17. contextos/benchmarks/graph.py +310 -0
  18. contextos/benchmarks/graph_adversarial.py +525 -0
  19. contextos/benchmarks/mcp.py +324 -0
  20. contextos/benchmarks/model_routing.py +203 -0
  21. contextos/benchmarks/optimization.py +305 -0
  22. contextos/benchmarks/rescue_integration.py +127 -0
  23. contextos/benchmarks/retrieval.py +266 -0
  24. contextos/benchmarks/temporal.py +377 -0
  25. contextos/benchmarks/temporal_hotpath.py +76 -0
  26. contextos/benchmarks/terminal.py +62 -0
  27. contextos/cli/__init__.py +1 -0
  28. contextos/cli/app.py +932 -0
  29. contextos/cli/dashboard.py +174 -0
  30. contextos/cli/formatters.py +299 -0
  31. contextos/config/__init__.py +1 -0
  32. contextos/config/settings.py +160 -0
  33. contextos/connectors/__init__.py +6 -0
  34. contextos/connectors/fake.py +11 -0
  35. contextos/connectors/json_import.py +125 -0
  36. contextos/connectors/local_files.py +102 -0
  37. contextos/connectors/manager.py +293 -0
  38. contextos/connectors/models.py +62 -0
  39. contextos/connectors/protocols.py +11 -0
  40. contextos/core/__init__.py +103 -0
  41. contextos/core/enums.py +489 -0
  42. contextos/core/exceptions.py +293 -0
  43. contextos/core/models.py +1147 -0
  44. contextos/core/protocols.py +549 -0
  45. contextos/daemon/__init__.py +1 -0
  46. contextos/daemon/manager.py +510 -0
  47. contextos/daemon/state.py +127 -0
  48. contextos/daemon/wiring.py +296 -0
  49. contextos/demo.py +217 -0
  50. contextos/embedding/__init__.py +1 -0
  51. contextos/embedding/deterministic.py +76 -0
  52. contextos/embedding/sentence_transformers.py +80 -0
  53. contextos/mcp/__init__.py +5 -0
  54. contextos/mcp/server.py +269 -0
  55. contextos/providers/__init__.py +13 -0
  56. contextos/providers/fake.py +217 -0
  57. contextos/providers/ollama.py +297 -0
  58. contextos/providers/openai_compatible.py +337 -0
  59. contextos/services/__init__.py +1 -0
  60. contextos/services/compilation.py +535 -0
  61. contextos/services/explainability.py +553 -0
  62. contextos/services/extraction.py +311 -0
  63. contextos/services/graph.py +524 -0
  64. contextos/services/graph_retrieval.py +143 -0
  65. contextos/services/ingestion.py +143 -0
  66. contextos/services/inspection.py +174 -0
  67. contextos/services/memory.py +291 -0
  68. contextos/services/model_service.py +409 -0
  69. contextos/services/optimization.py +426 -0
  70. contextos/services/privacy.py +331 -0
  71. contextos/services/retrieval.py +302 -0
  72. contextos/services/retrieval_index.py +88 -0
  73. contextos/services/router.py +302 -0
  74. contextos/services/secret_scanner.py +207 -0
  75. contextos/services/telemetry_query.py +102 -0
  76. contextos/services/temporal.py +500 -0
  77. contextos/services/token_counter.py +222 -0
  78. contextos/storage/__init__.py +1 -0
  79. contextos/storage/connector_repo.py +67 -0
  80. contextos/storage/database.py +497 -0
  81. contextos/storage/event_repo.py +137 -0
  82. contextos/storage/graph_repo.py +228 -0
  83. contextos/storage/lexical/__init__.py +1 -0
  84. contextos/storage/lexical/bm25.py +134 -0
  85. contextos/storage/memory_repo.py +589 -0
  86. contextos/storage/relation_repo.py +80 -0
  87. contextos/storage/telemetry_repo.py +481 -0
  88. contextos/storage/vector/__init__.py +1 -0
  89. contextos/storage/vector/in_memory.py +162 -0
  90. contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
  91. contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
  92. contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
  93. 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()