labull-framework 0.1.30__tar.gz → 0.1.32__tar.gz
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.
- {labull_framework-0.1.30/labull_framework.egg-info → labull_framework-0.1.32}/PKG-INFO +1 -1
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/deepcheck.py +127 -68
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/parallel.py +7 -2
- labull_framework-0.1.32/labull_framework/magic_block/subproc.py +175 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/validate.py +52 -34
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/settings.py +2 -1
- {labull_framework-0.1.30 → labull_framework-0.1.32/labull_framework.egg-info}/PKG-INFO +1 -1
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework.egg-info/SOURCES.txt +1 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/pyproject.toml +2 -2
- {labull_framework-0.1.30 → labull_framework-0.1.32}/LICENSE +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/MANIFEST.in +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/README-pypi.md +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/__init__.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/admin.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/apps.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/auth/__init__.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/auth/backend.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/auth/bootstrap.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/auth/sso.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/auth/views.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/__init__.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/data.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/gitinfo.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/shape_ast.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/static/canvas.css +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/static/canvas.js +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/static/page.html +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/views.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/env.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/jsonio.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/__init__.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/blockpackage.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/devkit.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/dispatch.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/export.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/health.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/registry.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/sdk.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/service.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/views.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/management/__init__.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/management/commands/__init__.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/management/commands/labull_contract.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/migrations/0001_initial.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/migrations/__init__.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/models.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/urls.py +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework.egg-info/dependency_links.txt +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework.egg-info/requires.txt +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework.egg-info/top_level.txt +0 -0
- {labull_framework-0.1.30 → labull_framework-0.1.32}/setup.cfg +0 -0
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/deepcheck.py
RENAMED
|
@@ -36,6 +36,13 @@
|
|
|
36
36
|
app registry 是迁移与请求**正在用**的那一份 —— 在进程内试跑块代码等于拿生产赌。
|
|
37
37
|
⚠️ 子进程里一律用**只读 / 内存**的那条数据库连接(别名 `probe`),真库一根头发都不动。
|
|
38
38
|
|
|
39
|
+
🔴 **整包与判据正文一律走 stdin**(`subproc.run_child()` 的 `payload`),**不走环境变量**:
|
|
40
|
+
这一包有多大是作者说了算的(实测有两个块 349 KB / 336 KB),而 Linux 对**单个**环境字符串
|
|
41
|
+
有硬上限 `MAX_ARG_STRLEN = 128 KiB` —— 超了 `execve` 直接回 E2BIG,而那从前被说成
|
|
42
|
+
"找不到可用的 Python 解释器",后果是**整个块存不回去**(保存与体检都走这一份判据)。
|
|
43
|
+
环境里因此只剩 `PYTHONPATH` / `PYTHONIOENCODING` 那几个小变量,`_GIVEN` 就是 stdin 那一份。
|
|
44
|
+
细节(为什么两件都要做、errno 怎么分)见 `subproc.py` 的文件头 —— 那一层只有一份实现。
|
|
45
|
+
|
|
39
46
|
🔴 **软失败**:环境交不出来(没有 settings / 没有 Django / 建表不让)时**不报错、也不停用**,
|
|
40
47
|
只回一句"没验"进日志。这不是宽容,是**判据的方向**:一句假错会让本来能跑的块被停用
|
|
41
48
|
(前端红框 + 路由 403),比"少验一次"严重得多。所以只有拿得准的错才说。
|
|
@@ -45,7 +52,6 @@ from __future__ import annotations
|
|
|
45
52
|
|
|
46
53
|
import json
|
|
47
54
|
import os
|
|
48
|
-
import subprocess
|
|
49
55
|
import sys
|
|
50
56
|
from typing import Any
|
|
51
57
|
|
|
@@ -53,17 +59,28 @@ from .sdk import (
|
|
|
53
59
|
BLOCK_BACKEND_FILE,
|
|
54
60
|
BLOCK_DEEPCHECK,
|
|
55
61
|
BLOCK_PROBE,
|
|
56
|
-
BLOCK_PROBE_LIMIT,
|
|
57
62
|
BLOCK_PROBE_PARAMS,
|
|
58
63
|
is_excluded,
|
|
59
64
|
)
|
|
65
|
+
from .subproc import run_child
|
|
60
66
|
|
|
61
67
|
#: 框架自己那个 app 的 label:探针**不撒种、也不建表**(块代码本来就不该碰它的表)。
|
|
62
68
|
_OWN_APP = 'labull_framework'
|
|
63
69
|
|
|
64
70
|
#: 深检那一次的子进程超时(秒)。
|
|
65
71
|
#: 🔴 超时**不算通过**("不知道"不等于"没问题"),与 `validate.TRIAL_LOAD_TIMEOUT` 同一个口径。
|
|
66
|
-
|
|
72
|
+
#:
|
|
73
|
+
#: 🔴 10 → 30(2026-10,用户要求)。原来那 10 秒是照"一个典型的块应该秒回"定的,
|
|
74
|
+
#: 但现场它**误报**了:`buy` 项目 14 个块里有 4 个(出入库流水 / 用户与权限 / 申请 /
|
|
75
|
+
#: 退货单)被判成「引用深检超过 10 秒没有结束,已中止」,而它们的 `backend_open.py`
|
|
76
|
+
#: 本身没有任何死循环 —— 慢的是**每个子进程都要跑一遍 `django.setup()`**:
|
|
77
|
+
#: 项目一大(几十个模型 + 几十个块 + 深检自己还要建内存表),冷启动那一下就要好几秒,
|
|
78
|
+
#: 再叠加 `LABULL_BLOCK_WORKERS` 那 4 个口子一起抢 CPU,10 秒根本不够。
|
|
79
|
+
#: ⇒ 后果不是"少查一点",而是**好块被停用**(体检不通过 ⇒ `enabled=False`),
|
|
80
|
+
#: 而报文说的是"可能有死循环",把人往完全错的方向指。
|
|
81
|
+
#: ⚠️ 30 秒是**上限而不是等待**:正常块仍然在一秒内出结果,这个数只在"真的很慢"时才起作用。
|
|
82
|
+
#: ⚠️ 别再往上调:一个块真跑 30 秒还没完,那就该去看它的 `backend_open.py` 了。
|
|
83
|
+
DEEP_TIMEOUT = 30
|
|
67
84
|
|
|
68
85
|
#: 探针里最多跑几条 handler(一个手滑不该变成"体检跑十分钟")。
|
|
69
86
|
PROBE_LIMIT = 6
|
|
@@ -253,7 +270,9 @@ def _seed_rows(apps: Any) -> list[str]:
|
|
|
253
270
|
|
|
254
271
|
#: 子进程里那份深检器。
|
|
255
272
|
#:
|
|
256
|
-
#: 🔴
|
|
273
|
+
#: 🔴 正文与整包走 **stdin 里的 JSON**(`deep_check()` 那边 `json.dumps` 的那一份),
|
|
274
|
+
#: **既不往脚本里拼字符串**(块正文里一个引号就能把脚本本身改坏),**也不走环境变量**
|
|
275
|
+
#: (Linux 对单个环境字符串有 128 KiB 硬上限 ⇒ 包一大就 E2BIG,见 `subproc.py` 的文件头)。
|
|
257
276
|
#: ⚠️ 这一段里**不许出现反引号**:它整段嵌在本文件的字符串里(模板正文里的反引号会把模板截断,
|
|
258
277
|
#: `tools/find-stray-backticks.py` 就是拿 `node --check` 兜这一类事故的)。
|
|
259
278
|
_CHECK = r'''
|
|
@@ -270,6 +289,15 @@ except Exception:
|
|
|
270
289
|
# 不停用、也不报错)⇒ 症状是"保存一个引用了不存在模块的块居然 200"(真踩过)。
|
|
271
290
|
from labull_framework.magic_block.sdk import is_excluded
|
|
272
291
|
|
|
292
|
+
# 🔴 父进程从 **stdin** 递进来的那一份(`deep_check()` 组、`_BOOTSTRAP` 收):正文 / 整包 /
|
|
293
|
+
# 基线 / 主文件名 / 开关全在里面。**这就是这一层唯一的数据来源**(环境变量里只有
|
|
294
|
+
# `PYTHONPATH` / `PYTHONIOENCODING` 那几个小变量,所以不会撞内核那条 128 KiB 的单串上限)。
|
|
295
|
+
# ⚠️ `globals().get(...)` 而不是直接写 `_GIVEN`:这一段可能被单独 exec(手工调试)——
|
|
296
|
+
# 那种时候它得是个空字典**而不是 NameError**(NameError 会让整层静默失效,见文件头)。
|
|
297
|
+
# 取不到的那几项后面会以 KeyError / 一句明确的 RuntimeError 收场 ⇒ 父进程报"自己没跑起来,
|
|
298
|
+
# 这一次没验"(软失败),**不会**变成"什么都没报 = 全绿"。
|
|
299
|
+
_GIVEN = globals().get('_GIVEN') or {}
|
|
300
|
+
|
|
273
301
|
# 🔴 基线里那几条**已经没了的字段名**(父进程从 git 那份契约算出来,见 `canvas.data.deleted_field_names()`):
|
|
274
302
|
# `_DEAD` 是全集(字段名 → 它在哪几个模型上被删了),
|
|
275
303
|
# `_DEAD_BY` 是按模型分开的那一份(知道 `row` 是哪个模型时用,报文能点名"就是这张表")。
|
|
@@ -302,15 +330,20 @@ def _read_dead(raw):
|
|
|
302
330
|
|
|
303
331
|
|
|
304
332
|
try:
|
|
305
|
-
_read_dead(
|
|
333
|
+
_read_dead(_GIVEN.get('deleted'))
|
|
306
334
|
except Exception:
|
|
307
335
|
pass
|
|
308
336
|
|
|
309
|
-
# 🔴
|
|
337
|
+
# 🔴 主文件名**在这里取**(不靠 `main()` 里的局部量):下面那几个"点某一处引用"的
|
|
310
338
|
# 判据是**模块级函数**,它们看不见 `main()` 的局部变量 —— 引用一个不存在的名字会让
|
|
311
339
|
# 整个 `_static_check` 以 `NameError` 收场,而那一层是**软失败**(只记一句"深检自己失败了"、
|
|
312
340
|
# 不停用、也不报错)⇒ 症状是"新判据一条都不报、旧判据照旧"。这里真踩过一次。
|
|
313
|
-
_MAIN_FILE =
|
|
341
|
+
_MAIN_FILE = str(_GIVEN.get('main_file') or 'backend_open.py')
|
|
342
|
+
|
|
343
|
+
#: 同上的小写那一份(`probe()` / `_handlers()` 用的是它)。
|
|
344
|
+
#: ⚠️ 它从前是**引导脚本注入**进命名空间的(`space["_main_file"] = …`),现在是这里的一行 ——
|
|
345
|
+
#: 两边都行,但**必须有一边**:少了它就是 `NameError`,而"试跑那一层静默不跑"正是它的症状。
|
|
346
|
+
_main_file = _MAIN_FILE
|
|
314
347
|
|
|
315
348
|
#: 现在静态过的是**哪一份文件**(`main()` 每份 .py 前落一次,见 `_static_check()`)。
|
|
316
349
|
#: 🔴 报文从前写死主文件名:多入口块里 `material_master.py` 的条目也报成
|
|
@@ -365,18 +398,19 @@ def main():
|
|
|
365
398
|
if not settings.configured:
|
|
366
399
|
return _unavailable('Django 设置没配好,这一块这一次没验')
|
|
367
400
|
|
|
368
|
-
|
|
369
|
-
#
|
|
401
|
+
# 🔴 正文与整包是父进程从 **stdin** 递进来的(见 `_GIVEN` 那一段与 `subproc.py`)。
|
|
402
|
+
# 递不进来时**要说出来**:这里一声不响地退回空正文,症状就是"判据一条都不报、保存全放行"
|
|
403
|
+
# —— 那正是这一层最怕的那种静默失效(`main()` 抛出去反而安全:父进程会记一句"自己没跑起来")。
|
|
404
|
+
if 'source' not in _GIVEN and 'files' not in _GIVEN:
|
|
405
|
+
raise RuntimeError('父进程没把正文递进来(深检的数据走 stdin,见 deepcheck.deep_check())')
|
|
406
|
+
source = str(_GIVEN.get('source') or '')
|
|
407
|
+
# 🔴 整包一起验(块包可以拆文件):`files` 是 `{包内相对路径: 正文}`。
|
|
370
408
|
# ⚠️ **子进程里没有父进程模块级的那些名字、也没有 wrapper 里的 `_parent`**:
|
|
371
409
|
# 要什么就自己 import(这一段顶上只 import 了 `...sdk` 那几个)。
|
|
372
410
|
# 写一个不存在的名字会让整层深检**静默失效**(`NameError` 被 `_unavailable` 吞成一句说明,
|
|
373
411
|
# 保存于是全放行)—— 这里真踩过,而且是"改动前能报的错、改动后不报了"这种最难发现的形态。
|
|
374
412
|
from labull_framework.magic_block import blockpackage as _blockpackage
|
|
375
|
-
|
|
376
|
-
try:
|
|
377
|
-
raw_package = json.loads(os.environ.get('LABULL_DEEP_FILES') or '{}')
|
|
378
|
-
except ValueError:
|
|
379
|
-
raw_package = {}
|
|
413
|
+
raw_package = _GIVEN.get('files') or {}
|
|
380
414
|
package: dict[str, str] = (
|
|
381
415
|
{str(name): str(body) for name, body in raw_package.items()} if isinstance(raw_package, dict) else {}
|
|
382
416
|
)
|
|
@@ -392,7 +426,7 @@ def main():
|
|
|
392
426
|
f'要么放进**项目**里的公共模块再 import(那是普通 Python,随便用)。',
|
|
393
427
|
))
|
|
394
428
|
|
|
395
|
-
module_name = str(
|
|
429
|
+
module_name = str(_GIVEN.get('module') or 'backend_open')
|
|
396
430
|
|
|
397
431
|
# 🔴 **每一份 `.py` 都静态过一遍**(不只主文件):拆出去的文件里 `Order.no` 这种写法
|
|
398
432
|
# 同样要在保存时现形。`_imported_models` 只对"导入了模型"的文件起作用,
|
|
@@ -417,13 +451,14 @@ def main():
|
|
|
417
451
|
# 🔴 试跑那一层会用到的几个函数(撒样例行 / 取主键 / 错该算谁头上)是**从外面注入**的
|
|
418
452
|
# (见 `deep_check()` 里那个 wrapper):它们只有一份实现(模块级那几个),
|
|
419
453
|
# 这里**不许再抄一份** —— 抄一份的下场是"保存时说合法、体检时说非法"。
|
|
420
|
-
|
|
454
|
+
probing = bool(_GIVEN.get('probe'))
|
|
455
|
+
if probing:
|
|
421
456
|
probe(source, module_name, apps, package)
|
|
422
457
|
# 🔴 **"这一次没验运行期"必须说出来**(用户 0.1.19 报的第三条):探针没跑时,
|
|
423
458
|
# `probe()` 那几条说明是**不会出现**的(它压根没进去),于是"说明"是空的、
|
|
424
459
|
# 结论那一栏看起来就是"全绿" —— 而"全绿"会被读成"运行期也验过了"。
|
|
425
460
|
# 这一句只在"探针没跑"时补,判据与 `deepcheck.probe_enabled()` **同一份**(见它)。
|
|
426
|
-
if
|
|
461
|
+
if not probing and not _any_probe_note():
|
|
427
462
|
_NOTES.append('这一次只做了**静态判据**(引用 / 字段 / 枚举在不在),'
|
|
428
463
|
'运行期错误**没验**(探针没跑)—— 全绿不等于"跑得起来"')
|
|
429
464
|
return _result()
|
|
@@ -1062,8 +1097,8 @@ def _run(handlers):
|
|
|
1062
1097
|
block={'id': '', 'module': '', 'name': '体检', 'brief': ''},
|
|
1063
1098
|
)
|
|
1064
1099
|
params = _params()
|
|
1065
|
-
source =
|
|
1066
|
-
limit = int(
|
|
1100
|
+
source = str(_GIVEN.get('source') or '')
|
|
1101
|
+
limit = int(_GIVEN.get('limit') or 6)
|
|
1067
1102
|
ran = 0
|
|
1068
1103
|
for route, handler, broke in handlers:
|
|
1069
1104
|
if broke is not None:
|
|
@@ -1112,7 +1147,7 @@ def _report(route, exc):
|
|
|
1112
1147
|
|
|
1113
1148
|
|
|
1114
1149
|
def _params():
|
|
1115
|
-
raw =
|
|
1150
|
+
raw = _GIVEN.get('params') or '{}'
|
|
1116
1151
|
try:
|
|
1117
1152
|
value = json.loads(raw)
|
|
1118
1153
|
except Exception:
|
|
@@ -1154,14 +1189,22 @@ def deep_check(
|
|
|
1154
1189
|
而体检是对每个块各问一次的(见 `canvas.data` 里那段缓存的说明)。
|
|
1155
1190
|
|
|
1156
1191
|
🔴 只回**拿得准的问题**:环境交不出来时回 `([], ['没验…'])` —— 说明进日志,不落库、不停用。
|
|
1192
|
+
🔴 **正文与整包走 stdin**(不是环境变量、也不是 argv):这一包有多大是作者说了算的,
|
|
1193
|
+
而内核给单个参数 / 环境字符串的额度只有 128 KiB(见 `subproc.py` 的文件头)——
|
|
1194
|
+
从前它一走环境变量,两个大块就必然 E2BIG,且**改完存不回去**。
|
|
1157
1195
|
"""
|
|
1158
1196
|
if not str(src or '').strip():
|
|
1159
1197
|
return [], []
|
|
1198
|
+
#: 子进程的引导脚本(`-c` 那一小段进 argv ⇒ 它**只许是常量**,一个字节的数据都不许带)。
|
|
1160
1199
|
child = (
|
|
1161
1200
|
'import json, os, sys\n'
|
|
1162
1201
|
'sys.path[:0] = [item for item in (os.environ.get("LABULL_DEEP_PATH") or "").split(os.pathsep) if item]\n'
|
|
1202
|
+
# 🔴 数据从 **stdin** 进来,且**显式按 UTF-8 解**(`sys.stdin.buffer`):子进程的
|
|
1203
|
+
# `sys.stdin.encoding` 是 locale 那一套(中文 Windows = cp936),而父进程递的是 UTF-8。
|
|
1204
|
+
'_raw = sys.stdin.buffer.read() if hasattr(sys.stdin, "buffer") else sys.stdin.read().encode("utf-8")\n'
|
|
1205
|
+
'_given = json.loads(_raw.decode("utf-8", "replace") or "{}")\n'
|
|
1163
1206
|
'from labull_framework.magic_block import deepcheck as _parent\n'
|
|
1164
|
-
'space = {"__name__": "__deepcheck__"}\n'
|
|
1207
|
+
'space = {"__name__": "__deepcheck__", "_GIVEN": _given}\n'
|
|
1165
1208
|
# 🔴 这几个函数**只有一份实现**(本模块模块级那几个),编译进来那份正文从命名空间里取用。
|
|
1166
1209
|
# 在子进程里抄一份的下场是"保存时说合法、体检时说非法"。
|
|
1167
1210
|
# ⚠️ `_where` **不在**这个名单里:子进程自己那份认得 `backend_open.py` 这个文件名,
|
|
@@ -1169,41 +1212,54 @@ def deep_check(
|
|
|
1169
1212
|
'for _name in ("_seed_rows", "_filled", "_blamable", "_needs_params", "_failure_message",\n'
|
|
1170
1213
|
' "_route_models"):\n'
|
|
1171
1214
|
' space[_name] = getattr(_parent, _name)\n'
|
|
1172
|
-
|
|
1173
|
-
# 所以它只能读子进程的全局(先前漏了这一条 ⇒ `NameError` ⇒ 试跑那一层静默不跑)。
|
|
1174
|
-
'space["_main_file"] = os.environ.get("LABULL_DEEP_MAIN_FILE") or "backend_open.py"\n'
|
|
1175
|
-
'exec(compile(os.environ["LABULL_DEEP_CHECK"], "labull_deepcheck.py", "exec"), space)\n'
|
|
1215
|
+
'exec(compile(str(_given["check"]), "labull_deepcheck.py", "exec"), space)\n'
|
|
1176
1216
|
'print("LABULL_DEEP_RESULT" + json.dumps(space["_RESULT_"], ensure_ascii=False))\n'
|
|
1177
1217
|
)
|
|
1178
|
-
env = dict(os.environ)
|
|
1179
|
-
# 🔴 把父进程的 `sys.path` 交过去:子进程默认的 path 里没有项目根,
|
|
1180
|
-
# 不交的话每个块都会报"找不到 orders / modules"——那是假错。
|
|
1181
|
-
env['PYTHONPATH'] = os.pathsep.join(item for item in sys.path if item)
|
|
1182
|
-
env['LABULL_DEEP_PATH'] = env['PYTHONPATH']
|
|
1183
|
-
env['LABULL_DEEP_CHECK'] = _CHECK
|
|
1184
|
-
env['LABULL_DEEP_SOURCE'] = json.dumps(src, ensure_ascii=False)
|
|
1185
|
-
# 🔴 主文件名由父进程告诉子进程(子进程里**没有**父进程模块级的 `BLOCK_BACKEND_FILE`)。
|
|
1186
|
-
env['LABULL_DEEP_MAIN_FILE'] = BLOCK_BACKEND_FILE
|
|
1187
|
-
# 🔴 **整包**一起递(不只主文件):拆过文件的块要靠兄弟文件才加载得起来,
|
|
1188
|
-
# 否则保存时说"加载不起来",而它明明在包里。
|
|
1189
|
-
env['LABULL_DEEP_FILES'] = json.dumps(
|
|
1190
|
-
{name: body for name, body in (files or {}).items()
|
|
1191
|
-
if isinstance(body, str) and name.endswith('.py') and not is_excluded(name)},
|
|
1192
|
-
ensure_ascii=False,
|
|
1193
|
-
)
|
|
1194
|
-
env['LABULL_DEEP_PROBE'] = '1' if probe else '0'
|
|
1195
|
-
env['LABULL_DEEP_PROBE_LIMIT'] = str(PROBE_LIMIT)
|
|
1196
1218
|
# 🔴 基线里那些"已经没了的字段名"交给子进程(它是**数据**、不是代码:子进程没有父进程的
|
|
1197
1219
|
# app registry,自己去比一遍契约是不可能的)。序列化失败就当没有 —— 少一条判据,
|
|
1198
1220
|
# 绝不让整个深检炸掉(这一层的软失败口径见文件头)。
|
|
1199
1221
|
try:
|
|
1200
|
-
|
|
1222
|
+
dead = deleted or {}
|
|
1223
|
+
json.dumps(dead, ensure_ascii=False)
|
|
1201
1224
|
except (TypeError, ValueError):
|
|
1202
|
-
|
|
1225
|
+
dead = {}
|
|
1226
|
+
# 🔴 探针那一次用的参数:**作者给的 `probe.open.json` 优先**,宿主环境里那个
|
|
1227
|
+
# `LABULL_DEEP_PROBE_PARAMS` 兜底(与"数据走文件"之前的行为逐字一致 —— 那时子进程读的
|
|
1228
|
+
# 就是父进程环境里这一项,而父进程只在作者给了文件时才把它盖掉)。
|
|
1203
1229
|
params = (files or {}).get(PROBE_PARAMS_FILE)
|
|
1204
|
-
if isinstance(params, str) and params.strip():
|
|
1205
|
-
|
|
1206
|
-
|
|
1230
|
+
if not (isinstance(params, str) and params.strip()):
|
|
1231
|
+
params = os.environ.get(BLOCK_PROBE_PARAMS) or ''
|
|
1232
|
+
payload_json = json.dumps(
|
|
1233
|
+
{
|
|
1234
|
+
# 🔴 判据正文(那一段 `_CHECK`)也走这里:它本身就有几十 KB,塞环境变量是同一个坑。
|
|
1235
|
+
'check': _CHECK,
|
|
1236
|
+
'source': src,
|
|
1237
|
+
# 🔴 **整包**一起递(不只主文件):拆过文件的块要靠兄弟文件才加载得起来,
|
|
1238
|
+
# 否则保存时说"加载不起来",而它明明在包里。
|
|
1239
|
+
'files': {
|
|
1240
|
+
name: body for name, body in (files or {}).items()
|
|
1241
|
+
if isinstance(body, str) and name.endswith('.py') and not is_excluded(name)
|
|
1242
|
+
},
|
|
1243
|
+
# 🔴 主文件名由父进程告诉子进程(子进程里**没有**父进程模块级的 `BLOCK_BACKEND_FILE`)。
|
|
1244
|
+
'main_file': BLOCK_BACKEND_FILE,
|
|
1245
|
+
# ⚠️ 这一个是从**宿主环境**读的(框架自己不设它):与从前逐字一致,
|
|
1246
|
+
# 它只影响"块模块被塞进 `sys.modules` 时叫什么名字"。
|
|
1247
|
+
'module': os.environ.get('LABULL_DEEP_MODULE') or 'backend_open',
|
|
1248
|
+
'deleted': dead,
|
|
1249
|
+
'probe': bool(probe),
|
|
1250
|
+
'limit': PROBE_LIMIT,
|
|
1251
|
+
# ⚠️ 参数是**小数据**(作者写的一段 JSON),但仍走同一个通道:这一层"数据只有一条路"
|
|
1252
|
+
# (少一条通道就少一处"某个调用点还塞环境变量"的复发空间)。
|
|
1253
|
+
'params': params if params.strip() else '',
|
|
1254
|
+
},
|
|
1255
|
+
ensure_ascii=False,
|
|
1256
|
+
)
|
|
1257
|
+
env = dict(os.environ)
|
|
1258
|
+
# 🔴 把父进程的 `sys.path` 交过去:子进程默认的 path 里没有项目根,
|
|
1259
|
+
# 不交的话每个块都会报"找不到 orders / modules"——那是假错。
|
|
1260
|
+
env['PYTHONPATH'] = os.pathsep.join(item for item in sys.path if item)
|
|
1261
|
+
env['LABULL_DEEP_PATH'] = env['PYTHONPATH']
|
|
1262
|
+
# 🔴 这一段子进程的编码要**两头都钉死**(子进程按 UTF-8 读 stdin / 写 stdout,父进程按 UTF-8 解码):
|
|
1207
1263
|
# 只写 `text=True` 的话,父进程用 `locale.getpreferredencoding(False)` 解码
|
|
1208
1264
|
# (中文 Windows = cp936),而子进程一旦吐 UTF-8 —— 服务进程环境里有
|
|
1209
1265
|
# `PYTHONIOENCODING=utf-8` / `PYTHONUTF8=1` 这类东西时就是这样 —— `subprocess` 的
|
|
@@ -1214,18 +1270,8 @@ def deep_check(
|
|
|
1214
1270
|
env['PYTHONIOENCODING'] = 'utf-8'
|
|
1215
1271
|
# Windows 商店版 Python 的 `sys.executable` 可能是 None,拿不到就没有解释器可用。
|
|
1216
1272
|
interpreter = sys.executable or 'python'
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
[interpreter, '-c', child],
|
|
1220
|
-
env=env,
|
|
1221
|
-
capture_output=True,
|
|
1222
|
-
text=True,
|
|
1223
|
-
encoding='utf-8',
|
|
1224
|
-
# 第二道保险:真碰上编码诡异的输出,也宁可显示成问号,不要让解码线程死掉。
|
|
1225
|
-
errors='replace',
|
|
1226
|
-
timeout=DEEP_TIMEOUT,
|
|
1227
|
-
)
|
|
1228
|
-
except subprocess.TimeoutExpired:
|
|
1273
|
+
child_done = run_child([interpreter, '-c', child], env, payload_json, DEEP_TIMEOUT, '引用深检')
|
|
1274
|
+
if child_done.timed_out:
|
|
1229
1275
|
return (
|
|
1230
1276
|
[
|
|
1231
1277
|
(
|
|
@@ -1236,25 +1282,38 @@ def deep_check(
|
|
|
1236
1282
|
],
|
|
1237
1283
|
[],
|
|
1238
1284
|
)
|
|
1239
|
-
|
|
1285
|
+
if not child_done.started:
|
|
1286
|
+
if child_done.arg_limit:
|
|
1287
|
+
# 🔴 **判据自己起不来不算这个块的错**(软失败口径见文件头):回 `([], [说明])` ——
|
|
1288
|
+
# 不落库、不停用,**更不拦保存**。⚠️ 这一条从前是一句 issue("找不到可用的 Python
|
|
1289
|
+
# 解释器"),而同一个判据也在保存那条路上(`service._vet` 的 `if issues: raise
|
|
1290
|
+
# SaveRejected`)⇒ 后果是**整个块存不回去**,而块自己一点毛病都没有(用户报的那条)。
|
|
1291
|
+
# ⚠️ 数据现在走 stdin,所以走到这里只可能是**宿主进程自己的环境块**撑爆了内核那条线。
|
|
1292
|
+
return [], [
|
|
1293
|
+
f'{BLOCK_BACKEND_FILE} 的引用深检这一次没跑:{child_done.why}。'
|
|
1294
|
+
f'这一层的数据本来走 stdin(环境变量与 argv 里只放小变量),所以这是宿主环境的额度不够,'
|
|
1295
|
+
f'**不是这个块的问题** —— 没验这一块不等于它有问题(不落库、不停用,也不拦保存)。'
|
|
1296
|
+
]
|
|
1240
1297
|
return (
|
|
1241
|
-
[('backend', f'{BLOCK_BACKEND_FILE}
|
|
1298
|
+
[('backend', f'{BLOCK_BACKEND_FILE} 引用深检跑不起来:{child_done.why}')],
|
|
1242
1299
|
[],
|
|
1243
1300
|
)
|
|
1244
1301
|
|
|
1245
|
-
if
|
|
1302
|
+
if child_done.stdout is None:
|
|
1246
1303
|
# 正常走不到这里(上面 `encoding` / `errors` 已经给全);真走到了也**不许静默**:
|
|
1247
1304
|
# 至少把 stderr 的最后一行说出来,否则"编码不对 ⇒ 什么都没验"会一直隐形。
|
|
1248
|
-
tail = (
|
|
1305
|
+
tail = (child_done.stderr or '').strip().splitlines()
|
|
1249
1306
|
return [], [
|
|
1250
|
-
f'引用深检的输出没能解码(returncode={
|
|
1307
|
+
f'引用深检的输出没能解码(returncode={child_done.returncode}):'
|
|
1251
1308
|
f'{tail[-1] if tail else "没有 stderr"}'
|
|
1252
1309
|
]
|
|
1253
|
-
payload = _payload_of(
|
|
1310
|
+
payload = _payload_of(child_done.stdout or '')
|
|
1254
1311
|
if payload is None:
|
|
1255
|
-
detail = (
|
|
1312
|
+
detail = (child_done.stderr or child_done.stdout or '').strip().splitlines()
|
|
1256
1313
|
# 🔴 判据自己崩了**不算这个块的错**:那不是"作者写坏了",是环境交不出来。
|
|
1257
|
-
return [], [
|
|
1314
|
+
return [], [
|
|
1315
|
+
f'引用深检自己没跑起来({detail[-1] if detail else child_done.returncode}),这一次没验'
|
|
1316
|
+
]
|
|
1258
1317
|
issues = [
|
|
1259
1318
|
(str(item.get('stage') or 'backend'), str(item.get('message') or ''))
|
|
1260
1319
|
for item in payload.get('issues') or []
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/parallel.py
RENAMED
|
@@ -16,10 +16,15 @@
|
|
|
16
16
|
|
|
17
17
|
🔴 **为什么要有上限**:一次全量体检 = N 个块 × 2 个子进程,每个子进程都要跑一遍 `django.setup()`。
|
|
18
18
|
不限并发的话,20 个块就能同时拉起几十个 Python 进程:内存与 CPU 一起打满,
|
|
19
|
-
而子进程一慢就会撞上 `validate.TRIAL_LOAD_TIMEOUT` / `deepcheck.DEEP_TIMEOUT`(
|
|
19
|
+
而子进程一慢就会撞上 `validate.TRIAL_LOAD_TIMEOUT` / `deepcheck.DEEP_TIMEOUT`(30 秒)——
|
|
20
20
|
**超时不算通过**(那是"不知道",不是"没问题"),于是本来好好的块会被判成不通过。
|
|
21
21
|
所以并发度是**够用就好**,不是越大越好:下面的默认值与上限就是照上面那组实测定的。
|
|
22
22
|
|
|
23
|
+
⚠️ 那两个超时原先是 10 秒,正因为**在这个并发下被撞穿**才调到 30(现场 4 个好块被误判
|
|
24
|
+
成"疑似死循环"并被停用)。所以下面这个默认值 `4` 与那 30 秒是**一起定的一对数**:
|
|
25
|
+
调大并发度就等于给每个子进程少留 CPU,超时那个判据会先撑不住。
|
|
26
|
+
机器小就往下调(`LABULL_BLOCK_WORKERS=2`),别往上调着玩。
|
|
27
|
+
|
|
23
28
|
🔴 并发度可配:`settings.LABULL_BLOCK_WORKERS = 4`(或环境变量 `LABULL_BLOCK_WORKERS`)。
|
|
24
29
|
配 `1` 就是退回串行 —— 排查"并发引入的怪事"时先试它。
|
|
25
30
|
⚠️ 启动体检(`health.startup_once()`)走的是同一个口子:多进程部署(gunicorn 起 N 个 worker)时
|
|
@@ -43,7 +48,7 @@ ENV_WORKERS = 'LABULL_BLOCK_WORKERS'
|
|
|
43
48
|
#: 4 与 8 差不多,而 4 同时只拉起 4 个 `django.setup()` 子进程(内存与 CPU 都更稳)。
|
|
44
49
|
DEFAULT_WORKERS = 4
|
|
45
50
|
|
|
46
|
-
#: 上限:再多也不会更快(瓶颈是"起子进程"),只会把机器压垮 ——
|
|
51
|
+
#: 上限:再多也不会更快(瓶颈是"起子进程"),只会把机器压垮 —— 而子进程一慢就撞那两个超时,
|
|
47
52
|
#: 超时不算通过 ⇒ 本来好好的块会被判成不通过(见本文件头部第三段)。
|
|
48
53
|
MAX_WORKERS = 8
|
|
49
54
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
子进程那一层的**唯一一份**规矩:数据走 stdin、环境里只放小东西、起不来时分得清是哪一种起不来。
|
|
3
|
+
|
|
4
|
+
🔴 **这个文件存在的理由**(用户报的那个缺陷,两个大块必然红字、而且改完存不回去):
|
|
5
|
+
从前"整包正文"是塞进**环境变量**递给子进程的(`LABULL_DEEP_FILES` / `LABULL_DEEP_SOURCE` /
|
|
6
|
+
`LABULL_TRIAL_FILES`)。而 Linux 对**单个** argv / 环境字符串有一条硬上限
|
|
7
|
+
`MAX_ARG_STRLEN = 32 * PAGE_SIZE = 128 KiB`:包一大,`execve` 直接回 `E2BIG`
|
|
8
|
+
(CPython 把它映射成 errno 7,`OSError` 的文案固定是 `Argument list too long`)。
|
|
9
|
+
而框架从前把**所有** `OSError` 都说成"找不到可用的 Python 解释器" ——
|
|
10
|
+
于是现场看到的是一句查不出来的话(解释器明明在、镜像没问题),真因是"递过去的东西太大"。
|
|
11
|
+
更糟的是同一个判据也在**保存**那条路上(`service._vet`):后果不是"少验一次",
|
|
12
|
+
而是**整个块存不回去**(实测:14 个块里那两个大块 —— `LABULL_DEEP_FILES` 349,052 /
|
|
13
|
+
336,688 字节 —— 必然红字,其余 12 个 20~126 KB 全正常,与"单串超没超 128 KiB"逐块对得上)。
|
|
14
|
+
|
|
15
|
+
🔴 所以修法是**两件**事,缺一件都不算修好:
|
|
16
|
+
① **数据一律走 stdin**(`run_child(payload=…)` ⇒ `subprocess.run(input=…)`):
|
|
17
|
+
环境块与 argv 里只剩 `PYTHONPATH` / `PYTHONIOENCODING` 那几个小变量,于是
|
|
18
|
+
单串上限(128 KiB)与总量上限(`SC_ARG_MAX`,默认 ≈ 2 MB)**一起**摆脱;
|
|
19
|
+
② 递之前按**字节数**量一遍(`oversize()`),真超线就回一句能照着改的人话,
|
|
20
|
+
**不要盲目 execve** 再拿 errno 7 去猜(那条线是内核的,不是解释器的);
|
|
21
|
+
而起不来时按 `errno` **分开报**:`E2BIG` = 内核的参数 / 环境上限,`ENOENT` = 真的没有解释器。
|
|
22
|
+
|
|
23
|
+
⚠️ 这两件事的分工:`oversize()` 是**前置**判据(只报"确定超线"的,宁可漏报 —— 误报一句
|
|
24
|
+
就等于白白少验一次,而它要拦的那种场景本来也会以 E2BIG 收场);`run_child()` 是**兜底**
|
|
25
|
+
(真撞上了也要说清楚是哪一种)。两处都只**描述**事实,"这一次还要不要算通过"由调用方定:
|
|
26
|
+
深检那一侧的口径是**软失败**(回 `([], ['没验…'])`:不落库、不停用、也不拦保存)。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import errno
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any, Mapping, Sequence
|
|
36
|
+
|
|
37
|
+
#: 内核上限:**单个** argv / 环境字符串最多这么长(Linux 的 `MAX_ARG_STRLEN = 32 * PAGE_SIZE`)。
|
|
38
|
+
#:
|
|
39
|
+
#: 🔴 它量的是**字节**,不是字符数:中文正文按 3 字节一个字算(所以 128 KiB ≈ 4.3 万个汉字)。
|
|
40
|
+
#: 🔴 它是**硬**上限(超了必 E2BIG),而且与"解释器在不在"毫无关系 —— 这两件事从前被合成了一句
|
|
41
|
+
#: "找不到可用的 Python 解释器",是那个缺陷最难查的地方。
|
|
42
|
+
#: ⚠️ 这是 Linux 的数(生产部署在那儿);别的平台没有同一条规矩,这里**照它保守估**。
|
|
43
|
+
#: 代价只是"早报一句、少验一次",而盲目 execve 的代价是一句查不出来的 errno 7 —— 两边一比就清楚。
|
|
44
|
+
MAX_ARG_STRLEN = 32 * 4096 # 131072 = 128 KiB
|
|
45
|
+
|
|
46
|
+
#: 拿不到 `SC_ARG_MAX`(Windows / 平台不给)时按这个经验值当"argv + 环境块总量"那条线。
|
|
47
|
+
FALLBACK_ARG_MAX = 2 * 1024 * 1024
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def arg_max() -> int:
|
|
51
|
+
"""`argv` + 环境块**加起来**那条线(能问内核就问,问不到退回经验值)。
|
|
52
|
+
|
|
53
|
+
🔴 Linux 上它是 `SC_ARG_MAX`(默认 ≈ stack/4 ≈ 2 MB),而且 `execve` 先扣掉环境块本身
|
|
54
|
+
⇒ 真实可用的比这个数还小。这里只拿它当"必然失败"的闸门,不做任何折扣:
|
|
55
|
+
折扣估出来的是"可能会失败",而"可能"不该让判据少验一次(见文件头那条)。
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
value = int(os.sysconf('SC_ARG_MAX'))
|
|
59
|
+
except (AttributeError, ValueError, OSError): # Windows 没有 os.sysconf
|
|
60
|
+
return FALLBACK_ARG_MAX
|
|
61
|
+
return value if value > 0 else FALLBACK_ARG_MAX
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _size(text: Any) -> int:
|
|
65
|
+
"""一个字符串进内核时占多少字节(UTF-8;`surrogatepass` 兜住落单的代理字符)。"""
|
|
66
|
+
return len(str(text).encode('utf-8', 'surrogatepass'))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def oversize(argv: Sequence[str], env: Mapping[str, str] | None) -> str:
|
|
70
|
+
"""`(argv, env)` 里有没有**必然**让 `execve` 失败的那一条:回一句人话,`''` 表示没有。
|
|
71
|
+
|
|
72
|
+
🔴 只报"确定超线"的两种:单个字符串 > `MAX_ARG_STRLEN`、或总量 > `SC_ARG_MAX`
|
|
73
|
+
(见文件头那条:这一层误报一句 = 白白少验一次,而它拦的场景本来也会以 E2BIG 收场)。
|
|
74
|
+
🔴 报文里必须有**三项**:是哪一个(变量名 / argv 第几项)、它多大、上限多大 ——
|
|
75
|
+
少了任一项,看的人就只能去猜("把整包塞环境变量"那个根因正是这么藏起来的)。
|
|
76
|
+
"""
|
|
77
|
+
biggest, where = 0, ''
|
|
78
|
+
for name, value in (env or {}).items():
|
|
79
|
+
size = _size(value)
|
|
80
|
+
if size > biggest:
|
|
81
|
+
biggest, where = size, f'环境变量 {name}'
|
|
82
|
+
for index, item in enumerate(argv or ()):
|
|
83
|
+
size = _size(item)
|
|
84
|
+
if size > biggest:
|
|
85
|
+
biggest, where = size, f'argv 第 {index} 项'
|
|
86
|
+
if biggest > MAX_ARG_STRLEN:
|
|
87
|
+
return (f'{where} 是 {biggest:,} 字节,超过内核**单个**参数 / 环境字符串的上限 '
|
|
88
|
+
f'{MAX_ARG_STRLEN:,} 字节(MAX_ARG_STRLEN = 32 × 页面大小 = 128 KiB)')
|
|
89
|
+
total = sum(_size(f'{name}={value}') for name, value in (env or {}).items())
|
|
90
|
+
total += sum(_size(item) for item in (argv or ()))
|
|
91
|
+
limit = arg_max()
|
|
92
|
+
if total > limit:
|
|
93
|
+
return (f'argv 与环境变量加起来 {total:,} 字节,超过内核上限 {limit:,} 字节'
|
|
94
|
+
f'(SC_ARG_MAX;execve 还要先扣掉环境块本身)')
|
|
95
|
+
return ''
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Child:
|
|
100
|
+
"""一次子进程的结果:**永不抛** —— 起不来、超时都收在这个对象里(报文由调用方组)。"""
|
|
101
|
+
|
|
102
|
+
#: 进程真的起来了(`False` ⇒ 看 `why`,别去读 `returncode`)。
|
|
103
|
+
started: bool = True
|
|
104
|
+
#: 起来了但超时被杀(那两处报文不一样:深检说"中止"、试加载说"加载超时")。
|
|
105
|
+
timed_out: bool = False
|
|
106
|
+
returncode: int = 0
|
|
107
|
+
#: ⚠️ `None` 是"解码线程死了"那种情况(`errors='replace'` 之后基本见不到,但**不许静默**)。
|
|
108
|
+
stdout: str | None = None
|
|
109
|
+
stderr: str = ''
|
|
110
|
+
#: `started is False` 时的那一句人话。
|
|
111
|
+
why: str = ''
|
|
112
|
+
#: 这条失败是"内核的参数 / 环境上限"(E2BIG)—— **不是**这个块的错,调用方该按软失败处理。
|
|
113
|
+
arg_limit: bool = False
|
|
114
|
+
#: 这条失败是"真的没有解释器"(ENOENT)—— 环境坏了,什么都验不了。
|
|
115
|
+
missing: bool = False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _oserror_why(exc: OSError) -> str:
|
|
119
|
+
"""起不来时的一句人话:**按 errno 分开说**(E2BIG 与 ENOENT 从前被合成了一句,见文件头)。"""
|
|
120
|
+
number = getattr(exc, 'errno', None)
|
|
121
|
+
if number == errno.E2BIG:
|
|
122
|
+
return (f'内核拒绝了这一次 execve({exc};errno 7 = E2BIG)。'
|
|
123
|
+
f'⚠️ 这**不是**"找不到 Python 解释器"—— 解释器在,是递给内核的参数 / 环境太大'
|
|
124
|
+
f'(单个字符串上限 {MAX_ARG_STRLEN:,} 字节,总量上限 {arg_max():,} 字节)')
|
|
125
|
+
if number == errno.ENOENT:
|
|
126
|
+
return f'找不到可用的 Python 解释器({exc})'
|
|
127
|
+
return f'子进程起不来({type(exc).__name__}: {exc};errno={number})'
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_child(
|
|
131
|
+
argv: Sequence[str],
|
|
132
|
+
env: Mapping[str, str] | None,
|
|
133
|
+
payload: str,
|
|
134
|
+
timeout: int | float,
|
|
135
|
+
what: str,
|
|
136
|
+
) -> Child:
|
|
137
|
+
"""把 `payload` 从 **stdin** 递进去跑一次子进程(数据**不走** argv / 环境变量)。
|
|
138
|
+
|
|
139
|
+
:param what: 报文里怎么称呼这一次(`引用深检` / `试加载`)。
|
|
140
|
+
|
|
141
|
+
🔴 编码两头钉死:子进程按 UTF-8 读 stdin / 写 stdout,父进程按 UTF-8 解码。
|
|
142
|
+
只写 `text=True` 的话父进程用 `locale.getpreferredencoding(False)` 解码
|
|
143
|
+
(中文 Windows = cp936),而子进程一旦吐 UTF-8,`subprocess` 的**解码线程**就会抛
|
|
144
|
+
`UnicodeDecodeError` 死掉 —— 那个异常在**线程**里:主线程看不见、`returncode` 仍是 0,
|
|
145
|
+
症状是"这一层只是没跑起来"(真踩过;同一个坑见 `canvas/gitinfo.py` 的 `_run()`)。
|
|
146
|
+
⚠️ `errors='replace'` 同时管两头:stdout 那边防解码线程死掉,stdin 这边让正文里
|
|
147
|
+
落单的代理字符变成 `?` 而不是抛 `UnicodeEncodeError`(那种正文来自库 / JSON,
|
|
148
|
+
`validate._content_key()` 专门为它写了 `surrogatepass`)。
|
|
149
|
+
"""
|
|
150
|
+
reason = oversize(argv, env)
|
|
151
|
+
if reason:
|
|
152
|
+
# 🔴 前置判据拦住的那一种:**不要**再去 execve 一趟(那只会拿回一句 errno 7)。
|
|
153
|
+
return Child(started=False, why=f'{what}没法把这一包递出去:{reason}', arg_limit=True)
|
|
154
|
+
try:
|
|
155
|
+
done = subprocess.run(
|
|
156
|
+
list(argv),
|
|
157
|
+
env=dict(env or {}),
|
|
158
|
+
input=payload,
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
encoding='utf-8',
|
|
162
|
+
errors='replace',
|
|
163
|
+
timeout=timeout,
|
|
164
|
+
)
|
|
165
|
+
except subprocess.TimeoutExpired:
|
|
166
|
+
return Child(timed_out=True, returncode=-1)
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
number = getattr(exc, 'errno', None)
|
|
169
|
+
return Child(
|
|
170
|
+
started=False,
|
|
171
|
+
why=_oserror_why(exc),
|
|
172
|
+
arg_limit=(number == errno.E2BIG),
|
|
173
|
+
missing=(number == errno.ENOENT),
|
|
174
|
+
)
|
|
175
|
+
return Child(returncode=done.returncode, stdout=done.stdout, stderr=done.stderr or '')
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/validate.py
RENAMED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
而"保存说合法、体检说非法"是最难查的一类事故。
|
|
6
6
|
|
|
7
7
|
🔴 试加载与引用深检都在**子进程**里:那份代码是使用者写的,顶层死循环 / 主动 `os._exit`
|
|
8
|
-
|
|
8
|
+
会带走整个服务进程。超时(`TRIAL_LOAD_TIMEOUT` = 30 秒)**不算通过** —— "不知道"不等于"没问题"。
|
|
9
|
+
🔴 递给那两个子进程的**整包正文一律走 stdin**(`subproc.run_child()`):环境变量只有内核给的
|
|
10
|
+
128 KiB 单串额度,包一大就 E2BIG(详见 `subproc.py` 的文件头,那一层只有一份实现)。
|
|
9
11
|
🔴 引用深检那一层还要一份**基线**:`canvas.data.deleted_field_names()`("git 那版契约里有、
|
|
10
12
|
现在没了"的字段名)。它由 `baseline_fields()` 取、逐块递进子进程 —— 判据与画布上那份
|
|
11
13
|
字段差异**同源**(画布说"删了 N 个字段"、深检说"块里点的那几个还在不在",两处不许漂)。
|
|
@@ -21,7 +23,6 @@ import json
|
|
|
21
23
|
import logging
|
|
22
24
|
import os
|
|
23
25
|
import re
|
|
24
|
-
import subprocess
|
|
25
26
|
import sys
|
|
26
27
|
import threading
|
|
27
28
|
from pathlib import Path
|
|
@@ -39,6 +40,7 @@ from .sdk import (
|
|
|
39
40
|
is_excluded,
|
|
40
41
|
)
|
|
41
42
|
from . import blockpackage
|
|
43
|
+
from .subproc import run_child
|
|
42
44
|
|
|
43
45
|
logger = logging.getLogger('labull_framework.magic_block')
|
|
44
46
|
|
|
@@ -102,19 +104,29 @@ SDK_NAMES: tuple[str, ...] = ('MagicBackendAPI', 'errors')
|
|
|
102
104
|
_NO_DYNAMIC_CODE: frozenset[str] = frozenset({'eval', 'exec'})
|
|
103
105
|
|
|
104
106
|
#: 试加载的子进程超时(秒)。判据就是这个数。
|
|
105
|
-
|
|
107
|
+
#:
|
|
108
|
+
#: 🔴 10 → 30(2026-10,用户要求):与 `deepcheck.DEEP_TIMEOUT` **必须同一个数**。
|
|
109
|
+
#: 两者都是"起一个 `django.setup()` 子进程",慢的原因一模一样(项目大 ⇒ 冷启动就要几秒,
|
|
110
|
+
#: 再叠加并发那 4 个口子);只调其中一个,另一个仍会在 10 秒把好块判成不通过,
|
|
111
|
+
#: 只是换了一句报文("试加载超过 10 秒")。理由详见 `deepcheck.DEEP_TIMEOUT` 那一段。
|
|
112
|
+
TRIAL_LOAD_TIMEOUT = 30
|
|
106
113
|
|
|
107
114
|
#: 试加载脚本:真的 import 那份 SDK 模块、把块后端加载一次。
|
|
108
|
-
#: 🔴
|
|
109
|
-
#:
|
|
115
|
+
#: 🔴 块正文走 **stdin 里的 JSON**,不往脚本里拼字符串(正文里一个引号就能把脚本本身改坏),
|
|
116
|
+
#: 也**不走环境变量**(Linux 对单个环境字符串有 128 KiB 硬上限 ⇒ 包一大就 E2BIG,
|
|
117
|
+
#: 见 `subproc.py` 的文件头;那一层只有一份实现,两个子进程共用)。
|
|
118
|
+
#: 🔴 **整包**一起递进去(`files`):块包可以拆文件(`from helper import x`),
|
|
110
119
|
#: 只递主文件的话,拆过文件的块会在保存时报"找不到 helper"——而它明明在包里。
|
|
111
120
|
#: ⚠️ 这里**不做任何替身**:块 import 什么就真的 import 什么 —— 块能用的东西与项目里别的代码一模一样。
|
|
112
121
|
_TRIAL_SCRIPT = (
|
|
113
122
|
'import json, os, sys\n'
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
# 🔴 显式按 UTF-8 读 stdin(`sys.stdin.buffer`):子进程的 `sys.stdin.encoding` 是 locale
|
|
124
|
+
# 那一套(中文 Windows = cp936),而父进程递进来的是 UTF-8。
|
|
125
|
+
'raw = sys.stdin.buffer.read() if hasattr(sys.stdin, "buffer") else sys.stdin.read().encode("utf-8")\n'
|
|
126
|
+
'DATA = json.loads(raw.decode("utf-8", "replace") or "{}")\n'
|
|
127
|
+
'sys.path[:0] = [item for item in (DATA.get("path") or "").split(os.pathsep) if item]\n'
|
|
116
128
|
'from labull_framework.magic_block import blockpackage\n'
|
|
117
|
-
'loaded = blockpackage.load_block(
|
|
129
|
+
'loaded = blockpackage.load_block(DATA.get("files") or {}, tag=DATA.get("tag") or "trial")\n'
|
|
118
130
|
'if loaded.errors:\n'
|
|
119
131
|
' sys.stderr.write(";".join(loaded.errors))\n'
|
|
120
132
|
' sys.exit(1)\n'
|
|
@@ -747,43 +759,49 @@ def trial_load(src: str, files: dict[str, str] | None = None, tag: str = '') ->
|
|
|
747
759
|
:param files: 那一整包(拆过文件的块要靠它 —— 兄弟文件也要一起递进去)。
|
|
748
760
|
:param tag: 给包内模块起前缀用的(传块 uuid;不给就用固定串)。
|
|
749
761
|
|
|
750
|
-
🔴
|
|
762
|
+
🔴 超时(`TRIAL_LOAD_TIMEOUT` = 30 秒)**不算通过**:那是"不知道",不是"没问题"。
|
|
751
763
|
🔴 这条判据的价值在**及时报错**:块里 `import` 了一个项目里没装的包、拼错了模块名、
|
|
752
764
|
装饰器参数写错 —— 都在保存那一刻报出来,而不是等到某次请求炸。
|
|
765
|
+
🔴 **整包走 stdin**(不是环境变量):这一包有多大是作者说了算的,而内核给单个环境字符串的
|
|
766
|
+
额度只有 128 KiB(见 `subproc.py` 的文件头)—— 从前它一走环境变量,大块就必然 E2BIG,
|
|
767
|
+
报出来还是一句"找不到可用的 Python 解释器"(真因完全看不出来)。
|
|
753
768
|
"""
|
|
754
769
|
env = dict(os.environ)
|
|
755
770
|
# 🔴 把父进程的 sys.path 交过去:子进程默认的 path 里没有项目根,
|
|
756
771
|
# 不交的话每个块都会报"找不到 labull_framework / modules"——那是假错。
|
|
757
772
|
env['PYTHONPATH'] = os.pathsep.join(item for item in sys.path if item)
|
|
758
|
-
# 🔴
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
# 🔴 同 `deepcheck.deep_check()`:子进程按 UTF-8 写、父进程按 UTF-8 读,两头都得钉死。
|
|
763
|
-
# 只写 `text=True` 时父进程按 `locale.getpreferredencoding(False)`(中文 Windows = cp936)
|
|
764
|
-
# 解码,子进程吐 UTF-8 时解码线程会抛 `UnicodeDecodeError` 死掉 ⇒ `stderr` 变成 None,
|
|
765
|
-
# 这一层就只剩一句"加载失败(……):1"(真因被吞了)。
|
|
773
|
+
# 🔴 同 `deepcheck.deep_check()`:子进程按 UTF-8 读 stdin / 写 stdout,父进程按 UTF-8 解码,
|
|
774
|
+
# 两头都得钉死。只写 `text=True` 时父进程按 `locale.getpreferredencoding(False)`
|
|
775
|
+
# (中文 Windows = cp936)解码,子进程吐 UTF-8 时解码线程会抛 `UnicodeDecodeError`
|
|
776
|
+
# 死掉 ⇒ `stderr` 变成 None,这一层就只剩一句"加载失败(……):1"(真因被吞了)。
|
|
766
777
|
env['PYTHONIOENCODING'] = 'utf-8'
|
|
767
778
|
interpreter = sys.executable or 'python'
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
+
payload = json.dumps(
|
|
780
|
+
{
|
|
781
|
+
# 🔴 **整包**递进去(不只主文件):拆过文件的块要靠它才能 import 到兄弟文件。
|
|
782
|
+
'files': files_of(src),
|
|
783
|
+
'tag': tag or 'trial',
|
|
784
|
+
'path': os.pathsep.join(item for item in sys.path if item),
|
|
785
|
+
},
|
|
786
|
+
ensure_ascii=False,
|
|
787
|
+
)
|
|
788
|
+
child = run_child([interpreter, '-c', _TRIAL_SCRIPT], env, payload, TRIAL_LOAD_TIMEOUT, '试加载')
|
|
789
|
+
if child.timed_out:
|
|
779
790
|
return [f'{BLOCK_BACKEND_FILE} 试加载超过 {TRIAL_LOAD_TIMEOUT} 秒没有结束,已中止(顶层不许做耗时的事)']
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
791
|
+
if not child.started:
|
|
792
|
+
if child.arg_limit:
|
|
793
|
+
# 🔴 内核的参数 / 环境额度不够 ⇒ **软失败**(口径与 `deepcheck` 那一侧逐字一致):
|
|
794
|
+
# 回空 = "没验",而不是"这个块有问题"。⚠️ 这一条从前是一句 issue,而它在保存那条路上
|
|
795
|
+
# (`service._vet` 的 `if issues: raise SaveRejected`)⇒ 后果是**整个块存不回去**。
|
|
796
|
+
# ⚠️ 能看见的那一句"没验"由引用深检那一侧出(同一个原因它也一样起不来),
|
|
797
|
+
# 所以这里不重复报一遍(它就是"环境不够"这一件事的第二个受害者)。
|
|
798
|
+
return []
|
|
799
|
+
return [f'{BLOCK_BACKEND_FILE} 试加载跑不起来:{child.why}']
|
|
800
|
+
|
|
801
|
+
if child.returncode == 0:
|
|
784
802
|
return []
|
|
785
|
-
lines = (
|
|
786
|
-
return [f'{BLOCK_BACKEND_FILE} 加载失败(顶层与装饰器不许出错):{lines[-1] if lines else
|
|
803
|
+
lines = (child.stderr or '').strip().splitlines()
|
|
804
|
+
return [f'{BLOCK_BACKEND_FILE} 加载失败(顶层与装饰器不许出错):{lines[-1] if lines else child.returncode}']
|
|
787
805
|
|
|
788
806
|
|
|
789
807
|
# ──────────────────────────── 路由名(唯一一处扫它) ────────────────────────────
|
|
@@ -217,7 +217,8 @@ def apply_platform_settings(ns: dict) -> None:
|
|
|
217
217
|
# 🔴 逐块校验(读回改动 / 保存 / 体检)同时开几个口子:一个块的结论要起两个子进程
|
|
218
218
|
# (试加载 + 引用深检),串行时 20 个块要 17 秒 —— 实测 4 个口子 5.5 秒。
|
|
219
219
|
# ⚠️ 不是越大越好:每个口子都会拉起一个 `django.setup()` 子进程,而子进程一慢就会撞上
|
|
220
|
-
#
|
|
220
|
+
# 那两个子进程超时(`validate.TRIAL_LOAD_TIMEOUT` / `deepcheck.DEEP_TIMEOUT`,现在都是 30 秒;
|
|
221
|
+
# 超时**不算通过** ⇒ 本来好好的块会被判成不通过)。理由与实测见 `magic_block/parallel.py`。
|
|
221
222
|
ns.setdefault('LABULL_BLOCK_WORKERS', BLOCK_WORKERS)
|
|
222
223
|
|
|
223
224
|
database_url = env.read_env('DATABASE_URL')
|
|
@@ -39,6 +39,7 @@ labull_framework/magic_block/parallel.py
|
|
|
39
39
|
labull_framework/magic_block/registry.py
|
|
40
40
|
labull_framework/magic_block/sdk.py
|
|
41
41
|
labull_framework/magic_block/service.py
|
|
42
|
+
labull_framework/magic_block/subproc.py
|
|
42
43
|
labull_framework/magic_block/validate.py
|
|
43
44
|
labull_framework/magic_block/views.py
|
|
44
45
|
labull_framework/management/__init__.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "labull-framework"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.32"
|
|
8
8
|
description = "Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks"
|
|
9
9
|
readme = "README-pypi.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -41,7 +41,7 @@ classifiers = [
|
|
|
41
41
|
# 与 `apps.py` 拉起 ⇒ 属于**运行时必需**,不是可选项。
|
|
42
42
|
# `[crypto]`:OIDC 的 RS/PS/ES/EdDSA 走 JWKS 验签,需要 `cryptography` 后端;
|
|
43
43
|
# Authing 那支 HS256 也走同一个后端 ⇒ 一起钉上。
|
|
44
|
-
# 其余顶层名字全是标准库:ast collections dataclasses datetime decimal functools inspect
|
|
44
|
+
# 其余顶层名字全是标准库:ast collections dataclasses datetime decimal errno functools inspect
|
|
45
45
|
# json logging os pathlib re subprocess sys threading time typing urllib uuid。
|
|
46
46
|
dependencies = [
|
|
47
47
|
"Django>=4.2",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/static/canvas.css
RENAMED
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/static/canvas.js
RENAMED
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/canvas/static/page.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/__init__.py
RENAMED
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/blockpackage.py
RENAMED
|
File without changes
|
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/dispatch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/magic_block/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/management/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework/migrations/0001_initial.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{labull_framework-0.1.30 → labull_framework-0.1.32}/labull_framework.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|