From a614895a2b77d154830d830fdec333ac3fe1d239 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 28 Oct 2026 13:47:30 +0800 Subject: [PATCH] Loader waits for a module another thread is still importing (issue #132, BUG-1482 reopened) Gate run 5888 failed: the synastry full dossier computes two full readings on a thread pool, and the second thread got jyotish_engine from sys.modules while the first was still executing it (no cmd_full_reading yet). Reuse now goes through importlib.import_module, which waits on the module lock. Four threads loading one module in a fresh interpreter: before [False, True, True, True], after all True three times. Dossier scenario before 4/4 AttributeError, after 6/6 ok; synastry-full-report.test.ts 17/17. Ledger ERR-117: local npm baseline hid it (system python3 lacks swisseph). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++ docs/BUG_HISTORY.md | 3 ++- docs/research/pre_work_error_ledger.md | 1 + scripts/jyotish_api_server.py | 4 +++ tests/test_local_module_reuse_20261028.py | 30 +++++++++++++++++++++++ 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd70699..a4209ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 修正排盘服务同时算两张盘时偶发报错 + +- 上一版「同一份代码只留一份」在两张盘同时算(如合盘全档案)时,偶尔会拿到还没载入完的代码而报错;现在会等它载入完再用。结果不变。 +- Skill 版本不变(BUG-1482 复发修复,issue #132)。 + ## 2026-10-28 — 九项计算按上游来源修正 - 月亮刚好落在星宿起点时,Yogini 第一大运不再变成接近 0,而是整段;不在起点上的余额不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b0a3887b..b5638765 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20235,7 +20235,7 @@ ## BUG-1482 | 排盘服务的加载器给同一个 Python 文件另起一份私有副本,缓存互不相通 -- 状态:resolved(代码 + 回归测试;待部署) +- 状态:resolved(2026-10-28 复发一次后再修;代码 + 回归测试;待部署) - 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 20 条 → #132) - 影响面:`scripts/jyotish_api_server.py` `_load_local_module`(97 处调用点不动)。 - 现象:一次咨询加一次排盘之后,同一个文件在进程里有多份:引擎、大运、Narayana、分盘等 19 个文件由加载器以 `_jyotish_local_<名>` 另起一份,又被别处以 `scripts.<名>` 或 `<名>` 正常 import 一份(`dasha_analyzer`、`jaimini`、`jyotish_engine`、`narayana_dasha`、`varga`、`domain_calculation_service` 各 3 份)。每份都有自己的模块级缓存,彼此不相通,多占内存、多算一遍。 @@ -20247,6 +20247,7 @@ - 关联:#133(重计算多进程)。 - 复发自:无 - 修复版本:分支 `codex/local-module-reuse-20261028`。 +- **2026-10-28 复发(staging 门禁 run 5888 失败)**:现象:前端测试「a public pair dossier fills every section without birth keys」报 `AttributeError: partially initialized module 'jyotish_engine' has no attribute 'cmd_full_reading'`。触发条件:合盘全档案用线程池同时算两个人的完整解读,两个线程同时第一次经加载器拿 `jyotish_engine`。根因:本条第一次修复让加载器直接返回 `sys.modules` 里的同一文件;A 线程正在执行这个模块的 import 时,它已经在 `sys.modules` 里但还没执行完,B 线程拿到的是半成品。旧的私有副本写法每个线程各执行一份,不会交出半成品。修复:复用 `sys.modules` 里的模块时也经 `importlib.import_module`,它会在模块锁上等另一线程执行完。验证:新增 `tests/test_local_module_reuse_20261028.py::test_two_threads_loading_one_module_both_get_it_fully_initialized`(新进程里 4 个线程同时起跑载入,修前 `[False, True, True, True]`,修后 3 次都全 True);本机按门禁的同一段代码构造合盘全档案,修前 4/4 报同一个错,修后 6/6 通过;前端 `synastry-full-report.test.ts` 17 条全过(本机把 `python3` 指到项目 venv 才能跑)。为何当时没拦住:本机全量前端测试里这条一直失败(系统 `python3` 没有 Swiss Ephemeris),被我当作「基线失败」比对掉了,见错误台账 ERR-117。 ## BUG-1483 | 月亮恰在星宿起点时 Yogini 第一大运余额变成约 0 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 7c9a451b..7387ea17 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -243,6 +243,7 @@ For large architecture or release work, also read: | ERR-077 | VedAstro request previews serialized the live `x-api-key` header, allowing logs and failed test output to expose credentials. | resolved; credential rotation required 2026-07-16 | Preview/metadata profiles never contain authentication headers. Inject `VEDASTRO_API_KEY` only immediately before network execution; rotate any key exposed before this fix. | | ERR-078 | On this macOS host, bare `python3` resolves to Python 3.9 without pytest, while the project requires Python >=3.11 and uses PEP 604 annotations. The pre-work launcher previously reused that incompatible interpreter for every child check. | resolved 2026-08-07 | `pre_work_check.py` must probe and prefer the repository `.venv` (Python >=3.11 with pytest), expose the selected runtime in its JSON report, and fail closed with setup guidance if no compatible runtime exists. Keep regression coverage for system-Python launch and strict `JYOTISH_PRE_WORK_PYTHON` override behavior. | | ERR-079 | VedAstro hosted replay and fixed-commit self-host validation cannot run on this host when `VEDASTRO_API_ENDPOINT` / `VEDASTRO_API_KEY` are absent and neither a .NET runtime nor a reachable Docker daemon is available. | observed 2026-07-17 | Keep hosted closure `blocked`; run `vedastro_contract_probe.py --repeats 3` only after injecting credentials through the environment, and build the pinned source in a provisioned Windows/.NET or reachable container environment. Never persist credentials in artifacts or shell history. | +| ERR-117 | Local frontend `npm test` runs tests that shell out to a bare `python3`; on a host whose system Python lacks the project deps (pyswisseph) those tests fail every time, so a baseline diff treats them as "already failing" and hides a real regression (2026-10-28: BUG-1482 race only showed in staging gate run 5888). | observed 2026-10-28 | Run baseline and delivery `npm test` with `PATH=/.venv/bin:$PATH` so `python3` is the project venv; before calling a frontend failure "baseline", check whether it fails only because of the local Python. After a host restore, rebuild `.venv` (no g++ here: build pyswisseph through a zig c++ wrapper) and run `deploy/patch_vedastro_update_check.py`, or VedAstro auto-update ends processes early. | ## ERR-080 | Issue link was mistaken for benchmark provenance | mitigated 2026-07-18 PyJHora issue #17 is a UI/encoding issue with a general mismatch report, not VP Jain calibration. The benchmark now pins `V4.8.7` / `ca22995709bd60e371e7820a1a5efc80ce4cf821` and the exact test function URL. Treat its AGPL numeric expectations as a candidate replay, never independent formula arbitration. diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 75a5f909..0ce41f73 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -2543,6 +2543,10 @@ def _load_local_module(module_name): loaded = sys.modules.get(loaded_name) loaded_file = getattr(loaded, '__file__', None) if loaded_file and os.path.realpath(loaded_file) == os.path.realpath(module_path): + # import_module waits on the module lock while another thread is + # still executing it; sys.modules alone hands out a half-built + # module (gate run 5888, BUG-1482 reopened). + loaded = importlib.import_module(loaded_name) _LOCAL_MODULE_CACHE[module_name] = loaded return loaded if os.path.isfile(module_path) and SCRIPTS_DIR in sys.path: diff --git a/tests/test_local_module_reuse_20261028.py b/tests/test_local_module_reuse_20261028.py index f4bf394f..5d31dcd8 100644 --- a/tests/test_local_module_reuse_20261028.py +++ b/tests/test_local_module_reuse_20261028.py @@ -37,3 +37,33 @@ def test_no_private_copies_after_a_consultation_and_a_chart(): pass # the module graph is what is measured, not the payload private = sorted(name for name in sys.modules if name.startswith("_jyotish_local_")) assert private == [], private + + +def test_two_threads_loading_one_module_both_get_it_fully_initialized(): + # BUG-1482 reopened: a second thread used to get the half-initialized module + # the first thread was still importing (gate run 5888, synastry dossier). + import subprocess + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + probe = r""" +import sys, threading +sys.path[:0] = [sys.argv[1] + "/scripts", sys.argv[1]] +import scripts.jyotish_api_server as server +for name in [n for n in sys.modules if n.split(".")[-1] == "jyotish_engine"]: + sys.modules.pop(name) +server._LOCAL_MODULE_CACHE.pop("jyotish_engine", None) +barrier = threading.Barrier(4) +seen = [] +def load(): + barrier.wait() + seen.append(hasattr(server._load_local_module("jyotish_engine"), "cmd_full_reading")) +threads = [threading.Thread(target=load) for _ in range(4)] +for thread in threads: thread.start() +for thread in threads: thread.join() +print(seen) +""" + for _ in range(3): + completed = subprocess.run([sys.executable, "-c", probe, str(root)], capture_output=True, text=True, timeout=300) + assert completed.returncode == 0, completed.stderr[-800:] + assert completed.stdout.strip().splitlines()[-1] == "[True, True, True, True]", completed.stdout[-400:]