Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ jobs:
- run: python -m pip install --upgrade pip
- run: python -m pip install -r requirements.txt
- run: npm ci
- name: Record managed short-process startup benchmark
run: python scripts/benchmark_process_startup.py --rounds 40 --warmup 5
- run: npm run check
- name: Verify both npm package contents
run: npm run pack:check
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## [Unreleased]

- Unix 内存限制 helper 改为隔离 Python 的内联 `-c` 启动,保留 `RLIMIT_AS`、状态管道、信号复位、session/process group 与 fail-closed 语义,同时避免 WSL 从 Windows 挂载目录逐次读取 helper 文件的冷启动开销。
- 发布包清单检查同时兼容 npm 10 的单元素数组与 npm 12 的单包名对象 JSON 响应,畸形或多包响应仍 fail closed。
- 主包 README、兼容包 README、Agent Skill 与 Release 安装说明统一为 Node.js 18+ / Python 3.10+ 的系统 Python 显式授权流程;非虚拟环境依赖只写入用户目录并兼容 Ubuntu PEP 668,pip 子进程清除 Python 环境污染;临时 `npx`、Doctor 修复和 WebUI 检查不再遗漏 `PROBHUB_ALLOW_SYSTEM_PYTHON=1`,安装器报错不再引导用户创建虚拟环境。
- 空工作区锁文件只在取得 OS 文件锁后初始化,消除 Windows 并发 generation 首次启动时的写入、刷新与关闭竞态。
- Build Manifest 升至 schema v4、试卷 generation 升至 schema v3,统一记录 ProbHub/Core、Typst、pypdf、模板与固定字体的 `builder_fingerprint`;`status` 提供字段级 stale 原因,旧 schema 和不可探测工具链不再误报 `current`,build/seal/generation 在发布前以 `builder_changed` 阻断身份漂移。Noto Sans CJK SC 与许可证改为随 npm 包发布,Typst 正式编译只使用校验后的包内字体。
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"!scripts/check_clean_install.py",
"!scripts/check_release.py",
"!scripts/audit_python_dependencies.py",
"!scripts/benchmark_process_startup.py",
"probhub/**/*.py",
"probhub/assets/fonts/**"
],
Expand Down
52 changes: 0 additions & 52 deletions probhub/_unix_exec.py

This file was deleted.

63 changes: 55 additions & 8 deletions probhub/process_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,56 @@
UNIX_EXEC_START_TIMEOUT_SECONDS = 10.0
UNIX_EXEC_READY = b"PROBHUB_UNIX_EXEC_READY_V1\n"
UNIX_EXEC_STATUS_LIMIT = 64 * 1024
UNIX_EXEC_HELPER_CODE = r'''
import os
import signal
import sys

READY = b"PROBHUB_UNIX_EXEC_READY_V1\n"


def report_failure(status_fd, message):
try:
os.write(status_fd, ("Unix execution helper failed: " + message).encode("utf-8"))
except OSError:
pass


def main():
argv = sys.argv[1:]
status_fd = None
try:
if len(argv) < 7 or argv[0] != "--memory-limit-bytes" or argv[2] != "--status-fd":
raise ValueError("invalid helper arguments")
limit_bytes = int(argv[1])
status_fd = int(argv[3])
if argv[4] not in ("--restore-signals", "--keep-signals"):
raise ValueError("invalid signal restore mode")
restore_signals = argv[4] == "--restore-signals"
if argv[5] != "--" or not argv[6:]:
raise ValueError("target command is missing")
if limit_bytes <= 0:
raise ValueError("memory limit must be positive")

import resource

os.set_inheritable(status_fd, False)
resource.setrlimit(resource.RLIMIT_AS, (limit_bytes, limit_bytes))
if restore_signals:
for name in ("SIGPIPE", "SIGXFZ", "SIGXFSZ"):
signum = getattr(signal, name, None)
if signum is not None:
signal.signal(signum, signal.SIG_DFL)
os.write(status_fd, READY)
os.execvpe(argv[6], argv[6:], os.environ)
except BaseException as exc:
if status_fd is not None:
report_failure(status_fd, str(exc))
return 127


raise SystemExit(main())
'''.lstrip()


