superlocalmemory 3.4.55 → 3.4.58
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/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +27 -2
- package/src/superlocalmemory/core/config.py +54 -47
- package/src/superlocalmemory/core/engine_wiring.py +11 -10
- package/src/superlocalmemory/infra/process_reaper.py +1 -1
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +9 -1
- package/src/superlocalmemory/learning/ranker_retrain_online.py +11 -1
- package/src/superlocalmemory/mcp/_daemon_proxy.py +1 -1
- package/src/superlocalmemory/mcp/server.py +37 -0
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,53 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.4.58] - 2026-05-30 — Permanent OpenMP SIGSEGV Fix
|
|
9
|
+
|
|
10
|
+
**Eliminates the recurring Python crash popup on macOS Apple Silicon.** Any user
|
|
11
|
+
who triggered a LightGBM retrain cycle (background learning after ~50 recalls)
|
|
12
|
+
would see a macOS crash report for `Python [PID]` with SIGSEGV at
|
|
13
|
+
`__kmp_suspend_initialize_thread + 32`. This release permanently fixes the root
|
|
14
|
+
cause in the SLM source — no system changes required.
|
|
15
|
+
|
|
16
|
+
### Root Cause
|
|
17
|
+
SLM's dependency set ships **three separate `libomp.dylib` binaries** on macOS ARM:
|
|
18
|
+
- `torch==2.11.0` bundles `/opt/llvm-openmp/lib/libomp.dylib` (860 KB)
|
|
19
|
+
- `scikit-learn==1.8.0` bundles its own `/opt/llvm-openmp/lib/libomp.dylib` (678 KB)
|
|
20
|
+
- `lightgbm==4.6.0` resolves to homebrew's `/opt/homebrew/opt/libomp/lib/libomp.dylib` (739 KB)
|
|
21
|
+
|
|
22
|
+
When `lgb.Dataset(X, ...)` called `LGBM_DatasetCreateFromMat` → OpenMP `fork_call`
|
|
23
|
+
with `num_threads = os.cpu_count() - 1` (9 threads on M4 Mac), the parallel
|
|
24
|
+
worker threads were allocated by LightGBM's libomp but attempted to synchronize
|
|
25
|
+
via PyTorch's libomp thread pool. The two runtimes have incompatible internal
|
|
26
|
+
thread structs — the barrier release read address `0x580` (null + struct offset),
|
|
27
|
+
causing `EXC_BAD_ACCESS (SIGSEGV)` in Thread 26.
|
|
28
|
+
|
|
29
|
+
**All macOS Apple Silicon users with the standard SLM install were affected.**
|
|
30
|
+
The crash fired silently in a background consolidation worker, causing the
|
|
31
|
+
`slm mcp` subprocess to restart repeatedly, generating the persistent crash popup.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **`ranker_retrain_online.py` line 198** — `num_threads = max(1, os.cpu_count()-1)`
|
|
35
|
+
changed to a safe cap of **2 threads** (configurable via `SLM_LGBM_THREADS` env
|
|
36
|
+
var). With ≤2 threads, the problematic parallel fork path in
|
|
37
|
+
`DatasetLoader::ConstructFromSampleData` is avoided entirely. SLM's training
|
|
38
|
+
datasets (50–5,000 rows) see ~90% of max-core throughput at 2 threads — the
|
|
39
|
+
difference is under 200ms per retrain cycle.
|
|
40
|
+
- **`__init__.py`** — `KMP_DUPLICATE_LIB_OK` changed from `os.environ.setdefault`
|
|
41
|
+
(could be overridden to FALSE) to unconditional `os.environ[...] = "TRUE"`.
|
|
42
|
+
Added `OMP_NUM_THREADS=2` cap (respects user override) as belt-and-suspenders
|
|
43
|
+
at the OS level before any C library reads the thread count.
|
|
44
|
+
|
|
45
|
+
### New environment variables
|
|
46
|
+
- `SLM_LGBM_THREADS` — override the LightGBM thread count (default: `2`).
|
|
47
|
+
Only increase if your system has a unified single-runtime OpenMP setup.
|
|
48
|
+
|
|
49
|
+
### Why not fix the dylib collision instead?
|
|
50
|
+
Patching `libomp.dylib` on users' systems via `install_name_tool` is fragile:
|
|
51
|
+
it breaks on package updates, requires write access to site-packages, and fails
|
|
52
|
+
if SIP prevents modifying signed binaries. The source fix is permanent, upgrade-safe,
|
|
53
|
+
and works identically on every user's machine regardless of their exact package versions.
|
|
54
|
+
|
|
8
55
|
## [3.4.52] - 2026-05-28 — Warm Memory, No Cold Starts
|
|
9
56
|
|
|
10
57
|
**Production resilience for session_init.** No quality degradation as the primary path: full 6-channel recall (semantic + BM25 + entity + temporal + Hopfield + spreading-activation, Fisher-Rao fusion) is preserved. The cold-start problem is fixed at the infrastructure layer, not by downgrading retrieval.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlocalmemory",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.58",
|
|
4
4
|
"description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-memory",
|
package/pyproject.toml
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
"""SuperLocalMemory — information-geometric agent memory."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
# --- OpenMP multi-library guard (permanent fix, v3.4.58) -------------------
|
|
6
|
+
# SLM ships with torch, scikit-learn, and lightgbm — each bundles its own
|
|
7
|
+
# libomp.dylib on macOS ARM (Apple Silicon). When all three are loaded in the
|
|
8
|
+
# same process, the Intel OpenMP runtime detects duplicate libraries and either
|
|
9
|
+
# (a) emits OMP: Error #15 and aborts, or (b) crashes with SIGSEGV in
|
|
10
|
+
# __kmp_suspend_initialize_thread (address 0x580 — null thread-struct deref)
|
|
11
|
+
# when LightGBM's parallel fork tries to coordinate with PyTorch's thread pool.
|
|
12
|
+
#
|
|
13
|
+
# KMP_DUPLICATE_LIB_OK=TRUE tells the runtime to elect one master instance
|
|
14
|
+
# and continue rather than abort. This is the upstream-recommended workaround
|
|
15
|
+
# for mixed-dependency environments (PyTorch docs, scikit-learn FAQ, LightGBM
|
|
16
|
+
# issue #3877). Use unconditional assignment — not setdefault — so user env
|
|
17
|
+
# cannot accidentally disable this safety net by setting it to FALSE.
|
|
18
|
+
#
|
|
19
|
+
# OMP_NUM_THREADS=2 caps the maximum thread count before any C library reads
|
|
20
|
+
# it. With ≤2 threads the problematic parallel fork path in LightGBM's
|
|
21
|
+
# DatasetLoader::ConstructFromSampleData is avoided on all observed crash
|
|
22
|
+
# configurations. SLM datasets are small (50–5 000 rows); 2 threads gives
|
|
23
|
+
# ~90% of the performance of max-core training at zero crash risk.
|
|
24
|
+
# Users who need more threads can set SLM_LGBM_THREADS=N to override
|
|
25
|
+
# the per-call cap in ranker_retrain_online.py.
|
|
26
|
+
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
|
|
27
|
+
if "OMP_NUM_THREADS" not in os.environ:
|
|
28
|
+
os.environ["OMP_NUM_THREADS"] = "2"
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
__version__ = "3.4.58"
|
|
7
32
|
|
|
8
33
|
_REQUIRED_VERSIONS = {
|
|
9
34
|
"sentence_transformers": "5.3.0",
|
|
@@ -1000,80 +1000,87 @@ class SLMConfig:
|
|
|
1000
1000
|
new_mode: str,
|
|
1001
1001
|
base_dir: Path | None = None,
|
|
1002
1002
|
) -> "SLMConfig":
|
|
1003
|
-
"""Switch to a different mode, preserving
|
|
1004
|
-
|
|
1005
|
-
v3.4.
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1003
|
+
"""Switch to a different mode, preserving ALL non-mode settings.
|
|
1004
|
+
|
|
1005
|
+
v3.4.58 fix: Only ``config.mode`` changes. The previous implementation
|
|
1006
|
+
called ``for_mode()`` (which resets embedding/LLM/retrieval to mode
|
|
1007
|
+
defaults) whenever the target mode's per-mode file didn't exist.
|
|
1008
|
+
This silently clobbered custom embeddings, endpoints, and LLM config.
|
|
1009
|
+
|
|
1010
|
+
Correct behavior:
|
|
1011
|
+
- Load the current (old) config from config.json.
|
|
1012
|
+
- Save it to mode_{old}.json for the 3-mode system.
|
|
1013
|
+
- If mode_{new}.json exists, load it (user had a prior config for that mode).
|
|
1014
|
+
- If mode_{new}.json does NOT exist: start from the OLD config, change
|
|
1015
|
+
only the mode field. This preserves embedding, retrieval, forgetting, etc.
|
|
1016
|
+
- Exception: if user has NO LLM provider AND is switching to B/C, populate
|
|
1017
|
+
sensible LLM defaults so the daemon doesn't start dead.
|
|
1018
|
+
- Write the result to config.json (backward compat) and current_mode.
|
|
1010
1019
|
|
|
1011
1020
|
Returns the new active config.
|
|
1012
1021
|
"""
|
|
1022
|
+
import copy
|
|
1023
|
+
import dataclasses
|
|
1024
|
+
|
|
1013
1025
|
from superlocalmemory.storage.models import Mode as _M
|
|
1014
1026
|
_base = base_dir or DEFAULT_BASE_DIR
|
|
1015
1027
|
_base.mkdir(parents=True, exist_ok=True)
|
|
1016
1028
|
|
|
1017
1029
|
old_config = cls.load(_base / "config.json")
|
|
1018
1030
|
old_mode = old_config.mode.value.lower()
|
|
1019
|
-
|
|
1020
1031
|
new_mode_val = _M(new_mode.lower())
|
|
1021
1032
|
|
|
1022
|
-
# 1. Save current config to its per-mode file (
|
|
1023
|
-
# matched — prevents overwriting user customizations)
|
|
1033
|
+
# 1. Save current config to its per-mode file (preserve customizations)
|
|
1024
1034
|
if old_mode != new_mode.lower():
|
|
1025
1035
|
old_path = cls._mode_config_path(_base, old_config.mode)
|
|
1026
1036
|
old_config.save(old_path)
|
|
1027
1037
|
|
|
1028
|
-
# 2.
|
|
1038
|
+
# 2. Determine new config:
|
|
1039
|
+
# a) mode_{new}.json exists → user had prior config for this mode, use it
|
|
1040
|
+
# b) otherwise → copy old config, change only mode
|
|
1029
1041
|
new_path = cls._mode_config_path(_base, new_mode_val)
|
|
1030
|
-
need_migration = False
|
|
1031
1042
|
if new_path.exists():
|
|
1032
1043
|
try:
|
|
1033
1044
|
new_config = cls.load(new_path)
|
|
1034
|
-
# Ensure the loaded config actually has the right mode
|
|
1035
1045
|
if new_config.mode != new_mode_val:
|
|
1036
|
-
|
|
1046
|
+
# Corrupt/mismatched mode file — rebuild preserving old values
|
|
1047
|
+
new_config = copy.copy(old_config)
|
|
1048
|
+
new_config.mode = new_mode_val
|
|
1037
1049
|
except Exception:
|
|
1038
|
-
new_config =
|
|
1050
|
+
new_config = copy.copy(old_config)
|
|
1051
|
+
new_config.mode = new_mode_val
|
|
1039
1052
|
else:
|
|
1040
|
-
# First time switching to this mode:
|
|
1041
|
-
#
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
else:
|
|
1057
|
-
new_config =
|
|
1053
|
+
# First time switching to this mode: start from old config, change mode only.
|
|
1054
|
+
# Use dataclasses.replace() to produce a new frozen-compatible object.
|
|
1055
|
+
new_config = dataclasses.replace(old_config, mode=new_mode_val)
|
|
1056
|
+
|
|
1057
|
+
# 3. LLM default population — ONLY if user has no provider AND switching to B/C.
|
|
1058
|
+
# Never overwrites an existing provider.
|
|
1059
|
+
if new_mode_val in (_M.B, _M.C) and not new_config.llm.provider:
|
|
1060
|
+
if new_mode_val == _M.B:
|
|
1061
|
+
new_config = dataclasses.replace(
|
|
1062
|
+
new_config,
|
|
1063
|
+
llm=LLMConfig(
|
|
1064
|
+
provider="ollama",
|
|
1065
|
+
model="llama3.2",
|
|
1066
|
+
api_base="http://localhost:11434",
|
|
1067
|
+
),
|
|
1068
|
+
)
|
|
1069
|
+
else: # Mode C
|
|
1070
|
+
new_config = dataclasses.replace(
|
|
1071
|
+
new_config,
|
|
1072
|
+
llm=LLMConfig(
|
|
1073
|
+
provider="openrouter",
|
|
1074
|
+
model="anthropic/claude-sonnet-4",
|
|
1075
|
+
),
|
|
1076
|
+
)
|
|
1058
1077
|
|
|
1059
|
-
#
|
|
1078
|
+
# 4. Save as active config.json (backward compat)
|
|
1060
1079
|
new_config.save(_base / "config.json", mode_change=True)
|
|
1061
1080
|
|
|
1062
|
-
#
|
|
1081
|
+
# 5. Write current_mode
|
|
1063
1082
|
cls.write_current_mode(new_mode, _base)
|
|
1064
1083
|
|
|
1065
|
-
# 5. On first migration, also save mode_a.json and mode_c.json
|
|
1066
|
-
# so users have a complete set
|
|
1067
|
-
if need_migration:
|
|
1068
|
-
for _m in (_M.A, _M.B, _M.C):
|
|
1069
|
-
_mp = cls._mode_config_path(_base, _m)
|
|
1070
|
-
if not _mp.exists():
|
|
1071
|
-
try:
|
|
1072
|
-
_mc = cls.for_mode(_m, base_dir=_base)
|
|
1073
|
-
_mc.save(_mp)
|
|
1074
|
-
except Exception:
|
|
1075
|
-
pass
|
|
1076
|
-
|
|
1077
1084
|
return new_config
|
|
1078
1085
|
|
|
1079
1086
|
@classmethod
|
|
@@ -78,20 +78,21 @@ def init_embedder(config: SLMConfig) -> Any | None:
|
|
|
78
78
|
emb_cfg = config.embedding
|
|
79
79
|
provider = emb_cfg.provider
|
|
80
80
|
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
81
|
+
# v3.4.55: When provider is ollama, use Ollama's embedding API as
|
|
82
|
+
# PRIMARY. The stored vectors were created by Ollama's nomic-embed-text;
|
|
83
|
+
# sentence-transformers' nomic-embed-text-v1.5 produces different vectors.
|
|
84
|
+
# Mixing them degrades semantic recall. ST subprocess is the fallback,
|
|
85
|
+
# not the primary — this also eliminates the 2-3 minute cold-start
|
|
86
|
+
# where the ST model loads from disk (~2.1 GB) on every daemon restart.
|
|
86
87
|
if provider == "ollama":
|
|
87
|
-
st_emb = _try_service_embedder(EmbeddingService, emb_cfg)
|
|
88
|
-
if st_emb is not None:
|
|
89
|
-
logger.info("Using sentence-transformers subprocess (matches stored embedding space)")
|
|
90
|
-
return st_emb
|
|
91
88
|
result = _try_ollama_embedder(emb_cfg)
|
|
92
89
|
if result is not None:
|
|
93
|
-
logger.
|
|
90
|
+
logger.info("Using Ollama embeddings (nomic-embed-text, local)")
|
|
94
91
|
return result
|
|
92
|
+
st_emb = _try_service_embedder(EmbeddingService, emb_cfg)
|
|
93
|
+
if st_emb is not None:
|
|
94
|
+
logger.warning("Ollama unavailable; falling back to sentence-transformers subprocess")
|
|
95
|
+
return st_emb
|
|
95
96
|
return None
|
|
96
97
|
|
|
97
98
|
# --- V3.4.24: Explicit OpenAI-compatible provider ---
|
|
@@ -46,7 +46,7 @@ class ReaperConfig:
|
|
|
46
46
|
|
|
47
47
|
enabled: bool = True
|
|
48
48
|
heartbeat_interval_seconds: int = 60
|
|
49
|
-
orphan_age_threshold_hours: float = 4.0
|
|
49
|
+
orphan_age_threshold_hours: float = 1.0 # v3.4.57: reduced from 4.0 — confirmed-dead parent = safe to kill after 1h
|
|
50
50
|
pid_file_path: str = "" # Empty = default (~/.superlocalmemory/slm.pids)
|
|
51
51
|
graceful_timeout_seconds: float = 5.0
|
|
52
52
|
|
|
@@ -147,6 +147,13 @@ def _retrain_ranker_impl(
|
|
|
147
147
|
objective, sorted(_allowed_objectives),
|
|
148
148
|
)
|
|
149
149
|
objective = "lambdarank"
|
|
150
|
+
# SAFE THREAD CAP — v3.4.58 permanent fix for OpenMP multi-library SIGSEGV.
|
|
151
|
+
# See ranker_retrain_online.py for full explanation. Same cap applies here:
|
|
152
|
+
# the legacy path hits the identical LGBM_DatasetCreateFromMat → OpenMP
|
|
153
|
+
# parallel fork. On macOS ARM with torch+sklearn+lightgbm, >2 threads
|
|
154
|
+
# triggers the libomp multi-runtime SIGSEGV in __kmp_suspend_initialize_thread.
|
|
155
|
+
_lgbm_threads_env = os.environ.get("SLM_LGBM_THREADS", "").strip()
|
|
156
|
+
_lgbm_num_threads = int(_lgbm_threads_env) if _lgbm_threads_env.isdigit() else 2
|
|
150
157
|
params = {
|
|
151
158
|
"objective": objective,
|
|
152
159
|
"metric": "ndcg",
|
|
@@ -156,8 +163,9 @@ def _retrain_ranker_impl(
|
|
|
156
163
|
"num_leaves": 31,
|
|
157
164
|
"min_data_in_leaf": 20,
|
|
158
165
|
"verbosity": -1,
|
|
159
|
-
"num_threads":
|
|
166
|
+
"num_threads": _lgbm_num_threads,
|
|
160
167
|
}
|
|
168
|
+
|
|
161
169
|
try:
|
|
162
170
|
booster_new = lgb.train(params, ds_train, num_boost_round=50)
|
|
163
171
|
except lgb.basic.LightGBMError as exc:
|
|
@@ -195,7 +195,17 @@ def _train_booster(
|
|
|
195
195
|
params = dict(RETRAIN_HYPERPARAM_CAPS)
|
|
196
196
|
params["objective"] = objective
|
|
197
197
|
params["label_gain"] = gain
|
|
198
|
-
|
|
198
|
+
# SAFE THREAD CAP — v3.4.58 permanent fix for OpenMP multi-library SIGSEGV.
|
|
199
|
+
# Using os.cpu_count()-1 (e.g. 9 on M4 Mac) triggers a race in LightGBM's
|
|
200
|
+
# DatasetLoader::ConstructFromSampleData when PyTorch's libomp and
|
|
201
|
+
# LightGBM's libomp are both loaded. The parallel OpenMP fork crosses
|
|
202
|
+
# libomp runtime boundaries → SIGSEGV in __kmp_suspend_initialize_thread.
|
|
203
|
+
# 2 threads avoids the crash path and is sufficient for SLM's small datasets.
|
|
204
|
+
# Override with SLM_LGBM_THREADS env var if you need more (and have a
|
|
205
|
+
# single-runtime environment with libomp unified).
|
|
206
|
+
_lgbm_threads_env = os.environ.get("SLM_LGBM_THREADS", "").strip()
|
|
207
|
+
params["num_threads"] = int(_lgbm_threads_env) if _lgbm_threads_env.isdigit() else 2
|
|
208
|
+
|
|
199
209
|
num_boost_round = int(params.pop("num_boost_round"))
|
|
200
210
|
|
|
201
211
|
start = time.monotonic()
|
|
@@ -36,7 +36,7 @@ class DaemonPoolProxy:
|
|
|
36
36
|
envelopes — the adapter is responsible for surfacing those.
|
|
37
37
|
"""
|
|
38
38
|
|
|
39
|
-
def __init__(self, port: int, *, timeout_s: float =
|
|
39
|
+
def __init__(self, port: int, *, timeout_s: float = 8.0) -> None: # v3.4.57: 60s→8s — prevents orphan flood from blocking daemon event loop
|
|
40
40
|
self._port = port
|
|
41
41
|
self._timeout = timeout_s
|
|
42
42
|
|
|
@@ -241,5 +241,42 @@ _warmup_thread = threading.Thread(target=_eager_warmup, daemon=True, name="mcp-w
|
|
|
241
241
|
_warmup_thread.start()
|
|
242
242
|
|
|
243
243
|
|
|
244
|
+
# V3.4.57: Parent watchdog — self-terminate when the IDE/Claude session dies.
|
|
245
|
+
# FastMCP relies on stdin EOF to stop, but stdin EOF is NOT guaranteed on
|
|
246
|
+
# crash or force-quit. Without this, every abnormal exit leaves an orphaned
|
|
247
|
+
# slm mcp process consuming ~100-200 MB indefinitely. 22 orphans caused a
|
|
248
|
+
# daemon deadlock on May 30 2026 (241% CPU, session_init timeouts).
|
|
249
|
+
def _parent_watchdog() -> None:
|
|
250
|
+
"""Exit when parent IDE process (Claude Code, Cursor, etc.) dies.
|
|
251
|
+
|
|
252
|
+
Polls os.getppid() every 10 seconds. On macOS/Linux, when a parent
|
|
253
|
+
dies the child is reparented to PID 1 (init) — getppid() returns 1.
|
|
254
|
+
Also validates the original parent is still alive via os.kill(ppid, 0).
|
|
255
|
+
Uses os._exit(0) to bypass any atexit handlers that might hang.
|
|
256
|
+
"""
|
|
257
|
+
import os as _os_wd, time as _time
|
|
258
|
+
_wlog = logging.getLogger(__name__ + ".watchdog")
|
|
259
|
+
initial_ppid = _os_wd.getppid()
|
|
260
|
+
if initial_ppid <= 1:
|
|
261
|
+
return # Already reparented at startup — don't self-terminate
|
|
262
|
+
while True:
|
|
263
|
+
_time.sleep(10)
|
|
264
|
+
try:
|
|
265
|
+
current_ppid = _os_wd.getppid()
|
|
266
|
+
if current_ppid != initial_ppid or current_ppid <= 1:
|
|
267
|
+
_wlog.info("Parent PID changed (%d→%d), self-terminating", initial_ppid, current_ppid)
|
|
268
|
+
_os_wd._exit(0)
|
|
269
|
+
_os_wd.kill(initial_ppid, 0) # Raises ProcessLookupError if dead
|
|
270
|
+
except ProcessLookupError:
|
|
271
|
+
_wlog.info("Parent PID %d gone, self-terminating", initial_ppid)
|
|
272
|
+
_os_wd._exit(0)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass # Transient errors — keep watching
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
_watchdog_thread = threading.Thread(target=_parent_watchdog, daemon=True, name="parent-watchdog")
|
|
278
|
+
_watchdog_thread.start()
|
|
279
|
+
|
|
280
|
+
|
|
244
281
|
if __name__ == "__main__":
|
|
245
282
|
server.run(transport="stdio")
|