cf-runtime 0.1.8__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.
@@ -0,0 +1,457 @@
1
+ """Provider-owned development runtime lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import signal
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from typing import Callable
16
+ import urllib.parse
17
+ import urllib.request
18
+
19
+ from .domain import RuntimeId
20
+ from .locking import LifecycleLock
21
+ from .manifest import (DEFAULT_RUNTIME_VERSION, DEFAULT_TOOL_VERSIONS, RuntimeManifest,
22
+ development_manifest, validate_manifest_pair)
23
+ from .paths import CogniflowHome
24
+ from .target import RuntimeIdentity, resolve_runtime_target
25
+
26
+
27
+ def _atomic_json(path: Path, payload: dict[str, object]) -> None:
28
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
29
+ try:
30
+ with temporary.open("w", encoding="utf-8", newline="\n") as stream:
31
+ stream.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n")
32
+ stream.flush()
33
+ os.fsync(stream.fileno())
34
+ os.replace(temporary, path)
35
+ finally:
36
+ temporary.unlink(missing_ok=True)
37
+
38
+
39
+ def _post(url: str, *, data: bytes, content_type: str, timeout: float, accept: str | None = None) -> bytes:
40
+ headers = {"Content-Type": content_type}
41
+ if accept:
42
+ headers["Accept"] = accept
43
+ request = urllib.request.Request(url, data=data, headers=headers, method="POST")
44
+ with urllib.request.urlopen(request, timeout=timeout) as response:
45
+ return response.read()
46
+
47
+
48
+ def _form(url: str, fields: dict[str, str], timeout: float, accept: str | None = None) -> bytes:
49
+ return _post(url, data=urllib.parse.urlencode(fields).encode(),
50
+ content_type="application/x-www-form-urlencoded", timeout=timeout, accept=accept)
51
+
52
+
53
+ def port_open(host: str, port: int) -> bool:
54
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
55
+ client.settimeout(0.2)
56
+ return client.connect_ex((host, port)) == 0
57
+
58
+
59
+ def process_running(pid: int) -> bool:
60
+ if pid <= 0:
61
+ return False
62
+ if os.name == "nt":
63
+ result = subprocess.run(
64
+ ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
65
+ capture_output=True,
66
+ text=True,
67
+ encoding="utf-8",
68
+ errors="replace",
69
+ check=False,
70
+ )
71
+ return result.returncode == 0 and f'"{pid}"' in result.stdout
72
+ try:
73
+ os.kill(pid, 0)
74
+ return True
75
+ except PermissionError:
76
+ return True
77
+ except (OSError, ValueError):
78
+ return False
79
+
80
+
81
+ def process_command_line(pid: int) -> str | None:
82
+ if os.name != "nt":
83
+ try:
84
+ return (Path("/proc") / str(pid) / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
85
+ except OSError:
86
+ return None
87
+ command = ["powershell", "-NoProfile", "-Command",
88
+ f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"]
89
+ try:
90
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
91
+ except OSError:
92
+ return None
93
+ return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
94
+
95
+
96
+ def terminate_process(pid: int) -> None:
97
+ if os.name == "nt":
98
+ result = subprocess.run(["taskkill", "/PID", str(pid), "/T"], capture_output=True, text=True, check=False)
99
+ if result.returncode:
100
+ result = subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, check=False)
101
+ if result.returncode:
102
+ raise RuntimeError((result.stderr or result.stdout or "taskkill failed").strip())
103
+ else:
104
+ os.kill(pid, signal.SIGTERM)
105
+
106
+
107
+ class DevelopmentRuntime:
108
+ def __init__(self, home: CogniflowHome, *, lock_timeout: float = 0.0,
109
+ popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen) -> None:
110
+ self.home = home
111
+ self.paths = home.runtime(RuntimeId.DEVELOPMENT)
112
+ self.lock_timeout = lock_timeout
113
+ self._popen = popen
114
+
115
+ def _lock(self) -> LifecycleLock:
116
+ return LifecycleLock(self.paths.lifecycle_lock, self.lock_timeout)
117
+
118
+ def prepare(self, source_root: Path, semantic_seed: Path) -> dict[str, object]:
119
+ with self._lock():
120
+ return self._prepare(source_root, semantic_seed)
121
+
122
+ def _prepare(self, source_root: Path, semantic_seed: Path) -> dict[str, object]:
123
+ source = source_root.expanduser().resolve(strict=False)
124
+ seed = semantic_seed.expanduser().resolve(strict=False)
125
+ if not source.is_dir():
126
+ raise ValueError("source_root must be an existing directory")
127
+ if not seed.is_file():
128
+ raise ValueError("semantic_seed must be an existing file")
129
+ revision = hashlib.sha256(seed.read_bytes()).hexdigest()
130
+ runtime_version = DEFAULT_RUNTIME_VERSION
131
+ tools = DEFAULT_TOOL_VERSIONS
132
+ command = None
133
+ if self.paths.manifest.is_file():
134
+ current = RuntimeManifest.read(self.paths.manifest)
135
+ if current.source_root != source:
136
+ raise ValueError("development manifest belongs to another source root")
137
+ runtime_version, tools, command = current.runtime_version, current.tool_versions, current.mcp_command
138
+ manifest = development_manifest(self.home, source_root=source, runtime_version=runtime_version,
139
+ semantic_revision=revision, tool_versions=tools,
140
+ **({"mcp_command": command} if command is not None else {}))
141
+ production_path = self.home.runtime(RuntimeId.PRODUCTION).manifest
142
+ if production_path.is_file():
143
+ validate_manifest_pair(manifest, RuntimeManifest.read(production_path))
144
+ self.paths.ensure_directories()
145
+ manifest.write(self.paths.manifest)
146
+ self.paths.identity.unlink(missing_ok=True)
147
+ return {"runtime_id": "development", "manifest": str(self.paths.manifest),
148
+ "semantic_revision": revision}
149
+
150
+ def start(self, source_root: Path, semantic_seed: Path, *, timeout: float) -> dict[str, object]:
151
+ with self._lock():
152
+ self._prepare(source_root, semantic_seed)
153
+ manifest = RuntimeManifest.read(self.paths.manifest)
154
+ started_pid: int | None = None
155
+ cleanup_errors: list[str] = []
156
+ try:
157
+ if port_open(manifest.fuseki_bind, manifest.fuseki_port):
158
+ self._verified_state(manifest)
159
+ else:
160
+ started_pid = self._start_process(manifest, timeout)
161
+ self._write_state(manifest, started_pid)
162
+ self._load_semantics(manifest, semantic_seed.resolve(), timeout)
163
+ RuntimeIdentity.from_manifest(manifest).write(self.paths.identity, self.home)
164
+ self._ready(manifest, timeout)
165
+ resolve_runtime_target(self.home, RuntimeId.DEVELOPMENT)
166
+ except Exception as error:
167
+ for name, cleanup in (
168
+ ("identity cleanup", lambda: self.paths.identity.unlink(missing_ok=True)),
169
+ ("process cleanup", lambda: self._terminate_and_wait(started_pid, timeout=min(timeout, 5.0)) if started_pid is not None else None),
170
+ ("lifecycle cleanup", lambda: self.paths.lifecycle_state.unlink(missing_ok=True) if started_pid is not None else None),
171
+ ):
172
+ try:
173
+ cleanup()
174
+ except Exception as cleanup_error:
175
+ cleanup_errors.append(f"{name}: {cleanup_error}")
176
+ if cleanup_errors:
177
+ error.add_note("cleanup also failed: " + "; ".join(cleanup_errors))
178
+ raise
179
+ return {"runtime_id": "development", "status": "ready", "pid": self._state_pid(),
180
+ "started": started_pid is not None, "manifest": str(self.paths.manifest)}
181
+
182
+ def status(self) -> dict[str, object]:
183
+ with self._lock():
184
+ manifest_exists = self.paths.manifest.is_file()
185
+ running = False
186
+ ready = False
187
+ pid = None
188
+ if manifest_exists:
189
+ manifest = RuntimeManifest.read(self.paths.manifest)
190
+ try:
191
+ pid = self._verified_state(manifest)
192
+ running = True
193
+ self._ready(manifest, 1.0)
194
+ ready = RuntimeIdentity.read(self.paths.identity) == RuntimeIdentity.from_manifest(manifest)
195
+ except (OSError, ValueError, RuntimeError):
196
+ pass
197
+ return {"runtime_id": "development", "status": "ready" if ready else "running" if running else "stopped",
198
+ "manifest_exists": manifest_exists, "running": running, "ready": ready, "pid": pid,
199
+ "manifest": str(self.paths.manifest)}
200
+
201
+ def stop(self, *, timeout: float) -> dict[str, object]:
202
+ with self._lock():
203
+ self.paths.identity.unlink(missing_ok=True)
204
+ if not self.paths.lifecycle_state.is_file():
205
+ return {"runtime_id": "development", "status": "not_running", "pid": None}
206
+ manifest = RuntimeManifest.read(self.paths.manifest)
207
+ pid = self._verified_state(manifest, allow_stale=True)
208
+ if not process_running(pid):
209
+ self.paths.lifecycle_state.unlink(missing_ok=True)
210
+ return {"runtime_id": "development", "status": "stale_removed", "pid": pid}
211
+ self._terminate_and_wait(pid, timeout)
212
+ self.paths.lifecycle_state.unlink(missing_ok=True)
213
+ return {"runtime_id": "development", "status": "stopped", "pid": pid}
214
+
215
+ def _start_process(self, manifest: RuntimeManifest, timeout: float) -> int:
216
+ java_name = "java.exe" if os.name == "nt" else "java"
217
+ java = self.home.java_install(manifest.tool_versions["java"]) / "bin" / java_name
218
+ fuseki = self.home.fuseki_install(manifest.tool_versions["fuseki"]) / "fuseki-server.jar"
219
+ if not java.is_file() or not fuseki.is_file():
220
+ raise ValueError("managed Java or Fuseki installation is missing")
221
+ database = str(self.paths.fuseki_database).replace("\\", "/").replace('"', '\\"')
222
+ dataset = manifest.fuseki_dataset.replace('"', '\\"')
223
+ config = ("@prefix fuseki: <http://jena.apache.org/fuseki#> .\n"
224
+ "@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .\n"
225
+ "@prefix tdb2: <http://jena.apache.org/2016/tdb#> .\n"
226
+ f'<#service> rdf:type fuseki:Service ; fuseki:name "{dataset}" ; '
227
+ 'fuseki:serviceQuery "query" ; fuseki:serviceUpdate "update" ; '
228
+ 'fuseki:serviceReadWriteGraphStore "data" ; fuseki:dataset <#dataset> .\n'
229
+ f'<#dataset> rdf:type tdb2:DatasetTDB2 ; tdb2:location "{database}" .\n')
230
+ temporary = self.paths.fuseki_server_config.with_suffix(".tmp")
231
+ temporary.write_text(config, encoding="utf-8", newline="\n")
232
+ os.replace(temporary, self.paths.fuseki_server_config)
233
+ command = [str(java), "-jar", str(fuseki), "--localhost", f"--port={manifest.fuseki_port}",
234
+ f"--config={self.paths.fuseki_server_config}"]
235
+ environment = os.environ.copy()
236
+ environment.update({"JAVA_HOME": str(java.parent.parent), "FUSEKI_BASE": str(self.paths.fuseki_config),
237
+ "TMPDIR": str(self.paths.temp), "TEMP": str(self.paths.temp)})
238
+ with (self.paths.logs / "fuseki.out.log").open("ab") as out, (self.paths.logs / "fuseki.err.log").open("ab") as err:
239
+ process = self._popen(command, cwd=fuseki.parent, env=environment, stdout=out, stderr=err)
240
+ deadline = time.monotonic() + timeout
241
+ while time.monotonic() < deadline:
242
+ if process.poll() is not None:
243
+ raise RuntimeError(f"Fuseki exited before readiness with code {process.returncode}")
244
+ try:
245
+ self._ready(manifest, min(1.0, timeout))
246
+ return process.pid
247
+ except Exception:
248
+ time.sleep(0.05)
249
+ error = TimeoutError("timed out waiting for managed Fuseki")
250
+ try:
251
+ self._terminate_and_wait(process.pid, timeout=min(max(timeout, 0.001), 5.0))
252
+ except Exception as cleanup_error:
253
+ error.add_note(f"cleanup also failed: {cleanup_error}")
254
+ raise error
255
+
256
+ def _load_semantics(self, manifest: RuntimeManifest, seed: Path, timeout: float) -> None:
257
+ base = f"http://{manifest.fuseki_bind}:{manifest.fuseki_port}/{manifest.fuseki_dataset}"
258
+ _form(base + "/update", {"update": "CLEAR ALL"}, timeout)
259
+ _post(base + "/data", data=seed.read_bytes(), content_type="application/trig", timeout=timeout)
260
+ body = _form(base + "/query", {"query": "ASK { GRAPH ?g { ?s ?p ?o } }"}, timeout,
261
+ accept="application/sparql-results+json")
262
+ if json.loads(body.decode()).get("boolean") is not True:
263
+ raise RuntimeError("semantic seed readiness verification failed")
264
+
265
+ def _ready(self, manifest: RuntimeManifest, timeout: float) -> None:
266
+ endpoint = f"http://{manifest.fuseki_bind}:{manifest.fuseki_port}/{manifest.fuseki_dataset}/query"
267
+ _form(endpoint, {"query": "ASK { ?s ?p ?o }"}, timeout)
268
+
269
+ def _write_state(self, manifest: RuntimeManifest, pid: int) -> None:
270
+ _atomic_json(self.paths.lifecycle_state, {
271
+ "runtime_id": "development", "environment_kind": "development", "lifecycle_owner": "cf-runtime",
272
+ "source_root": str(manifest.source_root), "runtime_root": str(self.paths.runtime_root),
273
+ "fuseki_config": str(self.paths.fuseki_server_config), "fuseki_database": str(self.paths.fuseki_database),
274
+ "fuseki_port": manifest.fuseki_port, "fuseki_pid": pid,
275
+ "started_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
276
+ })
277
+
278
+ def _state_pid(self) -> int:
279
+ return int(json.loads(self.paths.lifecycle_state.read_text(encoding="utf-8"))["fuseki_pid"])
280
+
281
+ def _verified_state(self, manifest: RuntimeManifest, *, allow_stale: bool = False) -> int:
282
+ try:
283
+ state = json.loads(self.paths.lifecycle_state.read_text(encoding="utf-8"))
284
+ except (OSError, json.JSONDecodeError) as error:
285
+ raise ValueError("development port has no valid provider lifecycle state") from error
286
+ expected = {"runtime_id": "development", "environment_kind": "development", "lifecycle_owner": "cf-runtime",
287
+ "source_root": str(manifest.source_root), "runtime_root": str(self.paths.runtime_root),
288
+ "fuseki_config": str(self.paths.fuseki_server_config), "fuseki_database": str(self.paths.fuseki_database),
289
+ "fuseki_port": manifest.fuseki_port}
290
+ if (not isinstance(state, dict) or set(state) != {*expected, "fuseki_pid", "started_at"}
291
+ or any(state.get(key) != value for key, value in expected.items())
292
+ or not isinstance(state.get("started_at"), str) or not state["started_at"]):
293
+ raise ValueError("lifecycle state is not owned by the canonical development runtime")
294
+ pid = state.get("fuseki_pid")
295
+ if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
296
+ raise ValueError("lifecycle state has no valid PID")
297
+ if allow_stale and not process_running(pid):
298
+ return pid
299
+ command = process_command_line(pid)
300
+ tokens = ("fuseki", str(self.paths.fuseki_server_config), str(manifest.fuseki_port), "--localhost")
301
+ if not process_running(pid) or command is None or not all(t.lower().replace("\\", "/") in command.lower().replace("\\", "/") for t in tokens):
302
+ raise ValueError("provider ownership of the Fuseki process could not be verified")
303
+ return pid
304
+
305
+ @staticmethod
306
+ def _terminate_and_wait(pid: int, timeout: float) -> None:
307
+ terminate_process(pid)
308
+ deadline = time.monotonic() + timeout
309
+ while process_running(pid) and time.monotonic() < deadline:
310
+ time.sleep(0.05)
311
+ if process_running(pid):
312
+ raise TimeoutError(f"PID {pid} did not stop")
313
+
314
+
315
+ class ProductionRuntime(DevelopmentRuntime):
316
+ """Lifecycle owner for the installed production composition.
317
+
318
+ Production has no source-tree seed input: the seed is produced by the
319
+ target interpreter and published below the production data root.
320
+ """
321
+
322
+ def __init__(self, home: CogniflowHome, *, lock_timeout: float = 0.0,
323
+ popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen) -> None:
324
+ super().__init__(home, lock_timeout=lock_timeout, popen=popen)
325
+ self.paths = home.runtime(RuntimeId.PRODUCTION)
326
+
327
+ def _seed(self, manifest: RuntimeManifest) -> Path:
328
+ seed = self.home.root / "data" / "production" / "semantic-seed" / "cogniflow.trig"
329
+ seed.parent.mkdir(parents=True, exist_ok=True)
330
+ command = [str(manifest.python_executable), "-m", "cf_runtime.installed_seed", "--output", str(seed)]
331
+ environment = {"PYTHONNOUSERSITE": "1", "PYTHONDONTWRITEBYTECODE": "1",
332
+ "PATH": str(Path(manifest.python_executable).parent),
333
+ "PYTHONUNBUFFERED": "1"}
334
+ result = subprocess.run(command, cwd=str(manifest.installation_root), env=environment,
335
+ capture_output=True, text=True, check=False, timeout=30)
336
+ if result.returncode != 0:
337
+ raise RuntimeError((result.stderr or result.stdout or "installed seed construction failed").strip())
338
+ expected = manifest.semantic_revision
339
+ actual = hashlib.sha256(seed.read_bytes()).hexdigest()
340
+ if actual != expected:
341
+ raise ValueError("installed semantic seed does not match the production manifest")
342
+ return seed
343
+
344
+ def start(self, *, timeout: float = 30.0) -> dict[str, object]:
345
+ with self._lock():
346
+ manifest = RuntimeManifest.read(self.paths.manifest)
347
+ if manifest.runtime_id is not RuntimeId.PRODUCTION:
348
+ raise ValueError("production lifecycle requires a production manifest")
349
+ seed = self._seed(manifest)
350
+ started_pid: int | None = None
351
+ try:
352
+ if port_open(manifest.fuseki_bind, manifest.fuseki_port):
353
+ pid = self._verified_state(manifest)
354
+ self._ready(manifest, timeout)
355
+ else:
356
+ pid = self._start_process(manifest, timeout)
357
+ started_pid = pid
358
+ self._write_state(manifest, pid)
359
+ self._load_semantics(manifest, seed, timeout)
360
+ self._ready(manifest, timeout)
361
+ RuntimeIdentity.from_manifest(manifest).write(self.paths.identity, self.home)
362
+ resolve_runtime_target(self.home, RuntimeId.PRODUCTION)
363
+ except Exception as error:
364
+ cleanup_errors: list[str] = []
365
+ for name, cleanup in (
366
+ ("identity cleanup", lambda: self.paths.identity.unlink(missing_ok=True)),
367
+ ("process cleanup", lambda: self._terminate_and_wait(started_pid, min(timeout, 5.0)) if started_pid is not None else None),
368
+ ("lifecycle cleanup", lambda: self.paths.lifecycle_state.unlink(missing_ok=True) if started_pid is not None else None),
369
+ ):
370
+ try:
371
+ cleanup()
372
+ except Exception as cleanup_error:
373
+ cleanup_errors.append(f"{name}: {cleanup_error}")
374
+ if cleanup_errors:
375
+ error.add_note("cleanup also failed: " + "; ".join(cleanup_errors))
376
+ raise
377
+ return {"runtime_id": "production", "status": "ready", "pid": pid,
378
+ "started": started_pid is not None, "manifest": str(self.paths.manifest),
379
+ "semantic_revision": manifest.semantic_revision, "seed": str(seed)}
380
+
381
+ def status(self) -> dict[str, object]:
382
+ with self._lock():
383
+ exists = self.paths.manifest.is_file()
384
+ running = ready = False
385
+ pid = None
386
+ if exists:
387
+ try:
388
+ manifest = RuntimeManifest.read(self.paths.manifest)
389
+ pid = self._verified_state(manifest)
390
+ running = True
391
+ self._ready(manifest, 1.0)
392
+ ready = RuntimeIdentity.read(self.paths.identity) == RuntimeIdentity.from_manifest(manifest)
393
+ except (OSError, ValueError, RuntimeError):
394
+ pass
395
+ return {"runtime_id": "production", "status": "ready" if ready else "running" if running else "stopped",
396
+ "manifest_exists": exists, "running": running, "ready": ready, "pid": pid,
397
+ "manifest": str(self.paths.manifest),
398
+ "semantic_revision": RuntimeManifest.read(self.paths.manifest).semantic_revision if exists else None}
399
+
400
+ def stop(self, *, timeout: float = 5.0) -> dict[str, object]:
401
+ with self._lock():
402
+ self.paths.identity.unlink(missing_ok=True)
403
+ if not self.paths.lifecycle_state.is_file():
404
+ return {"runtime_id": "production", "status": "not_running", "pid": None}
405
+ manifest = RuntimeManifest.read(self.paths.manifest)
406
+ pid = self._verified_state(manifest, allow_stale=True)
407
+ if process_running(pid):
408
+ self._terminate_and_wait(pid, timeout)
409
+ self.paths.lifecycle_state.unlink(missing_ok=True)
410
+ if port_open(manifest.fuseki_bind, manifest.fuseki_port):
411
+ raise RuntimeError("production Fuseki port remains in use")
412
+ return {"runtime_id": "production", "status": "stopped", "pid": pid}
413
+
414
+ def _write_state(self, manifest: RuntimeManifest, pid: int) -> None:
415
+ seed = self.home.root / "data" / "production" / "semantic-seed" / "cogniflow.trig"
416
+ java = self.home.java_install(manifest.tool_versions["java"]) / "bin" / ("java.exe" if os.name == "nt" else "java")
417
+ fuseki = self.home.fuseki_install(manifest.tool_versions["fuseki"]) / "fuseki-server.jar"
418
+ _atomic_json(self.paths.lifecycle_state, {
419
+ "runtime_id": "production", "environment_kind": "production", "lifecycle_owner": "cf-runtime",
420
+ "runtime_root": str(self.paths.runtime_root), "manifest": str(self.paths.manifest),
421
+ "fuseki_config": str(self.paths.fuseki_server_config), "fuseki_database": str(self.paths.fuseki_database),
422
+ "fuseki_port": manifest.fuseki_port, "fuseki_pid": pid,
423
+ "semantic_revision": manifest.semantic_revision,
424
+ "installation_root": str(manifest.installation_root), "environment_root": str(manifest.environment_root),
425
+ "target_python": str(manifest.python_executable), "java_executable": str(java),
426
+ "fuseki_jar": str(fuseki), "seed_path": str(seed),
427
+ "seed_sha256": hashlib.sha256(seed.read_bytes()).hexdigest() if seed.is_file() else "",
428
+ "started_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
429
+ })
430
+
431
+ def _verified_state(self, manifest: RuntimeManifest, *, allow_stale: bool = False) -> int:
432
+ try:
433
+ state = json.loads(self.paths.lifecycle_state.read_text(encoding="utf-8"))
434
+ except (OSError, json.JSONDecodeError) as error:
435
+ raise ValueError("production port has no valid provider lifecycle state") from error
436
+ expected = {"runtime_id": "production", "environment_kind": "production", "lifecycle_owner": "cf-runtime",
437
+ "runtime_root": str(self.paths.runtime_root), "manifest": str(self.paths.manifest),
438
+ "fuseki_config": str(self.paths.fuseki_server_config), "fuseki_database": str(self.paths.fuseki_database),
439
+ "fuseki_port": manifest.fuseki_port, "semantic_revision": manifest.semantic_revision,
440
+ "installation_root": str(manifest.installation_root), "environment_root": str(manifest.environment_root),
441
+ "target_python": str(manifest.python_executable),
442
+ "java_executable": str(self.home.java_install(manifest.tool_versions["java"]) / "bin" / ("java.exe" if os.name == "nt" else "java")),
443
+ "fuseki_jar": str(self.home.fuseki_install(manifest.tool_versions["fuseki"]) / "fuseki-server.jar"),
444
+ "seed_path": str(self.home.root / "data" / "production" / "semantic-seed" / "cogniflow.trig"),
445
+ "seed_sha256": hashlib.sha256((self.home.root / "data" / "production" / "semantic-seed" / "cogniflow.trig").read_bytes()).hexdigest()}
446
+ if not isinstance(state, dict) or set(state) != {*expected, "fuseki_pid", "started_at"} or any(state.get(k) != v for k, v in expected.items()):
447
+ raise ValueError("lifecycle state is not owned by the canonical production runtime")
448
+ pid = state.get("fuseki_pid")
449
+ if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
450
+ raise ValueError("production lifecycle state has no valid PID")
451
+ if allow_stale and not process_running(pid):
452
+ return pid
453
+ command = process_command_line(pid)
454
+ tokens = ("fuseki", str(self.paths.fuseki_server_config), str(manifest.fuseki_port), "--localhost")
455
+ if not process_running(pid) or command is None or not all(t.lower().replace("\\", "/") in command.lower().replace("\\", "/") for t in tokens):
456
+ raise ValueError("provider ownership of the production Fuseki process could not be verified")
457
+ return pid
cf_runtime/locking.py ADDED
@@ -0,0 +1,64 @@
1
+ """Cross-platform exclusive lifecycle lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import time
8
+
9
+
10
+ class LifecycleLockedError(RuntimeError):
11
+ pass
12
+
13
+
14
+ class LifecycleLock:
15
+ def __init__(self, path: Path, timeout: float) -> None:
16
+ self.path = path
17
+ self.timeout = timeout
18
+ self._stream = None
19
+
20
+ def __enter__(self) -> LifecycleLock:
21
+ if self.timeout < 0:
22
+ raise ValueError("lock timeout cannot be negative")
23
+ self.path.parent.mkdir(parents=True, exist_ok=True)
24
+ self._stream = self.path.open("a+b")
25
+ self._stream.seek(0, os.SEEK_END)
26
+ if self._stream.tell() == 0:
27
+ self._stream.write(b"0")
28
+ self._stream.flush()
29
+ deadline = time.monotonic() + self.timeout
30
+ while True:
31
+ try:
32
+ self._acquire()
33
+ return self
34
+ except (BlockingIOError, OSError):
35
+ if time.monotonic() >= deadline:
36
+ self._stream.close()
37
+ self._stream = None
38
+ raise LifecycleLockedError("development lifecycle is locked")
39
+ time.sleep(min(0.01, max(0.0, deadline - time.monotonic())))
40
+
41
+ def _acquire(self) -> None:
42
+ assert self._stream is not None
43
+ if os.name == "nt":
44
+ import msvcrt
45
+ self._stream.seek(0)
46
+ msvcrt.locking(self._stream.fileno(), msvcrt.LK_NBLCK, 1)
47
+ else:
48
+ import fcntl
49
+ fcntl.flock(self._stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
50
+
51
+ def __exit__(self, _type: object, _value: object, _traceback: object) -> None:
52
+ if self._stream is None:
53
+ return
54
+ try:
55
+ if os.name == "nt":
56
+ import msvcrt
57
+ self._stream.seek(0)
58
+ msvcrt.locking(self._stream.fileno(), msvcrt.LK_UNLCK, 1)
59
+ else:
60
+ import fcntl
61
+ fcntl.flock(self._stream.fileno(), fcntl.LOCK_UN)
62
+ finally:
63
+ self._stream.close()
64
+ self._stream = None