class ProcessCancelled(Exception):
Expand Down Expand Up @@ -112,21 +162,19 @@ def _prepare_unix_memory_limited_command(command, memory_limit_mb):
command = list(command)
if not command:
raise ValueError("memory-limited Unix target command is missing")
helper = Path(__file__).with_name("_unix_exec.py")
if not helper.is_file():
raise OSError(f"Unix execution helper is missing: {helper}")
limit_bytes = int(float(memory_limit_mb) * 1024 * 1024)
if limit_bytes <= 0:
raise ValueError("memory limit must be positive")
return command, helper, limit_bytes
return command, limit_bytes


def _unix_memory_limited_command(command, helper, limit_bytes, status_fd, restore_signals):
def _unix_memory_limited_command(command, limit_bytes, status_fd, restore_signals):
return [
sys.executable,
"-I",
"-S",
str(helper),
"-c",
UNIX_EXEC_HELPER_CODE,
"--memory-limit-bytes",
str(limit_bytes),
"--status-fd",
Expand Down Expand Up @@ -676,7 +724,7 @@ def spawn_managed(
if kwargs.get("executable") is not None:
raise ValueError("memory-limited Unix commands do not support executable=")
pass_fds = tuple(kwargs.get("pass_fds") or ())
command, helper, limit_bytes = _prepare_unix_memory_limited_command(
command, limit_bytes = _prepare_unix_memory_limited_command(
command, memory_limit_mb
)
read_fd, write_fd = os.pipe()
Expand All @@ -686,7 +734,6 @@ def spawn_managed(
kwargs["close_fds"] = True
command = _unix_memory_limited_command(
command,
helper,
limit_bytes,
write_fd,
kwargs.get("restore_signals", True),
Expand Down
4 changes: 2 additions & 2 deletions references/process-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ Windows 虚拟环境的 `python.exe` 可能是一个重定向启动器,尤其
Linux/Unix 使用:

- 独立 session/process group;
- 由独立 exec helper 在子进程内设置 `RLIMIT_AS`,随后以 `exec` 原位替换为目标程序;
- 由隔离 Python 启动的内联 exec helper 在子进程内设置 `RLIMIT_AS`,随后以 `exec` 原位替换为目标程序;
- Linux `/proc` 低频采样整棵进程树的 RSS 和进程数;
- `killpg` 在结束时清理进程组。

Flask 多线程请求和 CLI 都不会在父进程中使用 Python `preexec_fn`。helper 会通过仅在成功 `exec` 时关闭的状态管道报告参数、`setrlimit` 或 `exec` 失败启动阶段超时同样 fail closed,不会退化成无内存限制执行。
Flask 多线程请求和 CLI 都不会在父进程中使用 Python `preexec_fn`。helper 代码通过 `python -I -S -c` 传入,避免 WSL 在 Windows 挂载目录中为每个短进程重新打开 helper 脚本;它仍通过仅在成功 `exec` 时关闭的状态管道报告参数、`setrlimit` 或 `exec` 失败启动阶段超时同样 fail closed,不会退化成无内存限制执行。

资源采样约每 `50 ms` 进行一次,时间和输出检查使用更短轮询,以降低大量短进程和 stress 场景的监控开销。Linux 的 `RLIMIT_AS` 是每进程地址空间限制,和 Windows Job 的整树共享内存配额并不完全等价;ProbHub 同时使用 `/proc` 聚合 RSS 做补充监控。无法使用 `/proc` 时,进程组清理与 `RLIMIT_AS` 仍然有效,但进程数和聚合内存遥测能力会受限。

Expand Down
104 changes: 104 additions & 0 deletions scripts/benchmark_process_startup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Measure managed short-process startup latency with and without a memory limit."""

import argparse
import json
import math
import platform
import statistics
import subprocess
import sys
import time
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

from probhub.process_control import spawn_managed


def _target_command():
if platform.system() == "Windows":
return [sys.executable, "-I", "-S", "-c", "pass"]
return ["/bin/true"]


def _percentile(values, percentile):
index = max(0, min(len(values) - 1, math.ceil(len(values) * percentile) - 1))
return values[index]


def _measure(command, *, rounds, warmup, memory_limit_mb):
values = []
for index in range(rounds + warmup):
started = time.perf_counter()
managed = spawn_managed(
command,
memory_limit_mb=memory_limit_mb,
process_limit=8,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
returncode = managed.proc.wait(timeout=10)
finally:
try:
if managed.proc.poll() is None:
managed.terminate()
managed.proc.wait(timeout=2)
finally:
managed.close()
if returncode != 0:
raise RuntimeError(f"benchmark target exited with {returncode}")
elapsed_ms = (time.perf_counter() - started) * 1000
if index >= warmup:
values.append(elapsed_ms)
values.sort()
return {
"rounds": len(values),
"mean_ms": statistics.mean(values),
"p50_ms": statistics.median(values),
"p95_ms": _percentile(values, 0.95),
"min_ms": values[0],
"max_ms": values[-1],
}


def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--rounds", type=int, default=100)
parser.add_argument("--warmup", type=int, default=10)
parser.add_argument("--memory-limit-mb", type=int, default=256)
args = parser.parse_args(argv)
if args.rounds <= 0 or args.warmup < 0 or args.memory_limit_mb <= 0:
parser.error("rounds and memory limit must be positive; warmup must be non-negative")

command = _target_command()
unlimited = _measure(
command,
rounds=args.rounds,
warmup=args.warmup,
memory_limit_mb=None,
)
limited = _measure(
command,
rounds=args.rounds,
warmup=args.warmup,
memory_limit_mb=args.memory_limit_mb,
)
print(json.dumps({
"platform": platform.platform(),
"python": sys.version.split()[0],
"command": command,
"memory_limit_mb": args.memory_limit_mb,
"unlimited": unlimited,
"memory_limited": limited,
"incremental_p50_ms": limited["p50_ms"] - unlimited["p50_ms"],
}, ensure_ascii=False, indent=2))
return 0


if __name__ == "__main__":
raise SystemExit(main())
11 changes: 7 additions & 4 deletions scripts/check_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,13 @@ def _parse_npm_json(stdout, command):
payload = json.loads(stdout)
except json.JSONDecodeError as exc:
raise ReleaseCheckError(f"invalid npm JSON from {' '.join(command)}: {stdout[-2000:]}") from exc
if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict):
raise ReleaseCheckError(f"unexpected npm pack response: {payload!r}")
return payload[0]
if isinstance(payload, list) and len(payload) == 1 and isinstance(payload[0], dict):
return payload[0]
if isinstance(payload, dict) and len(payload) == 1:
manifest = next(iter(payload.values()))
if isinstance(manifest, dict):
return manifest
raise ReleaseCheckError(f"unexpected npm pack response: {payload!r}")


def npm_pack_manifest(target=None, *, dry_run=True, destination=None):
Expand Down Expand Up @@ -185,7 +189,6 @@ def validate_pack_inventories(*, dry_run=True, destination=None):
"requirements.txt", "bin/init.js", "bin/probhub.js", "bin/python.js",
"probhub/__init__.py", "probhub/cli.py", "probhub/install_deps.py",
"probhub/install_skill.py", "probhub/process_control.py",
"probhub/_unix_exec.py",
"probhub/webui_runtime.py",
"probhub/assets/fonts/NotoSansCJKsc-Regular.otf",
"probhub/assets/fonts/OFL.txt",
Expand Down
25 changes: 25 additions & 0 deletions tests/test_npm_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,31 @@ def test_release_metadata_gate_passes_for_the_source_tree(self):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(json.loads(result.stdout)["ok"])

def test_npm_pack_json_accepts_legacy_and_current_single_package_shapes(self):
from scripts import check_release

manifest = {"name": "probhub", "files": [{"path": "package.json"}]}
command = ["npm", "pack", "--json"]
self.assertEqual(
check_release._parse_npm_json(json.dumps([manifest]), command),
manifest,
)
self.assertEqual(
check_release._parse_npm_json(
json.dumps({"probhub": manifest}),
command,
),
manifest,
)

for payload in ([], {}, {"probhub": []}, {"one": manifest, "two": manifest}):
with self.subTest(payload=payload):
with self.assertRaisesRegex(
check_release.ReleaseCheckError,
"unexpected npm pack response",
):
check_release._parse_npm_json(json.dumps(payload), command)

def test_publish_release_gate_requires_matching_tag_and_clean_worktree(self):
from scripts import check_release

Expand Down
Loading
Loading