procproxy 0.1.4.1__tar.gz → 0.1.4.2__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.
- {procproxy-0.1.4.1/src/procproxy.egg-info → procproxy-0.1.4.2}/PKG-INFO +24 -3
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/README.md +23 -2
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/docs/15-/347/274/226/347/250/213/346/216/245/345/217/243/344/270/216SDK.md +151 -5
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/examples/verify_sdk.py +22 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/pyproject.toml +1 -1
- {procproxy-0.1.4.1 → procproxy-0.1.4.2/src/procproxy.egg-info}/PKG-INFO +24 -3
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/SOURCES.txt +2 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/__init__.py +1 -1
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/api.py +54 -16
- procproxy-0.1.4.2/src/proxycore/background.py +461 -0
- procproxy-0.1.4.2/src/proxycore/errors.py +20 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/runtime.py +2 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/LICENSE +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/MANIFEST.in +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/docs/00-/346/236/266/346/236/204/344/270/216/351/230/205/350/257/273/350/267/257/347/272/277.md" +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/docs/07-/350/247/204/345/210/231/345/274/225/346/223/216/344/270/216Profile/346/240/274/345/274/217.md" +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/docs/11-/350/260/203/350/257/225/346/216/222/351/224/231/346/211/213/345/206/214.md" +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/docs/14-/346/212/212/350/277/233/347/250/213/346/265/201/351/207/217/350/275/254/345/217/221/347/273/231/346/212/223/345/214/205/345/267/245/345/205/267.md" +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/examples/proxy_process.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/setup.cfg +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/dependency_links.txt +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/entry_points.txt +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/requires.txt +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/top_level.txt +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/__main__.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/__init__.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/base.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/null.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/__init__.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/filter.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/pidmap.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/pkt.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/canproxy.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/checkroute.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/cli.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/config.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/config_init.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/config_source.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/data/default.yaml +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/dns/__init__.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/dns/cache.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/dns/modes.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/dns/udp.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/forward.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/ipc.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/log.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/pathsetup.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/probe.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/proc.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/rules.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/service/__init__.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/service/__main__.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/tunnel/__init__.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/tunnel/relay.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/tunnel/sessions.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/tunnel/sink.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/types.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/__init__.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/base.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/chain.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/http_connect.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/socks4.py +0 -0
- {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/socks5.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: procproxy
|
|
3
|
-
Version: 0.1.4.
|
|
3
|
+
Version: 0.1.4.2
|
|
4
4
|
Summary: ProcProxy —— 进程级代理:按进程决定哪条连接走哪个上游代理
|
|
5
5
|
Author: Longersking
|
|
6
6
|
License-Expression: MIT
|
|
@@ -507,6 +507,24 @@ finally:
|
|
|
507
507
|
`start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
|
|
508
508
|
请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
|
|
509
509
|
|
|
510
|
+
### 后台常驻(父进程可退出)
|
|
511
|
+
|
|
512
|
+
```python
|
|
513
|
+
from proxycore.api import get_status, start_proxy, stop_proxy
|
|
514
|
+
|
|
515
|
+
bg = start_proxy("D:/cfg/chrome.yaml", background=True)
|
|
516
|
+
print(bg.pid, bg.config_path, bg.runtime_dir)
|
|
517
|
+
# 父进程可以退出;独立子进程继续运行
|
|
518
|
+
|
|
519
|
+
print(get_status(bg.runtime_dir)) # 跨进程读 state.json
|
|
520
|
+
print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
|
|
524
|
+
- 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
|
|
525
|
+
- `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
|
|
526
|
+
- 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
|
|
527
|
+
|
|
510
528
|
### 错误处理
|
|
511
529
|
|
|
512
530
|
配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
|
|
@@ -528,6 +546,9 @@ except ProxyStartError as exc:
|
|
|
528
546
|
| `config_version_unsupported` | `version != 1` |
|
|
529
547
|
| `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
|
|
530
548
|
| `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
|
|
549
|
+
| `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
|
|
550
|
+
| `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
|
|
551
|
+
| `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
|
|
531
552
|
|
|
532
553
|
`api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
|
|
533
554
|
|
|
@@ -590,7 +611,7 @@ src/proxycore/ 核心包
|
|
|
590
611
|
samples/ mock 服务 + 示例配置
|
|
591
612
|
examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
|
|
592
613
|
docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
|
|
593
|
-
tests/ 测试套件(
|
|
614
|
+
tests/ 测试套件(633 passed / 4 skipped)
|
|
594
615
|
└── tools/ 验证脚本(run_demo / run_e2e / bench / …)
|
|
595
616
|
```
|
|
596
617
|
|
|
@@ -621,7 +642,7 @@ tests/ 测试套件(625 passed / 4 skipped)
|
|
|
621
642
|
|
|
622
643
|
```bash
|
|
623
644
|
pip install -e ".[dev]"
|
|
624
|
-
python -m pytest -q tests/ #
|
|
645
|
+
python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
|
|
625
646
|
python -m ruff check src tests
|
|
626
647
|
python -m mypy src/proxycore
|
|
627
648
|
```
|
|
@@ -468,6 +468,24 @@ finally:
|
|
|
468
468
|
`start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
|
|
469
469
|
请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
|
|
470
470
|
|
|
471
|
+
### 后台常驻(父进程可退出)
|
|
472
|
+
|
|
473
|
+
```python
|
|
474
|
+
from proxycore.api import get_status, start_proxy, stop_proxy
|
|
475
|
+
|
|
476
|
+
bg = start_proxy("D:/cfg/chrome.yaml", background=True)
|
|
477
|
+
print(bg.pid, bg.config_path, bg.runtime_dir)
|
|
478
|
+
# 父进程可以退出;独立子进程继续运行
|
|
479
|
+
|
|
480
|
+
print(get_status(bg.runtime_dir)) # 跨进程读 state.json
|
|
481
|
+
print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
- 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
|
|
485
|
+
- 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
|
|
486
|
+
- `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
|
|
487
|
+
- 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
|
|
488
|
+
|
|
471
489
|
### 错误处理
|
|
472
490
|
|
|
473
491
|
配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
|
|
@@ -489,6 +507,9 @@ except ProxyStartError as exc:
|
|
|
489
507
|
| `config_version_unsupported` | `version != 1` |
|
|
490
508
|
| `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
|
|
491
509
|
| `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
|
|
510
|
+
| `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
|
|
511
|
+
| `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
|
|
512
|
+
| `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
|
|
492
513
|
|
|
493
514
|
`api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
|
|
494
515
|
|
|
@@ -551,7 +572,7 @@ src/proxycore/ 核心包
|
|
|
551
572
|
samples/ mock 服务 + 示例配置
|
|
552
573
|
examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
|
|
553
574
|
docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
|
|
554
|
-
tests/ 测试套件(
|
|
575
|
+
tests/ 测试套件(633 passed / 4 skipped)
|
|
555
576
|
└── tools/ 验证脚本(run_demo / run_e2e / bench / …)
|
|
556
577
|
```
|
|
557
578
|
|
|
@@ -582,7 +603,7 @@ tests/ 测试套件(625 passed / 4 skipped)
|
|
|
582
603
|
|
|
583
604
|
```bash
|
|
584
605
|
pip install -e ".[dev]"
|
|
585
|
-
python -m pytest -q tests/ #
|
|
606
|
+
python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
|
|
586
607
|
python -m ruff check src tests
|
|
587
608
|
python -m mypy src/proxycore
|
|
588
609
|
```
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
> 发行包名是 `procproxy`,导入名是 `proxycore`。`pip install procproxy` 同时安装
|
|
6
6
|
> 库和可选的 CLI;CLI 只是库的外壳,不使用它也能完成初始化、代理、查询和停止。
|
|
7
7
|
|
|
8
|
-
本文既是使用教程,也是**行为契约**。对应 `proxycore 0.1.4.
|
|
9
|
-
`examples/verify_sdk.py`(
|
|
8
|
+
本文既是使用教程,也是**行为契约**。对应 `proxycore 0.1.4.2`;§10 的验收脚本
|
|
9
|
+
`examples/verify_sdk.py`(31 项断言,含后台进程)可以直接用来检查一份实现是否与本文一致。
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -429,7 +429,111 @@ ppx.stop() # 或在 with 退出时自动停止
|
|
|
429
429
|
|
|
430
430
|
停止后不再捕获新连接;已经建立的连接由关闭流程处理。
|
|
431
431
|
|
|
432
|
-
### 6.7
|
|
432
|
+
### 6.7 后台常驻:父进程退出后继续运行
|
|
433
|
+
|
|
434
|
+
适合"应用只负责拉起数据面,随后退出"的场景。
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
from proxycore.api import get_status, start_proxy, stop_proxy
|
|
438
|
+
|
|
439
|
+
bg = start_proxy("D:/cfg/chrome.yaml", background=True)
|
|
440
|
+
print(bg.pid) # 后台数据面 PID
|
|
441
|
+
print(bg.config_path) # 生效配置的绝对路径
|
|
442
|
+
print(bg.runtime_dir) # state.json / control.json 所在目录
|
|
443
|
+
|
|
444
|
+
# 父进程可以退出;数据面继续运行
|
|
445
|
+
state = get_status(bg.runtime_dir) # 读 state.json(跨进程、只读文件)
|
|
446
|
+
result = stop_proxy(bg.runtime_dir) # 写 control.json,等待子进程退出
|
|
447
|
+
print(result.to_dict())
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
`start_proxy(background=True)` 的完整签名(其余参数与前台一致):
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
def start_proxy(
|
|
454
|
+
config=None,
|
|
455
|
+
*,
|
|
456
|
+
backend="",
|
|
457
|
+
sink_test_target="",
|
|
458
|
+
upstream="",
|
|
459
|
+
log_level="",
|
|
460
|
+
log_jsonl="",
|
|
461
|
+
background: bool = False,
|
|
462
|
+
runtime_dir: str | os.PathLike[str] | None = None,
|
|
463
|
+
wait_timeout_s: float = 10.0,
|
|
464
|
+
replace: bool = False,
|
|
465
|
+
) -> ProxyHandle | BackgroundProxy: ...
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
返回的 `BackgroundProxy`:
|
|
469
|
+
|
|
470
|
+
| 字段 | 类型 | 说明 |
|
|
471
|
+
|---|---|---|
|
|
472
|
+
| `pid` | `int` | 后台数据面进程 PID |
|
|
473
|
+
| `config_path` | `str` | 子进程实际加载的配置绝对路径 |
|
|
474
|
+
| `runtime_dir` | `str` | 状态/控制文件目录 |
|
|
475
|
+
| `state_path` | `str` | `<runtime_dir>/state.json` |
|
|
476
|
+
| `control_path` | `str` | `<runtime_dir>/control.json` |
|
|
477
|
+
| `log_path` | `str` | `<runtime_dir>/daemon.log`(子进程 stdout/stderr) |
|
|
478
|
+
| `backend` | `str` | `null` / `windivert` |
|
|
479
|
+
| `started_at` | `float` | 启动时间戳 |
|
|
480
|
+
|
|
481
|
+
`BackgroundProxy.to_dict()` 返回上述字段的字典。
|
|
482
|
+
|
|
483
|
+
#### 文件协议(与 CLI 完全一致)
|
|
484
|
+
|
|
485
|
+
| 文件 | 方向 | 内容 |
|
|
486
|
+
|---|---|---|
|
|
487
|
+
| `state.json` | 数据面 → 应用 | 周期性状态;含 `pid`、`backend`、`started_at`、`config`、会话/计数等 |
|
|
488
|
+
| `control.json` | 应用 → 数据面 | `{"action": "stop"}` 或 `{"action": "panic", "ts": ...}`;数据面 1 秒内消费并删除 |
|
|
489
|
+
| `sessions.jsonl` | 数据面 → 应用 | 会话日志(CLI 默认路径) |
|
|
490
|
+
| `daemon.log` | 数据面 → 应用 | 子进程 stdout/stderr,启动失败时看这里 |
|
|
491
|
+
|
|
492
|
+
`runtime_dir` 解析顺序:
|
|
493
|
+
|
|
494
|
+
1. `start_proxy(..., runtime_dir=...)` 显式参数;
|
|
495
|
+
2. 环境变量 `PROCPROXY_RUNTIME_DIR`;
|
|
496
|
+
3. `~/.procproxy-app`。
|
|
497
|
+
|
|
498
|
+
子进程启动时会被显式设置 `PROCPROXY_RUNTIME_DIR=<解析后的绝对路径>`,
|
|
499
|
+
所以父进程和应用侧的 `get_status` / `stop_proxy` 只要使用同一个目录即可。
|
|
500
|
+
|
|
501
|
+
#### 行为细节
|
|
502
|
+
|
|
503
|
+
- **父进程退出不影响子进程**:Windows 使用 `DETACHED_PROCESS`,POSIX 使用
|
|
504
|
+
`start_new_session=True`;子进程 stdout/stderr 重定向到 `daemon.log`;
|
|
505
|
+
- **配置对象会被落盘**:传 `Config` 对象时写到 `<runtime_dir>/config.yaml`,
|
|
506
|
+
再由子进程加载;传路径时直接使用该绝对路径;
|
|
507
|
+
- **重复启动**:同一 `runtime_dir` 已有存活数据面时抛
|
|
508
|
+
`ProxyStartError(code="already_running")`;`replace=True` 会先停止旧进程;
|
|
509
|
+
- **启动失败**:子进程退出或超时分别抛 `background_start_failed` /
|
|
510
|
+
`background_start_timeout`,异常文本附带 `daemon.log` 最后 20 行;
|
|
511
|
+
- **权限**:子进程继承父进程权限;`backend="windivert"` 仍然要求父进程以管理员启动;
|
|
512
|
+
- **停止不强制杀进程**:`stop_proxy` 只写控制文件并等待;超时返回
|
|
513
|
+
`StopResult(stopped=False, reason="timeout")`,不会静默杀掉别人的进程。
|
|
514
|
+
|
|
515
|
+
#### 跨进程查询/停止的独立函数
|
|
516
|
+
|
|
517
|
+
```python
|
|
518
|
+
from proxycore.api import get_status, is_running, resolve_runtime_dir, stop_proxy
|
|
519
|
+
|
|
520
|
+
get_status(runtime_dir=None) -> dict | None # 读 state.json;不存在/非法返回 None
|
|
521
|
+
is_running(runtime_dir=None) -> bool # state.json 的 pid 是否存活
|
|
522
|
+
stop_proxy(runtime_dir=None, *, timeout_s=10.0) -> StopResult
|
|
523
|
+
resolve_runtime_dir(runtime_dir=None) -> Path # 显式 → 环境变量 → ~/.procproxy-app
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
`StopResult` 字段:`stopped: bool`、`pid: int`、`runtime_dir: str`、
|
|
527
|
+
`control_path: str`、`reason: str`(`stopped` / `not_running` / `timeout`)。
|
|
528
|
+
|
|
529
|
+
> 你现有的 `get_status` / `stop_proxy` 只要使用同一个 `runtime_dir` 和上述
|
|
530
|
+
> 文件名,就与 SDK 完全兼容;不需要改 state.json / control.json 的格式。
|
|
531
|
+
>
|
|
532
|
+
> CLI 的 `procproxy stop` 使用 CLI 自己的 runtime_dir;要停止 SDK 后台数据面,
|
|
533
|
+
> 请设置 `PROCPROXY_RUNTIME_DIR=<runtime_dir>` 后再执行,或直接调用
|
|
534
|
+
> `proxycore.api.stop_proxy(runtime_dir)`。
|
|
535
|
+
|
|
536
|
+
### 6.8 没有管理员怎么办
|
|
433
537
|
|
|
434
538
|
只能做联调:
|
|
435
539
|
|
|
@@ -629,6 +733,41 @@ class ProxyStartError(RuntimeError):
|
|
|
629
733
|
|
|
630
734
|
`str(exc)` 是给人看的中文信息;程序判断请用 `exc.code`。
|
|
631
735
|
|
|
736
|
+
### 7.8 后台常驻 API
|
|
737
|
+
|
|
738
|
+
```python
|
|
739
|
+
def start_proxy_background(
|
|
740
|
+
config: Config | str | os.PathLike[str] | None = None,
|
|
741
|
+
*,
|
|
742
|
+
backend: str = "",
|
|
743
|
+
sink_test_target: str = "",
|
|
744
|
+
upstream: str = "",
|
|
745
|
+
log_level: str = "",
|
|
746
|
+
log_jsonl: str = "",
|
|
747
|
+
runtime_dir: str | os.PathLike[str] | None = None,
|
|
748
|
+
wait_timeout_s: float = 10.0,
|
|
749
|
+
replace: bool = False,
|
|
750
|
+
) -> BackgroundProxy: ...
|
|
751
|
+
|
|
752
|
+
def get_status(runtime_dir=None) -> dict | None: ...
|
|
753
|
+
def is_running(runtime_dir=None) -> bool: ...
|
|
754
|
+
def stop_proxy(runtime_dir=None, *, timeout_s: float = 10.0) -> StopResult: ...
|
|
755
|
+
def resolve_runtime_dir(runtime_dir=None) -> Path: ...
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
`start_proxy(..., background=True, runtime_dir=..., wait_timeout_s=..., replace=...)`
|
|
759
|
+
是同一入口;`start_proxy_background()` 便于类型明确的调用方直接使用。
|
|
760
|
+
|
|
761
|
+
异常码:
|
|
762
|
+
|
|
763
|
+
| code | 触发条件 |
|
|
764
|
+
|---|---|
|
|
765
|
+
| `already_running` | 同一 runtime_dir 已有存活数据面,且 `replace=False` |
|
|
766
|
+
| `background_start_failed` | 子进程在写出 state.json 前退出(附 daemon.log 末尾) |
|
|
767
|
+
| `background_start_timeout` | 等待 state.json 超时;子进程会被终止 |
|
|
768
|
+
| `runtime_dir_unavailable` | runtime_dir 无法创建/写入 |
|
|
769
|
+
| `config_not_found` 等 | 配置阶段失败,与前台一致 |
|
|
770
|
+
|
|
632
771
|
---
|
|
633
772
|
|
|
634
773
|
## 8. 配置参考
|
|
@@ -783,7 +922,7 @@ udp_policy:
|
|
|
783
922
|
## 10. 验收清单(给实现方/文档校验)
|
|
784
923
|
|
|
785
924
|
仓库里的 [`examples/verify_sdk.py`](https://github.com/Longersking/procproxy/blob/master/examples/verify_sdk.py)
|
|
786
|
-
是一个
|
|
925
|
+
是一个 31 项断言的可执行契约:
|
|
787
926
|
|
|
788
927
|
```bash
|
|
789
928
|
python examples/verify_sdk.py
|
|
@@ -802,7 +941,10 @@ python examples/verify_sdk.py
|
|
|
802
941
|
9. `start_proxy(path)` 的 `config_source == "explicit"`;
|
|
803
942
|
10. `status()` 含 `backend/rules/sessions/breaker/dns/udp/config_path/config_source/running`;
|
|
804
943
|
11. `reload()` 返回递增的 generation;
|
|
805
|
-
12. 显式路径不存在 → `ProxyStartError.code == "config_not_found"
|
|
944
|
+
12. 显式路径不存在 → `ProxyStartError.code == "config_not_found"`;
|
|
945
|
+
13. `start_proxy(background=True)` 返回 pid/config_path/runtime_dir;
|
|
946
|
+
14. `get_status()` 能跨进程读到同一个 pid;
|
|
947
|
+
15. `is_running()` 为 True,`stop_proxy()` 写 control.json 后变为 False。
|
|
806
948
|
|
|
807
949
|
参考实现:
|
|
808
950
|
|
|
@@ -833,6 +975,10 @@ python examples/verify_sdk.py
|
|
|
833
975
|
| `profile_option_unsupported` | `handle_services` / `handle_other_users=true` |
|
|
834
976
|
| `E_BACKEND_UNAVAILABLE` | 未知后端、非管理员、驱动不可加载 |
|
|
835
977
|
| `config_path_missing` | 对内存配置调用无参 `reload()` |
|
|
978
|
+
| `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
|
|
979
|
+
| `background_start_failed` | 后台子进程在写出 state.json 前退出 |
|
|
980
|
+
| `background_start_timeout` | 后台子进程未在超时内写出 state.json |
|
|
981
|
+
| `runtime_dir_unavailable` | 后台 runtime_dir 无法创建/写入 |
|
|
836
982
|
|
|
837
983
|
`config_unknown_field` 只出现在 `Config.warnings`,不阻止加载。
|
|
838
984
|
|
|
@@ -25,9 +25,12 @@ os.environ.pop("PROCPROXY_CONFIG", None)
|
|
|
25
25
|
from proxycore import __version__ # noqa: E402
|
|
26
26
|
from proxycore.api import ( # noqa: E402
|
|
27
27
|
ProxyStartError,
|
|
28
|
+
get_status,
|
|
28
29
|
init_config,
|
|
30
|
+
is_running,
|
|
29
31
|
make_runtime,
|
|
30
32
|
start_proxy,
|
|
33
|
+
stop_proxy,
|
|
31
34
|
)
|
|
32
35
|
from proxycore.config import Config, atomic_write # noqa: E402
|
|
33
36
|
from proxycore.types import ConnRequest, Endpoint, ProcInfo, Proto # noqa: E402
|
|
@@ -143,6 +146,25 @@ def main() -> int:
|
|
|
143
146
|
else:
|
|
144
147
|
raise AssertionError("缺失配置未被拒绝")
|
|
145
148
|
|
|
149
|
+
# 7. 后台常驻:独立进程 + state.json / control.json
|
|
150
|
+
bg_config = write_config(work / "background.yaml", port=19135)
|
|
151
|
+
bg = start_proxy(
|
|
152
|
+
bg_config,
|
|
153
|
+
background=True,
|
|
154
|
+
runtime_dir=work / "background-rt",
|
|
155
|
+
wait_timeout_s=20.0,
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
check("background pid", bg.pid > 0, str(bg.pid))
|
|
159
|
+
check("background config path", bg.config_path == str(bg_config.resolve()))
|
|
160
|
+
state = get_status(bg.runtime_dir)
|
|
161
|
+
check("background state pid", bool(state) and state.get("pid") == bg.pid)
|
|
162
|
+
check("background is_running", is_running(bg.runtime_dir))
|
|
163
|
+
finally:
|
|
164
|
+
result = stop_proxy(bg.runtime_dir, timeout_s=20.0)
|
|
165
|
+
check("background stopped", result.stopped, result.reason)
|
|
166
|
+
check("background not running", not is_running(bg.runtime_dir))
|
|
167
|
+
|
|
146
168
|
print(f"\n全部通过:{len(PASSED)} 项检查")
|
|
147
169
|
for name in PASSED:
|
|
148
170
|
print(f" [OK] {name}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: procproxy
|
|
3
|
-
Version: 0.1.4.
|
|
3
|
+
Version: 0.1.4.2
|
|
4
4
|
Summary: ProcProxy —— 进程级代理:按进程决定哪条连接走哪个上游代理
|
|
5
5
|
Author: Longersking
|
|
6
6
|
License-Expression: MIT
|
|
@@ -507,6 +507,24 @@ finally:
|
|
|
507
507
|
`start_proxy()` 会启动后台维护线程;`run_forever()` 会阻塞到 Ctrl+C。
|
|
508
508
|
请在主线程使用,并在退出时 `stop()`(或使用 `with`)。
|
|
509
509
|
|
|
510
|
+
### 后台常驻(父进程可退出)
|
|
511
|
+
|
|
512
|
+
```python
|
|
513
|
+
from proxycore.api import get_status, start_proxy, stop_proxy
|
|
514
|
+
|
|
515
|
+
bg = start_proxy("D:/cfg/chrome.yaml", background=True)
|
|
516
|
+
print(bg.pid, bg.config_path, bg.runtime_dir)
|
|
517
|
+
# 父进程可以退出;独立子进程继续运行
|
|
518
|
+
|
|
519
|
+
print(get_status(bg.runtime_dir)) # 跨进程读 state.json
|
|
520
|
+
print(stop_proxy(bg.runtime_dir)) # 写 control.json 并等待退出
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- 状态目录默认 `~/.procproxy-app`(可用 `runtime_dir=` 或 `PROCPROXY_RUNTIME_DIR` 覆盖)
|
|
524
|
+
- 文件协议:`state.json`(数据面写)、`control.json`(应用写 `stop`/`panic`)
|
|
525
|
+
- `start_proxy(background=True, replace=True)` 可替换同目录下已在跑的数据面
|
|
526
|
+
- 子进程继承父进程权限;`backend="windivert"` 仍需管理员启动
|
|
527
|
+
|
|
510
528
|
### 错误处理
|
|
511
529
|
|
|
512
530
|
配置 / 后端 / 启动失败统一抛 `ProxyStartError`,`code` 是稳定的机器可读标识:
|
|
@@ -528,6 +546,9 @@ except ProxyStartError as exc:
|
|
|
528
546
|
| `config_version_unsupported` | `version != 1` |
|
|
529
547
|
| `rule_upstream_missing` 等 | 规则或上游校验失败(完整列表见文档) |
|
|
530
548
|
| `E_BACKEND_UNAVAILABLE` | 后端不可用(未知后端、WinDivert 不可加载等) |
|
|
549
|
+
| `already_running` | 后台模式同一 runtime_dir 已有存活数据面 |
|
|
550
|
+
| `background_start_failed` / `background_start_timeout` | 后台子进程启动失败 / 超时 |
|
|
551
|
+
| `runtime_dir_unavailable` | 后台状态目录无法创建或写入 |
|
|
531
552
|
|
|
532
553
|
`api.load_config()` 同样抛 `ProxyStartError`,不是 `ConfigError`。
|
|
533
554
|
|
|
@@ -590,7 +611,7 @@ src/proxycore/ 核心包
|
|
|
590
611
|
samples/ mock 服务 + 示例配置
|
|
591
612
|
examples/ 纯 Python 示例(proxy_process.py / verify_sdk.py)
|
|
592
613
|
docs/ 文档(00 架构 / 07 规则 / 11 排错 / 14 抓包 / 15 SDK)
|
|
593
|
-
tests/ 测试套件(
|
|
614
|
+
tests/ 测试套件(633 passed / 4 skipped)
|
|
594
615
|
└── tools/ 验证脚本(run_demo / run_e2e / bench / …)
|
|
595
616
|
```
|
|
596
617
|
|
|
@@ -621,7 +642,7 @@ tests/ 测试套件(625 passed / 4 skipped)
|
|
|
621
642
|
|
|
622
643
|
```bash
|
|
623
644
|
pip install -e ".[dev]"
|
|
624
|
-
python -m pytest -q tests/ #
|
|
645
|
+
python -m pytest -q tests/ # 633 passed / 4 skipped(非管理员 Windows)
|
|
625
646
|
python -m ruff check src tests
|
|
626
647
|
python -m mypy src/proxycore
|
|
627
648
|
```
|
|
@@ -18,12 +18,14 @@ src/procproxy.egg-info/top_level.txt
|
|
|
18
18
|
src/proxycore/__init__.py
|
|
19
19
|
src/proxycore/__main__.py
|
|
20
20
|
src/proxycore/api.py
|
|
21
|
+
src/proxycore/background.py
|
|
21
22
|
src/proxycore/canproxy.py
|
|
22
23
|
src/proxycore/checkroute.py
|
|
23
24
|
src/proxycore/cli.py
|
|
24
25
|
src/proxycore/config.py
|
|
25
26
|
src/proxycore/config_init.py
|
|
26
27
|
src/proxycore/config_source.py
|
|
28
|
+
src/proxycore/errors.py
|
|
27
29
|
src/proxycore/forward.py
|
|
28
30
|
src/proxycore/ipc.py
|
|
29
31
|
src/proxycore/log.py
|
|
@@ -49,9 +49,19 @@ from dataclasses import dataclass
|
|
|
49
49
|
from pathlib import Path
|
|
50
50
|
from typing import Any
|
|
51
51
|
|
|
52
|
+
from .background import (
|
|
53
|
+
BackgroundProxy,
|
|
54
|
+
StopResult,
|
|
55
|
+
get_status,
|
|
56
|
+
is_running,
|
|
57
|
+
resolve_runtime_dir,
|
|
58
|
+
start_proxy_background,
|
|
59
|
+
stop_proxy,
|
|
60
|
+
)
|
|
52
61
|
from .config import Config, ConfigError, ConfigManager
|
|
53
62
|
from .config_init import create_config_file
|
|
54
63
|
from .config_source import ORIGIN_EXPLICIT, ORIGIN_OBJECT, normalize_config_path, resolve_config_source
|
|
64
|
+
from .errors import ProxyStartError
|
|
55
65
|
from .log import EventLogger, build_logger, get_logger
|
|
56
66
|
from .runtime import Runtime
|
|
57
67
|
from .types import ConnRequest, Decision
|
|
@@ -59,18 +69,6 @@ from .types import ConnRequest, Decision
|
|
|
59
69
|
LOG = EventLogger(get_logger("proxycore.api"))
|
|
60
70
|
|
|
61
71
|
|
|
62
|
-
class ProxyStartError(RuntimeError):
|
|
63
|
-
"""启动失败(配置非法 / 后端不可用 / sink 端口占用)。
|
|
64
|
-
|
|
65
|
-
`code` 是稳定的机器可读标识:配置错误沿用 `ConfigError.code`
|
|
66
|
-
(如 `config_not_found`),后端不可用为 `E_BACKEND_UNAVAILABLE`。
|
|
67
|
-
"""
|
|
68
|
-
|
|
69
|
-
def __init__(self, message: str, *, code: str = "") -> None:
|
|
70
|
-
super().__init__(message)
|
|
71
|
-
self.code = code
|
|
72
|
-
|
|
73
|
-
|
|
74
72
|
def _wrap_config_error(exc: ConfigError) -> ProxyStartError:
|
|
75
73
|
return ProxyStartError(f"配置无效:[{exc.code}] {exc.message}", code=exc.code)
|
|
76
74
|
|
|
@@ -285,17 +283,45 @@ def start_proxy(
|
|
|
285
283
|
upstream: str = "",
|
|
286
284
|
log_level: str = "",
|
|
287
285
|
log_jsonl: str = "",
|
|
288
|
-
|
|
289
|
-
|
|
286
|
+
background: bool = False,
|
|
287
|
+
runtime_dir: str | os.PathLike[str] | None = None,
|
|
288
|
+
wait_timeout_s: float = 10.0,
|
|
289
|
+
replace: bool = False,
|
|
290
|
+
) -> ProxyHandle | BackgroundProxy:
|
|
291
|
+
"""启动 ProcProxy 数据面。
|
|
292
|
+
|
|
293
|
+
前台(默认,`background=False`):在当前进程里启动,返回 `ProxyHandle`;
|
|
294
|
+
进程退出即停止,适合 `with` / 嵌入程序 / 测试。
|
|
295
|
+
|
|
296
|
+
后台(`background=True`):拉起独立子进程并立即返回 `BackgroundProxy`
|
|
297
|
+
(含 `pid` / `config_path` / `runtime_dir` / `state_path` / `control_path`)。
|
|
298
|
+
父进程可以退出,数据面继续运行;用 `get_status()` 查询、`stop_proxy()` 停止。
|
|
299
|
+
子进程的 `PROCPROXY_RUNTIME_DIR` 固定为解析后的 runtime_dir
|
|
300
|
+
(默认 `~/.procproxy-app`),状态/控制文件跨进程一致。
|
|
301
|
+
|
|
302
|
+
其它参数:
|
|
290
303
|
|
|
291
304
|
* `config=None`:按来源优先级自动选择;pip 安装后默认使用内置配置
|
|
292
305
|
(`backend=null`,不抓包、不改道),且不会创建任何文件;
|
|
293
|
-
* 返回的 `ProxyHandle` 支持 `with`,退出会自动 stop;
|
|
294
306
|
* `backend=""` 用配置里的 backend;`backend="null"` 用于联调(不抓包);
|
|
295
307
|
* `sink_test_target` 非空 = 测试模式:sink 把所有连接转发到该固定目标;
|
|
296
308
|
注意 backend=null 且不传 `sink_test_target`/`upstream` 时**不会**监听 sink;
|
|
297
|
-
* 后端不可用(WinDivert 不可加载/非管理员)→ 抛 `ProxyStartError
|
|
309
|
+
* 后端不可用(WinDivert 不可加载/非管理员)→ 抛 `ProxyStartError`,不会静默直连;
|
|
310
|
+
* 后台模式的 `replace=True`:同一 runtime_dir 已有存活数据面时先停掉再启动,
|
|
311
|
+
否则抛 `already_running`。
|
|
298
312
|
"""
|
|
313
|
+
if background:
|
|
314
|
+
return start_proxy_background(
|
|
315
|
+
config,
|
|
316
|
+
backend=backend,
|
|
317
|
+
sink_test_target=sink_test_target,
|
|
318
|
+
upstream=upstream,
|
|
319
|
+
log_level=log_level,
|
|
320
|
+
log_jsonl=log_jsonl,
|
|
321
|
+
runtime_dir=runtime_dir,
|
|
322
|
+
wait_timeout_s=wait_timeout_s,
|
|
323
|
+
replace=replace,
|
|
324
|
+
)
|
|
299
325
|
runtime = make_runtime(
|
|
300
326
|
config,
|
|
301
327
|
backend=backend,
|
|
@@ -325,6 +351,11 @@ def start_proxy(
|
|
|
325
351
|
def run_forever(config: Config | str | os.PathLike[str] | None = None, **kwargs: Any) -> int:
|
|
326
352
|
"""启动并阻塞(等价于 `cli run`),Ctrl+C 退出。返回进程退出码。"""
|
|
327
353
|
handle = start_proxy(config, **kwargs)
|
|
354
|
+
if not isinstance(handle, ProxyHandle):
|
|
355
|
+
raise ProxyStartError(
|
|
356
|
+
"run_forever 不支持 background=True;后台模式请直接调用 start_proxy(background=True)",
|
|
357
|
+
code="invalid_argument",
|
|
358
|
+
)
|
|
328
359
|
try:
|
|
329
360
|
handle.runtime.serve_forever(reload_watch=True)
|
|
330
361
|
except KeyboardInterrupt:
|
|
@@ -335,11 +366,18 @@ def run_forever(config: Config | str | os.PathLike[str] | None = None, **kwargs:
|
|
|
335
366
|
|
|
336
367
|
|
|
337
368
|
__all__ = [
|
|
369
|
+
"BackgroundProxy",
|
|
338
370
|
"ProxyHandle",
|
|
339
371
|
"ProxyStartError",
|
|
372
|
+
"StopResult",
|
|
373
|
+
"get_status",
|
|
340
374
|
"init_config",
|
|
375
|
+
"is_running",
|
|
341
376
|
"load_config",
|
|
342
377
|
"make_runtime",
|
|
378
|
+
"resolve_runtime_dir",
|
|
343
379
|
"run_forever",
|
|
344
380
|
"start_proxy",
|
|
381
|
+
"start_proxy_background",
|
|
382
|
+
"stop_proxy",
|
|
345
383
|
]
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""后台常驻数据面:独立进程 + state.json / control.json 文件协议。
|
|
2
|
+
|
|
3
|
+
与前台 `start_proxy()` 的区别:
|
|
4
|
+
|
|
5
|
+
* 前台:在当前进程里启动后端/线程,返回 `ProxyHandle`,进程退出即停止;
|
|
6
|
+
* 后台:用当前 Python 解释器拉起一个**脱离父进程**的
|
|
7
|
+
`python -m proxycore run` 子进程,等它写出 `state.json` 后返回
|
|
8
|
+
`BackgroundProxy`。父进程可以直接退出,数据面继续跑。
|
|
9
|
+
|
|
10
|
+
跨进程协议(与 CLI `stop` 完全一致):
|
|
11
|
+
|
|
12
|
+
* `<runtime_dir>/state.json`:数据面周期性写出的状态(含 `pid`);
|
|
13
|
+
* `<runtime_dir>/control.json`:外部写入 `{"action": "stop"|"panic"}`,
|
|
14
|
+
数据面在 1 秒内读取并删除,然后自行退出;
|
|
15
|
+
* `<runtime_dir>/sessions.jsonl`:会话日志(CLI 默认);
|
|
16
|
+
* `<runtime_dir>/daemon.log`:子进程 stdout/stderr。
|
|
17
|
+
|
|
18
|
+
`runtime_dir` 优先级:显式参数 → `PROCPROXY_RUNTIME_DIR` → `~/.procproxy-app`。
|
|
19
|
+
子进程启动时会把解析后的绝对路径写入 `PROCPROXY_RUNTIME_DIR`,保证父子
|
|
20
|
+
使用同一组状态文件。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .config import Config, ConfigError, atomic_write
|
|
36
|
+
from .config_source import resolve_config_source
|
|
37
|
+
from .errors import ProxyStartError
|
|
38
|
+
|
|
39
|
+
RUNTIME_DIR_ENV = "PROCPROXY_RUNTIME_DIR"
|
|
40
|
+
APP_RUNTIME_DIR = ".procproxy-app"
|
|
41
|
+
STATE_FILE = "state.json"
|
|
42
|
+
CONTROL_FILE = "control.json"
|
|
43
|
+
LOG_FILE = "daemon.log"
|
|
44
|
+
DEFAULT_WAIT_TIMEOUT_S = 10.0
|
|
45
|
+
DEFAULT_STOP_TIMEOUT_S = 10.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class BackgroundProxy:
|
|
50
|
+
"""后台数据面的启动凭证。父进程退出后这些信息仍然有效。"""
|
|
51
|
+
|
|
52
|
+
pid: int
|
|
53
|
+
config_path: str
|
|
54
|
+
runtime_dir: str
|
|
55
|
+
state_path: str
|
|
56
|
+
control_path: str
|
|
57
|
+
log_path: str
|
|
58
|
+
backend: str
|
|
59
|
+
started_at: float
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return {
|
|
63
|
+
"pid": self.pid,
|
|
64
|
+
"config_path": self.config_path,
|
|
65
|
+
"runtime_dir": self.runtime_dir,
|
|
66
|
+
"state_path": self.state_path,
|
|
67
|
+
"control_path": self.control_path,
|
|
68
|
+
"log_path": self.log_path,
|
|
69
|
+
"backend": self.backend,
|
|
70
|
+
"started_at": self.started_at,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class StopResult:
|
|
76
|
+
"""一次停止请求的结果;`reason`: stopped / not_running / timeout。"""
|
|
77
|
+
|
|
78
|
+
stopped: bool
|
|
79
|
+
pid: int
|
|
80
|
+
runtime_dir: str
|
|
81
|
+
control_path: str
|
|
82
|
+
reason: str
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"stopped": self.stopped,
|
|
87
|
+
"pid": self.pid,
|
|
88
|
+
"runtime_dir": self.runtime_dir,
|
|
89
|
+
"control_path": self.control_path,
|
|
90
|
+
"reason": self.reason,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------ 路径解析 ----
|
|
95
|
+
def resolve_runtime_dir(runtime_dir: str | os.PathLike[str] | None = None) -> Path:
|
|
96
|
+
"""显式参数 → `PROCPROXY_RUNTIME_DIR` → `~/.procproxy-app`(绝对路径)。"""
|
|
97
|
+
raw = runtime_dir if runtime_dir is not None else os.environ.get(RUNTIME_DIR_ENV, "")
|
|
98
|
+
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
99
|
+
path = Path.home() / APP_RUNTIME_DIR
|
|
100
|
+
else:
|
|
101
|
+
path = Path(os.path.expandvars(os.fspath(raw))).expanduser()
|
|
102
|
+
if not path.is_absolute():
|
|
103
|
+
path = (Path.cwd() / path).resolve()
|
|
104
|
+
return path.resolve()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def state_path(runtime_dir: str | os.PathLike[str] | None = None) -> Path:
|
|
108
|
+
return resolve_runtime_dir(runtime_dir) / STATE_FILE
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def control_path(runtime_dir: str | os.PathLike[str] | None = None) -> Path:
|
|
112
|
+
return resolve_runtime_dir(runtime_dir) / CONTROL_FILE
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def log_path(runtime_dir: str | os.PathLike[str] | None = None) -> Path:
|
|
116
|
+
return resolve_runtime_dir(runtime_dir) / LOG_FILE
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------ 状态读取 ----
|
|
120
|
+
def get_status(runtime_dir: str | os.PathLike[str] | None = None) -> dict[str, Any] | None:
|
|
121
|
+
"""读取 `state.json` 原始内容;文件不存在或非法返回 `None`。
|
|
122
|
+
|
|
123
|
+
这是跨进程查询的唯一入口:不连接子进程、不需要权限,只读文件。
|
|
124
|
+
"""
|
|
125
|
+
path = state_path(runtime_dir)
|
|
126
|
+
try:
|
|
127
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
128
|
+
except (OSError, json.JSONDecodeError):
|
|
129
|
+
return None
|
|
130
|
+
return data if isinstance(data, dict) else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_running(runtime_dir: str | os.PathLike[str] | None = None) -> bool:
|
|
134
|
+
"""`state.json` 里的 pid 是否仍然存活。"""
|
|
135
|
+
state = get_status(runtime_dir)
|
|
136
|
+
if not state:
|
|
137
|
+
return False
|
|
138
|
+
try:
|
|
139
|
+
pid = int(state.get("pid") or 0)
|
|
140
|
+
except (TypeError, ValueError):
|
|
141
|
+
return False
|
|
142
|
+
return _pid_alive(pid)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------ 启动/停止 ----
|
|
146
|
+
def start_proxy_background(
|
|
147
|
+
config: Config | str | os.PathLike[str] | None = None,
|
|
148
|
+
*,
|
|
149
|
+
backend: str = "",
|
|
150
|
+
sink_test_target: str = "",
|
|
151
|
+
upstream: str = "",
|
|
152
|
+
log_level: str = "",
|
|
153
|
+
log_jsonl: str = "",
|
|
154
|
+
runtime_dir: str | os.PathLike[str] | None = None,
|
|
155
|
+
wait_timeout_s: float = DEFAULT_WAIT_TIMEOUT_S,
|
|
156
|
+
replace: bool = False,
|
|
157
|
+
) -> BackgroundProxy:
|
|
158
|
+
"""拉起独立后台数据面并立即返回;父进程退出后它继续运行。
|
|
159
|
+
|
|
160
|
+
参数与 `start_proxy()` 同名参数含义一致。额外参数:
|
|
161
|
+
|
|
162
|
+
* `runtime_dir`:状态/控制文件目录;默认 `~/.procproxy-app`;
|
|
163
|
+
* `wait_timeout_s`:等待子进程写出 `state.json` 的最长时间;
|
|
164
|
+
* `replace`:发现同一 runtime_dir 已有存活数据面时,先停掉它再启动;
|
|
165
|
+
否则抛 `ProxyStartError(code="already_running")`。
|
|
166
|
+
|
|
167
|
+
`config` 传 `Config` 对象时会落到 `<runtime_dir>/config.yaml` 再由子进程读取;
|
|
168
|
+
传路径时直接使用该绝对路径。
|
|
169
|
+
"""
|
|
170
|
+
root = resolve_runtime_dir(runtime_dir)
|
|
171
|
+
try:
|
|
172
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise ProxyStartError(
|
|
175
|
+
f"无法创建 runtime_dir {root}: {exc}", code="runtime_dir_unavailable"
|
|
176
|
+
) from exc
|
|
177
|
+
|
|
178
|
+
_handle_existing(root, replace=replace, stop_timeout_s=wait_timeout_s)
|
|
179
|
+
|
|
180
|
+
cfg_path = _materialize_config(config, root)
|
|
181
|
+
cmd = [sys.executable, "-m", "proxycore", "run", "--config", str(cfg_path)]
|
|
182
|
+
if backend:
|
|
183
|
+
cmd += ["--backend", backend]
|
|
184
|
+
if sink_test_target:
|
|
185
|
+
cmd += ["--sink-test-target", sink_test_target]
|
|
186
|
+
if upstream:
|
|
187
|
+
cmd += ["--upstream", upstream]
|
|
188
|
+
if log_level:
|
|
189
|
+
cmd += ["--log-level", log_level]
|
|
190
|
+
if log_jsonl:
|
|
191
|
+
cmd += ["--log-jsonl", log_jsonl]
|
|
192
|
+
|
|
193
|
+
env = dict(os.environ)
|
|
194
|
+
env[RUNTIME_DIR_ENV] = str(root)
|
|
195
|
+
env["PYTHONIOENCODING"] = "utf-8"
|
|
196
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
197
|
+
# 保证子进程加载与父进程同一份 proxycore(源码树/PYTHONPATH 场景尤其需要)
|
|
198
|
+
package_root = Path(__file__).resolve().parent.parent
|
|
199
|
+
old_pythonpath = env.get("PYTHONPATH", "")
|
|
200
|
+
env["PYTHONPATH"] = str(package_root) + (os.pathsep + old_pythonpath if old_pythonpath else "")
|
|
201
|
+
|
|
202
|
+
log_file = root / LOG_FILE
|
|
203
|
+
with open(log_file, "ab", buffering=0) as log:
|
|
204
|
+
kwargs: dict[str, Any] = {
|
|
205
|
+
"stdin": subprocess.DEVNULL,
|
|
206
|
+
"stdout": log,
|
|
207
|
+
"stderr": subprocess.STDOUT,
|
|
208
|
+
"env": env,
|
|
209
|
+
"close_fds": True,
|
|
210
|
+
}
|
|
211
|
+
if os.name == "nt":
|
|
212
|
+
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
213
|
+
else:
|
|
214
|
+
kwargs["start_new_session"] = True
|
|
215
|
+
proc = subprocess.Popen(cmd, **kwargs)
|
|
216
|
+
|
|
217
|
+
# 注意:Windows venv 的 python.exe 可能是一个启动器,会再拉起真实解释器,
|
|
218
|
+
# 因此 Popen.pid 不一定等于 state.json 里的数据面 pid。就绪判断以 state.json
|
|
219
|
+
# 为准;`proc.poll()` 只用来尽早发现"子进程根本没跑起来"。
|
|
220
|
+
spawn_time = time.time()
|
|
221
|
+
deadline = time.monotonic() + max(0.1, float(wait_timeout_s))
|
|
222
|
+
while time.monotonic() < deadline:
|
|
223
|
+
ready = _background_ready(root, spawn_time, cfg_path)
|
|
224
|
+
if ready is not None:
|
|
225
|
+
return ready
|
|
226
|
+
returncode = proc.poll()
|
|
227
|
+
if returncode is not None:
|
|
228
|
+
# 启动器可能已退出、真实数据面仍在写 state;给它一个短暂的宽限。
|
|
229
|
+
time.sleep(0.3)
|
|
230
|
+
ready = _background_ready(root, spawn_time, cfg_path)
|
|
231
|
+
if ready is not None:
|
|
232
|
+
return ready
|
|
233
|
+
tail = _tail(log_file)
|
|
234
|
+
raise ProxyStartError(
|
|
235
|
+
f"后台数据面启动失败(退出码 {returncode})\n{tail}",
|
|
236
|
+
code="background_start_failed",
|
|
237
|
+
)
|
|
238
|
+
time.sleep(0.05)
|
|
239
|
+
|
|
240
|
+
state = get_status(root)
|
|
241
|
+
state_pid = _state_pid(state)
|
|
242
|
+
_terminate(proc, state_pid)
|
|
243
|
+
raise ProxyStartError(
|
|
244
|
+
f"后台数据面在 {wait_timeout_s:.1f}s 内未写出有效 state.json;"
|
|
245
|
+
f"日志见 {log_file}",
|
|
246
|
+
code="background_start_timeout",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def stop_proxy(
|
|
251
|
+
runtime_dir: str | os.PathLike[str] | None = None,
|
|
252
|
+
*,
|
|
253
|
+
timeout_s: float = DEFAULT_STOP_TIMEOUT_S,
|
|
254
|
+
) -> StopResult:
|
|
255
|
+
"""写入 control.json 请求停止,并等待子进程退出。
|
|
256
|
+
|
|
257
|
+
不强制杀进程:超时返回 `StopResult(stopped=False, reason="timeout")`,
|
|
258
|
+
调用方可以决定是否升级处理。control.json 与 CLI `procproxy stop` 同格式。
|
|
259
|
+
"""
|
|
260
|
+
root = resolve_runtime_dir(runtime_dir)
|
|
261
|
+
ctrl = root / CONTROL_FILE
|
|
262
|
+
state = get_status(root)
|
|
263
|
+
if state is None:
|
|
264
|
+
return StopResult(False, 0, str(root), str(ctrl), "not_running")
|
|
265
|
+
pid = _state_pid(state)
|
|
266
|
+
if pid <= 0 or not _pid_alive(pid):
|
|
267
|
+
return StopResult(False, pid, str(root), str(ctrl), "not_running")
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
_write_json_atomic(ctrl, {"action": "stop", "ts": time.time()})
|
|
271
|
+
except OSError as exc:
|
|
272
|
+
raise ProxyStartError(
|
|
273
|
+
f"无法写入控制文件 {ctrl}: {exc}", code="runtime_dir_unavailable"
|
|
274
|
+
) from exc
|
|
275
|
+
|
|
276
|
+
deadline = time.monotonic() + max(0.1, float(timeout_s))
|
|
277
|
+
while time.monotonic() < deadline:
|
|
278
|
+
if not _pid_alive(pid):
|
|
279
|
+
return StopResult(True, pid, str(root), str(ctrl), "stopped")
|
|
280
|
+
time.sleep(0.1)
|
|
281
|
+
return StopResult(False, pid, str(root), str(ctrl), "timeout")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------ 内部工具 ----
|
|
285
|
+
def _state_pid(state: dict[str, Any] | None) -> int:
|
|
286
|
+
if not state:
|
|
287
|
+
return 0
|
|
288
|
+
try:
|
|
289
|
+
return int(state.get("pid") or 0)
|
|
290
|
+
except (TypeError, ValueError):
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _background_ready(
|
|
295
|
+
root: Path, spawn_time: float, config_path: Path
|
|
296
|
+
) -> BackgroundProxy | None:
|
|
297
|
+
"""state.json 是否已由本次启动的数据面写出。
|
|
298
|
+
|
|
299
|
+
不能用 `Popen.pid` 比对:Windows venv 的 python.exe 可能是启动器,
|
|
300
|
+
真正跑数据面的是它的子进程。用 pid 存活 + started_at 判断。
|
|
301
|
+
"""
|
|
302
|
+
state = get_status(root)
|
|
303
|
+
pid = _state_pid(state)
|
|
304
|
+
if state is None or pid <= 0 or not _pid_alive(pid):
|
|
305
|
+
return None
|
|
306
|
+
try:
|
|
307
|
+
started_at = float(state.get("started_at") or 0.0)
|
|
308
|
+
except (TypeError, ValueError):
|
|
309
|
+
started_at = 0.0
|
|
310
|
+
# 正常情况下 _handle_existing 已清掉残留;这个判断只防极小概率的旧文件。
|
|
311
|
+
if started_at and started_at < spawn_time - 5.0:
|
|
312
|
+
return None
|
|
313
|
+
return BackgroundProxy(
|
|
314
|
+
pid=pid,
|
|
315
|
+
config_path=str(state.get("config") or config_path),
|
|
316
|
+
runtime_dir=str(root),
|
|
317
|
+
state_path=str(root / STATE_FILE),
|
|
318
|
+
control_path=str(root / CONTROL_FILE),
|
|
319
|
+
log_path=str(root / LOG_FILE),
|
|
320
|
+
backend=str(state.get("backend") or ""),
|
|
321
|
+
started_at=started_at or time.time(),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _handle_existing(root: Path, *, replace: bool, stop_timeout_s: float) -> None:
|
|
326
|
+
state = get_status(root)
|
|
327
|
+
if state is None:
|
|
328
|
+
return
|
|
329
|
+
pid = _state_pid(state)
|
|
330
|
+
if pid <= 0 or not _pid_alive(pid):
|
|
331
|
+
# 上次异常退出的残留:清掉状态与控制文件,避免新进程被旧指令影响
|
|
332
|
+
for path in (root / STATE_FILE, root / CONTROL_FILE):
|
|
333
|
+
with contextlib.suppress(OSError):
|
|
334
|
+
path.unlink()
|
|
335
|
+
return
|
|
336
|
+
if not replace:
|
|
337
|
+
raise ProxyStartError(
|
|
338
|
+
f"后台数据面已在运行(pid={pid}, runtime_dir={root});"
|
|
339
|
+
"如需替换请传 replace=True",
|
|
340
|
+
code="already_running",
|
|
341
|
+
)
|
|
342
|
+
result = stop_proxy(root, timeout_s=stop_timeout_s)
|
|
343
|
+
if not result.stopped:
|
|
344
|
+
raise ProxyStartError(
|
|
345
|
+
f"替换旧数据面失败(pid={pid}, reason={result.reason})",
|
|
346
|
+
code="stop_timeout" if result.reason == "timeout" else "already_running",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _materialize_config(config: Config | str | os.PathLike[str] | None, root: Path) -> Path:
|
|
351
|
+
"""把 Config 对象写成 runtime_dir/config.yaml;路径则直接校验后使用。"""
|
|
352
|
+
try:
|
|
353
|
+
if isinstance(config, Config):
|
|
354
|
+
config.validate()
|
|
355
|
+
target = root / "config.yaml"
|
|
356
|
+
atomic_write(config, target)
|
|
357
|
+
return target
|
|
358
|
+
source = resolve_config_source(config)
|
|
359
|
+
Config.load(source.path) # 尽早失败:子进程启动前就暴露配置错误
|
|
360
|
+
return source.path
|
|
361
|
+
except ConfigError as exc:
|
|
362
|
+
raise ProxyStartError(
|
|
363
|
+
f"配置无效:[{exc.code}] {exc.message}", code=exc.code
|
|
364
|
+
) from exc
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
|
|
368
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
370
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
371
|
+
os.replace(tmp, path)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _pid_alive(pid: int) -> bool:
|
|
375
|
+
if pid <= 0:
|
|
376
|
+
return False
|
|
377
|
+
if os.name == "nt":
|
|
378
|
+
import ctypes
|
|
379
|
+
|
|
380
|
+
process_query_limited_information = 0x1000
|
|
381
|
+
kernel32 = ctypes.windll.kernel32
|
|
382
|
+
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
|
|
383
|
+
if not handle:
|
|
384
|
+
return False
|
|
385
|
+
try:
|
|
386
|
+
exit_code = ctypes.c_ulong()
|
|
387
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
388
|
+
return False
|
|
389
|
+
return exit_code.value == 259 # STILL_ACTIVE
|
|
390
|
+
finally:
|
|
391
|
+
kernel32.CloseHandle(handle)
|
|
392
|
+
try:
|
|
393
|
+
os.kill(pid, 0)
|
|
394
|
+
return True
|
|
395
|
+
except ProcessLookupError:
|
|
396
|
+
return False
|
|
397
|
+
except PermissionError:
|
|
398
|
+
return True
|
|
399
|
+
except OSError:
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _tail(path: Path, lines: int = 20) -> str:
|
|
404
|
+
try:
|
|
405
|
+
content = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
406
|
+
except OSError:
|
|
407
|
+
return ""
|
|
408
|
+
return "\n".join(content[-lines:])
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _kill_pid(pid: int) -> None:
|
|
412
|
+
if pid <= 0 or not _pid_alive(pid):
|
|
413
|
+
return
|
|
414
|
+
if os.name == "nt":
|
|
415
|
+
import ctypes
|
|
416
|
+
|
|
417
|
+
process_terminate = 0x0001
|
|
418
|
+
kernel32 = ctypes.windll.kernel32
|
|
419
|
+
handle = kernel32.OpenProcess(process_terminate, False, pid)
|
|
420
|
+
if handle:
|
|
421
|
+
try:
|
|
422
|
+
kernel32.TerminateProcess(handle, 1)
|
|
423
|
+
finally:
|
|
424
|
+
kernel32.CloseHandle(handle)
|
|
425
|
+
return
|
|
426
|
+
with contextlib.suppress(OSError):
|
|
427
|
+
os.kill(pid, 15)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _terminate(proc: subprocess.Popen[Any], state_pid: int = 0) -> None:
|
|
431
|
+
# 先杀 state.json 里的真实数据面(venv 启动器场景 Popen.pid 可能是另一个进程)
|
|
432
|
+
_kill_pid(state_pid)
|
|
433
|
+
with contextlib.suppress(OSError):
|
|
434
|
+
proc.terminate()
|
|
435
|
+
try:
|
|
436
|
+
proc.wait(timeout=2.0)
|
|
437
|
+
return
|
|
438
|
+
except subprocess.TimeoutExpired:
|
|
439
|
+
pass
|
|
440
|
+
with contextlib.suppress(OSError):
|
|
441
|
+
proc.kill()
|
|
442
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
443
|
+
proc.wait(timeout=2.0)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
__all__ = [
|
|
447
|
+
"APP_RUNTIME_DIR",
|
|
448
|
+
"DEFAULT_STOP_TIMEOUT_S",
|
|
449
|
+
"DEFAULT_WAIT_TIMEOUT_S",
|
|
450
|
+
"RUNTIME_DIR_ENV",
|
|
451
|
+
"BackgroundProxy",
|
|
452
|
+
"StopResult",
|
|
453
|
+
"control_path",
|
|
454
|
+
"get_status",
|
|
455
|
+
"is_running",
|
|
456
|
+
"log_path",
|
|
457
|
+
"resolve_runtime_dir",
|
|
458
|
+
"start_proxy_background",
|
|
459
|
+
"state_path",
|
|
460
|
+
"stop_proxy",
|
|
461
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""公共异常类型。
|
|
2
|
+
|
|
3
|
+
`ProxyStartError` 独立成模块,避免 `api` 与 `background` 互相导入。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProxyStartError(RuntimeError):
|
|
10
|
+
"""启动/停止失败。
|
|
11
|
+
|
|
12
|
+
`code` 是稳定的机器可读标识:配置错误沿用 `ConfigError.code`
|
|
13
|
+
(如 `config_not_found`),后端不可用为 `E_BACKEND_UNAVAILABLE`,
|
|
14
|
+
后台模式另有 `background_start_failed` / `background_start_timeout` /
|
|
15
|
+
`already_running` / `runtime_dir_unavailable`。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, *, code: str = "") -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.code = code
|
|
@@ -130,6 +130,8 @@ class Runtime:
|
|
|
130
130
|
|
|
131
131
|
self._maintenance = threading.Thread(target=self._maintenance_loop, name="ppx-maintenance", daemon=True)
|
|
132
132
|
self._maintenance.start()
|
|
133
|
+
# 后台启动器靠 state.json 判断“已就绪”,不能等第一次周期写(最长 5s)。
|
|
134
|
+
self._write_state(reason="started")
|
|
133
135
|
self.log.info(
|
|
134
136
|
"runtime_started",
|
|
135
137
|
backend=self.backend.name,
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|