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.
Files changed (63) hide show
  1. {procproxy-0.1.4.1/src/procproxy.egg-info → procproxy-0.1.4.2}/PKG-INFO +24 -3
  2. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/README.md +23 -2
  3. {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
  4. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/examples/verify_sdk.py +22 -0
  5. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/pyproject.toml +1 -1
  6. {procproxy-0.1.4.1 → procproxy-0.1.4.2/src/procproxy.egg-info}/PKG-INFO +24 -3
  7. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/SOURCES.txt +2 -0
  8. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/__init__.py +1 -1
  9. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/api.py +54 -16
  10. procproxy-0.1.4.2/src/proxycore/background.py +461 -0
  11. procproxy-0.1.4.2/src/proxycore/errors.py +20 -0
  12. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/runtime.py +2 -0
  13. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/LICENSE +0 -0
  14. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/MANIFEST.in +0 -0
  15. {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
  16. {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
  17. {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
  18. {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
  19. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/examples/proxy_process.py +0 -0
  20. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/setup.cfg +0 -0
  21. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/dependency_links.txt +0 -0
  22. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/entry_points.txt +0 -0
  23. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/requires.txt +0 -0
  24. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/procproxy.egg-info/top_level.txt +0 -0
  25. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/__main__.py +0 -0
  26. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/__init__.py +0 -0
  27. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/base.py +0 -0
  28. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/null.py +0 -0
  29. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/__init__.py +0 -0
  30. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/filter.py +0 -0
  31. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/pidmap.py +0 -0
  32. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/backend/windivert/pkt.py +0 -0
  33. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/canproxy.py +0 -0
  34. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/checkroute.py +0 -0
  35. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/cli.py +0 -0
  36. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/config.py +0 -0
  37. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/config_init.py +0 -0
  38. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/config_source.py +0 -0
  39. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/data/default.yaml +0 -0
  40. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/dns/__init__.py +0 -0
  41. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/dns/cache.py +0 -0
  42. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/dns/modes.py +0 -0
  43. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/dns/udp.py +0 -0
  44. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/forward.py +0 -0
  45. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/ipc.py +0 -0
  46. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/log.py +0 -0
  47. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/pathsetup.py +0 -0
  48. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/probe.py +0 -0
  49. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/proc.py +0 -0
  50. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/rules.py +0 -0
  51. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/service/__init__.py +0 -0
  52. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/service/__main__.py +0 -0
  53. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/tunnel/__init__.py +0 -0
  54. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/tunnel/relay.py +0 -0
  55. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/tunnel/sessions.py +0 -0
  56. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/tunnel/sink.py +0 -0
  57. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/types.py +0 -0
  58. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/__init__.py +0 -0
  59. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/base.py +0 -0
  60. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/chain.py +0 -0
  61. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/http_connect.py +0 -0
  62. {procproxy-0.1.4.1 → procproxy-0.1.4.2}/src/proxycore/upstream/socks4.py +0 -0
  63. {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.1
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/ 测试套件(625 passed / 4 skipped)
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/ # 625 passed / 4 skipped(非管理员 Windows)
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/ 测试套件(625 passed / 4 skipped)
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/ # 625 passed / 4 skipped(非管理员 Windows)
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.1`;§10 的验收脚本
9
- `examples/verify_sdk.py`(25 项断言)可以直接用来检查一份实现是否与本文一致。
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
- 是一个 25 项断言的可执行契约:
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}")
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "procproxy"
7
- version = "0.1.4.1"
7
+ version = "0.1.4.2"
8
8
  description = "ProcProxy —— 进程级代理:按进程决定哪条连接走哪个上游代理"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: procproxy
3
- Version: 0.1.4.1
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/ 测试套件(625 passed / 4 skipped)
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/ # 625 passed / 4 skipped(非管理员 Windows)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.4.1"
5
+ __version__ = "0.1.4.2"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -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
- ) -> ProxyHandle:
289
- """启动 ProcProxy 数据面(后端 + 规则 + 会话表,必要时监听 sink)。
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