remote-cmd-manager 2.5.0__tar.gz → 2.7.0__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.
- {remote_cmd_manager-2.5.0/remote_cmd_manager.egg-info → remote_cmd_manager-2.7.0}/PKG-INFO +20 -19
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/README.md +19 -18
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/pyproject.toml +2 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/__init__.py +16 -10
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/_version.py +1 -1
- remote_cmd_manager-2.7.0/remote_cmd/api/__init__.py +22 -0
- {remote_cmd_manager-2.5.0/remote_cmd/core → remote_cmd_manager-2.7.0/remote_cmd/api}/host_manager.py +8 -2
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/async_connection_pool.py +26 -5
- remote_cmd_manager-2.7.0/remote_cmd/core/budget.py +283 -0
- remote_cmd_manager-2.7.0/remote_cmd/core/host_manager.py +16 -0
- remote_cmd_manager-2.5.0/remote_cmd/service/_pool_policy.py → remote_cmd_manager-2.7.0/remote_cmd/core/pool_policy.py +5 -1
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/sync_connection_pool.py +25 -5
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/repository/sqlite_host_repository.py +40 -4
- remote_cmd_manager-2.7.0/remote_cmd/service/_pool_policy.py +16 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/async_batch_executor.py +38 -9
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/batch_executor.py +27 -7
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/credential_provider.py +26 -3
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/host_service.py +3 -3
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/retry_policy.py +1 -1
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/crypto.py +7 -2
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/exceptions.py +21 -3
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0/remote_cmd_manager.egg-info}/PKG-INFO +20 -19
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/SOURCES.txt +8 -1
- remote_cmd_manager-2.7.0/tests/performance/test_paramiko_threads.py +174 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_async_batch_executor.py +4 -2
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_batch_executor.py +6 -3
- remote_cmd_manager-2.7.0/tests/test_connection_budget.py +490 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_credential_provider.py +69 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_crypto.py +28 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_host_manager.py +14 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_host_service.py +20 -1
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_keyring_provider.py +5 -3
- remote_cmd_manager-2.7.0/tests/test_package_import.py +59 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_pool_policy.py +10 -1
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_sqlite_repository.py +42 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_ssh_client.py +63 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/LICENSE +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/MANIFEST.in +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/config.example.yaml +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/examples/basic_usage.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/examples/deploy_script.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/examples/nginx_batch_update.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/examples/system_health_check.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/cli/__init__.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/cli/main.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/__init__.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/async_ssh_client.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/host.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/ssh_client.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/repository/__init__.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/repository/host_repository.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/repository/json_host_repository.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/__init__.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/_host_runner.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/_types.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/ssh_service.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/storage_factory.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/service/task_runner.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/__init__.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/config.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/credential_guard.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/utils/logging_utils.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/requirements.txt +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/setup.cfg +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/__init__.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/conftest.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/integration/conftest.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/integration/test_ssh_connection.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/performance/__init__.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/performance/conftest.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/performance/test_benchmarks.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_async_ssh_client.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_cli.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_config.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_exceptions.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_host.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_logging_utils.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_output_policy.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_release_metadata.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_repository.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_retry_policy.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_ssh_service.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_storage_factory.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_sync_connection_pool.py +0 -0
- {remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/tests/test_task_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remote_cmd_manager
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Cross-platform SSH remote server management tool (Linux/macOS/Windows) — lightweight, zero-configuration CLI and Python API for managing servers over SSH. Supports host CRUD, batch commands, file transfer, credential encryption, async execution, and more.
|
|
5
5
|
Author-email: Vae-Scrooge <vaescrooge@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -89,36 +89,36 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
|
|
|
89
89
|
|
|
90
90
|
---
|
|
91
91
|
|
|
92
|
-
## v2.
|
|
92
|
+
## v2.7.0 Release Highlights
|
|
93
93
|
|
|
94
|
-
v2.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
v2.7.0 is a runtime-efficiency and error-semantics release: credential-layer
|
|
95
|
+
exceptions are narrowed so real bugs surface, SQLite no longer truncates its WAL
|
|
96
|
+
on every write, and the global connection budget waits on a true async queue
|
|
97
|
+
instead of polling. Public APIs remain backward compatible. See the
|
|
98
|
+
[full migration notes](./CHANGELOG.md).
|
|
98
99
|
|
|
99
|
-
###
|
|
100
|
+
### Error Semantics
|
|
100
101
|
|
|
101
|
-
-
|
|
102
|
-
-
|
|
102
|
+
- Keyring, encryption, and host-service decrypt paths now catch only expected failure classes; programming errors propagate instead of being silently swallowed.
|
|
103
|
+
- Background pool monitor loops keep their catch-all guard but log full stack traces.
|
|
104
|
+
- Optional `asyncssh` detection uses `find_spec`, so a defect in the async modules themselves can no longer masquerade as "dependency not installed".
|
|
103
105
|
|
|
104
|
-
###
|
|
106
|
+
### SQLite WAL Efficiency
|
|
105
107
|
|
|
106
|
-
- `
|
|
107
|
-
- New `
|
|
108
|
+
- `flush()` now runs a **PASSIVE** checkpoint instead of `TRUNCATE` — no more writer waits or WAL rewrites on every host add/update/remove.
|
|
109
|
+
- New explicit `repo.checkpoint("TRUNCATE")` (also `PASSIVE`/`FULL`/`RESTART`) for deliberate WAL compaction.
|
|
108
110
|
|
|
109
|
-
###
|
|
111
|
+
### Connection Budget
|
|
110
112
|
|
|
111
|
-
- `
|
|
112
|
-
- JSON repository concurrency semantics are now explicit: single-process/single-writer; use SQLite for multi-process writers.
|
|
113
|
-
- The `dev` extra now includes `asyncssh`, so `pip install -e ".[dev]"` runs the full test suite out of the box.
|
|
113
|
+
- `ConnectionBudget.acquire_async()` now waits on a real queue (`asyncio.Future` woken via `call_soon_threadsafe`), removing the v2.6 polling loop; cancellation-safe and FIFO among async waiters.
|
|
114
114
|
|
|
115
|
-
###
|
|
115
|
+
### Benchmark Data (P2.4)
|
|
116
116
|
|
|
117
|
-
-
|
|
117
|
+
- The sync Paramiko path costs ~100 µs per command in thread create/join and peaks at ≈ 3× sustained concurrency in live threads. A centralized channel-reader rewrite is a v2.7.x decision based on these measurements.
|
|
118
118
|
|
|
119
119
|
## Table of Contents
|
|
120
120
|
|
|
121
|
-
- [v2.
|
|
121
|
+
- [v2.7.0 Release Highlights](#v270-release-highlights)
|
|
122
122
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
123
123
|
- [Quick Start](#quick-start)
|
|
124
124
|
- [Use Cases](#use-cases)
|
|
@@ -268,6 +268,7 @@ with SSHClient(config) as client:
|
|
|
268
268
|
| **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
|
|
269
269
|
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
|
|
270
270
|
| **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
|
|
271
|
+
| **Global Connection Budget** | Optional `ConnectionBudget` caps live SSH connections process-wide across pools and executors |
|
|
271
272
|
| **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
|
|
272
273
|
| **Connection Test** | Test all host SSH connections and report status |
|
|
273
274
|
| **Secure Logging** | Structured logging that filters sensitive data (`SensitiveDataFilter`) |
|
|
@@ -36,36 +36,36 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
|
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
|
-
## v2.
|
|
39
|
+
## v2.7.0 Release Highlights
|
|
40
40
|
|
|
41
|
-
v2.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
v2.7.0 is a runtime-efficiency and error-semantics release: credential-layer
|
|
42
|
+
exceptions are narrowed so real bugs surface, SQLite no longer truncates its WAL
|
|
43
|
+
on every write, and the global connection budget waits on a true async queue
|
|
44
|
+
instead of polling. Public APIs remain backward compatible. See the
|
|
45
|
+
[full migration notes](./CHANGELOG.md).
|
|
45
46
|
|
|
46
|
-
###
|
|
47
|
+
### Error Semantics
|
|
47
48
|
|
|
48
|
-
-
|
|
49
|
-
-
|
|
49
|
+
- Keyring, encryption, and host-service decrypt paths now catch only expected failure classes; programming errors propagate instead of being silently swallowed.
|
|
50
|
+
- Background pool monitor loops keep their catch-all guard but log full stack traces.
|
|
51
|
+
- Optional `asyncssh` detection uses `find_spec`, so a defect in the async modules themselves can no longer masquerade as "dependency not installed".
|
|
50
52
|
|
|
51
|
-
###
|
|
53
|
+
### SQLite WAL Efficiency
|
|
52
54
|
|
|
53
|
-
- `
|
|
54
|
-
- New `
|
|
55
|
+
- `flush()` now runs a **PASSIVE** checkpoint instead of `TRUNCATE` — no more writer waits or WAL rewrites on every host add/update/remove.
|
|
56
|
+
- New explicit `repo.checkpoint("TRUNCATE")` (also `PASSIVE`/`FULL`/`RESTART`) for deliberate WAL compaction.
|
|
55
57
|
|
|
56
|
-
###
|
|
58
|
+
### Connection Budget
|
|
57
59
|
|
|
58
|
-
- `
|
|
59
|
-
- JSON repository concurrency semantics are now explicit: single-process/single-writer; use SQLite for multi-process writers.
|
|
60
|
-
- The `dev` extra now includes `asyncssh`, so `pip install -e ".[dev]"` runs the full test suite out of the box.
|
|
60
|
+
- `ConnectionBudget.acquire_async()` now waits on a real queue (`asyncio.Future` woken via `call_soon_threadsafe`), removing the v2.6 polling loop; cancellation-safe and FIFO among async waiters.
|
|
61
61
|
|
|
62
|
-
###
|
|
62
|
+
### Benchmark Data (P2.4)
|
|
63
63
|
|
|
64
|
-
-
|
|
64
|
+
- The sync Paramiko path costs ~100 µs per command in thread create/join and peaks at ≈ 3× sustained concurrency in live threads. A centralized channel-reader rewrite is a v2.7.x decision based on these measurements.
|
|
65
65
|
|
|
66
66
|
## Table of Contents
|
|
67
67
|
|
|
68
|
-
- [v2.
|
|
68
|
+
- [v2.7.0 Release Highlights](#v270-release-highlights)
|
|
69
69
|
- [Why Remote CMD?](#why-remote-cmd)
|
|
70
70
|
- [Quick Start](#quick-start)
|
|
71
71
|
- [Use Cases](#use-cases)
|
|
@@ -215,6 +215,7 @@ with SSHClient(config) as client:
|
|
|
215
215
|
| **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
|
|
216
216
|
| **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
|
|
217
217
|
| **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
|
|
218
|
+
| **Global Connection Budget** | Optional `ConnectionBudget` caps live SSH connections process-wide across pools and executors |
|
|
218
219
|
| **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
|
|
219
220
|
| **Connection Test** | Test all host SSH connections and report status |
|
|
220
221
|
| **Secure Logging** | Structured logging that filters sensitive data (`SensitiveDataFilter`) |
|
|
@@ -122,6 +122,8 @@ ignore = [
|
|
|
122
122
|
"__init__.py" = ["E402"]
|
|
123
123
|
# 测试中 pytest fixture 参数未直接引用属于正常模式(如 patch 装饰器触发副作用)
|
|
124
124
|
"tests/test_async_ssh_client.py" = ["ARG002"]
|
|
125
|
+
# mock_paramiko fixture 以 patch 副作用生效,线程模型测试不直接引用其返回值
|
|
126
|
+
"tests/test_ssh_client.py" = ["ARG002"]
|
|
125
127
|
"tests/test_async_batch_executor.py" = ["ARG001", "ARG002"]
|
|
126
128
|
# 连接池测试中 patched_client fixture 触发 patch 副作用,factory 保留 cfg 参数以匹配真实签名
|
|
127
129
|
"tests/test_sync_connection_pool.py" = ["ARG001", "ARG002"]
|
|
@@ -48,7 +48,7 @@ Remote CMD - SSH 远程服务器管理工具
|
|
|
48
48
|
- 文档: 参见 docs/ 目录
|
|
49
49
|
|
|
50
50
|
Author: Vae-Scrooge
|
|
51
|
-
Version: 2.
|
|
51
|
+
Version: 2.7.0(单一真相源见 remote_cmd._version)
|
|
52
52
|
License: MIT
|
|
53
53
|
"""
|
|
54
54
|
|
|
@@ -59,23 +59,25 @@ __author__ = "Vae-Scrooge"
|
|
|
59
59
|
__email__ = "vae-scrooge@example.com"
|
|
60
60
|
__license__ = "MIT"
|
|
61
61
|
|
|
62
|
+
import importlib.util
|
|
62
63
|
import logging
|
|
63
64
|
|
|
64
65
|
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
65
66
|
|
|
66
|
-
# 异步原生模块依赖 asyncssh(optional extra [async]
|
|
67
|
-
#
|
|
68
|
-
|
|
67
|
+
# 异步原生模块依赖 asyncssh(optional extra [async])。v2.7(P2.2)起用
|
|
68
|
+
# find_spec 显式探测可选依赖:仅当 asyncssh 缺失时才降级;若 asyncssh 已
|
|
69
|
+
# 安装而我们自己的异步模块 import 失败(代码缺陷/依赖损坏),import
|
|
70
|
+
# 错误会向上传播,不再被静默误判为"未安装 asyncssh"。
|
|
71
|
+
_HAS_ASYNC = importlib.util.find_spec("asyncssh") is not None
|
|
72
|
+
|
|
73
|
+
if _HAS_ASYNC:
|
|
69
74
|
from remote_cmd.core.async_connection_pool import AsyncConnectionPool
|
|
70
75
|
from remote_cmd.core.async_ssh_client import AsyncSSHClient
|
|
71
76
|
from remote_cmd.service.async_batch_executor import AsyncBatchExecutor
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
_HAS_ASYNC = False
|
|
76
|
-
|
|
78
|
+
from remote_cmd.api.host_manager import HostManager
|
|
79
|
+
from remote_cmd.core.budget import ConnectionBudget
|
|
77
80
|
from remote_cmd.core.host import Host
|
|
78
|
-
from remote_cmd.core.host_manager import HostManager
|
|
79
81
|
from remote_cmd.core.ssh_client import SSHClient
|
|
80
82
|
from remote_cmd.core.sync_connection_pool import SyncConnectionPool
|
|
81
83
|
|
|
@@ -105,7 +107,7 @@ from remote_cmd.service.batch_executor import (
|
|
|
105
107
|
from remote_cmd.service.credential_provider import KeyringCredentialProvider
|
|
106
108
|
from remote_cmd.service.task_runner import Task, TaskRunner, TaskStatus
|
|
107
109
|
from remote_cmd.utils.crypto import CredentialEncryption
|
|
108
|
-
from remote_cmd.utils.exceptions import PlaintextCredentialWarning
|
|
110
|
+
from remote_cmd.utils.exceptions import BudgetTimeoutError, PlaintextCredentialWarning
|
|
109
111
|
from remote_cmd.utils.logging_utils import (
|
|
110
112
|
SensitiveDataFilter,
|
|
111
113
|
get_logger,
|
|
@@ -142,6 +144,8 @@ if _HAS_ASYNC:
|
|
|
142
144
|
"BatchHostResult",
|
|
143
145
|
"OutputPolicy",
|
|
144
146
|
"PlaintextCredentialWarning",
|
|
147
|
+
"ConnectionBudget",
|
|
148
|
+
"BudgetTimeoutError",
|
|
145
149
|
"SyncConnectionPool",
|
|
146
150
|
"TaskRunner",
|
|
147
151
|
"Task",
|
|
@@ -177,6 +181,8 @@ else:
|
|
|
177
181
|
"BatchHostResult",
|
|
178
182
|
"OutputPolicy",
|
|
179
183
|
"PlaintextCredentialWarning",
|
|
184
|
+
"ConnectionBudget",
|
|
185
|
+
"BudgetTimeoutError",
|
|
180
186
|
"SyncConnectionPool",
|
|
181
187
|
"TaskRunner",
|
|
182
188
|
"Task",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API facade 包(向后兼容层)
|
|
3
|
+
|
|
4
|
+
存放从 ``core`` 迁出的公共兼容 facade,使核心层(``core``)保持对
|
|
5
|
+
``service`` 的零依赖。当前包含:
|
|
6
|
+
|
|
7
|
+
- :class:`remote_cmd.api.host_manager.HostManager`:v1.x 的 HostManager
|
|
8
|
+
API,内部委托给 HostService + JsonHostRepository。
|
|
9
|
+
|
|
10
|
+
旧导入路径 ``remote_cmd.core.host_manager`` 仍然可用(re-export shim),
|
|
11
|
+
但新代码应使用 ``remote_cmd.api.host_manager`` 或直接使用 HostService。
|
|
12
|
+
|
|
13
|
+
依赖方向(v2.6 契约)::
|
|
14
|
+
|
|
15
|
+
cli / api → service → core → utils
|
|
16
|
+
repository ← service / cli
|
|
17
|
+
core 绝不 import service / cli / api 的实现
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from remote_cmd.api.host_manager import HostManager
|
|
21
|
+
|
|
22
|
+
__all__ = ["HostManager"]
|
{remote_cmd_manager-2.5.0/remote_cmd/core → remote_cmd_manager-2.7.0/remote_cmd/api}/host_manager.py
RENAMED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
2
|
+
主机管理模块(向后兼容 facade,canonical 位置)
|
|
3
3
|
|
|
4
|
-
保持原有 API 兼容,内部委托给新的 Repository + Service 层。
|
|
4
|
+
保持原有 HostManager API 兼容,内部委托给新的 Repository + Service 层。
|
|
5
5
|
|
|
6
6
|
新的代码应直接使用:
|
|
7
7
|
- remote_cmd.core.host.Host (代替 Host)
|
|
8
8
|
- remote_cmd.service.host_service.HostService (代替 HostManager)
|
|
9
9
|
- remote_cmd.repository.json_host_repository.JsonHostRepository
|
|
10
|
+
- remote_cmd.api.host_manager.HostManager(本模块;仅在需要旧 API 时)
|
|
11
|
+
|
|
12
|
+
v2.6 架构说明:
|
|
13
|
+
本模块自 ``remote_cmd.core.host_manager`` 迁至 ``remote_cmd.api``,
|
|
14
|
+
使 ``core`` 层不再依赖 ``service`` 层。旧导入路径由
|
|
15
|
+
``remote_cmd.core.host_manager`` 的 re-export shim 保持可用。
|
|
10
16
|
"""
|
|
11
17
|
|
|
12
18
|
import logging
|
{remote_cmd_manager-2.5.0 → remote_cmd_manager-2.7.0}/remote_cmd/core/async_connection_pool.py
RENAMED
|
@@ -19,13 +19,14 @@ import uuid
|
|
|
19
19
|
from typing import Any, Optional
|
|
20
20
|
|
|
21
21
|
from remote_cmd.core.async_ssh_client import AsyncSSHClient
|
|
22
|
-
from remote_cmd.core.
|
|
23
|
-
from remote_cmd.
|
|
22
|
+
from remote_cmd.core.budget import ConnectionBudget
|
|
23
|
+
from remote_cmd.core.pool_policy import (
|
|
24
24
|
ConnectionMeta,
|
|
25
25
|
idle_expired,
|
|
26
26
|
lifetime_expired,
|
|
27
27
|
should_close,
|
|
28
28
|
)
|
|
29
|
+
from remote_cmd.core.ssh_client import ConnectionConfig
|
|
29
30
|
from remote_cmd.utils.exceptions import PoolClosedError
|
|
30
31
|
|
|
31
32
|
logger = logging.getLogger(__name__)
|
|
@@ -40,6 +41,9 @@ class AsyncConnectionPool:
|
|
|
40
41
|
max_lifetime: 连接最大生命周期(秒),超过自动关闭
|
|
41
42
|
idle_timeout: 空闲超时(秒),超过自动关闭
|
|
42
43
|
health_check_interval: 后台清理任务周期(秒)
|
|
44
|
+
connection_budget: 可选的全局连接预算(v2.6)。提供时本池创建的
|
|
45
|
+
每条存活连接都占用一个预算槽位(含空闲连接),连接关闭/清理/
|
|
46
|
+
close_all 时释放;跨多个池共享同一预算实例可获得全局连接上限
|
|
43
47
|
"""
|
|
44
48
|
|
|
45
49
|
def __init__(
|
|
@@ -50,6 +54,7 @@ class AsyncConnectionPool:
|
|
|
50
54
|
idle_timeout: int = 300,
|
|
51
55
|
health_check_interval: int = 60,
|
|
52
56
|
client_factory: Optional[Any] = None,
|
|
57
|
+
connection_budget: Optional[ConnectionBudget] = None,
|
|
53
58
|
) -> None:
|
|
54
59
|
"""
|
|
55
60
|
Args:
|
|
@@ -60,6 +65,7 @@ class AsyncConnectionPool:
|
|
|
60
65
|
health_check_interval: 后台清理任务周期(秒)
|
|
61
66
|
client_factory: 客户端工厂,默认为 AsyncSSHClient;测试可注入
|
|
62
67
|
mock(与 SyncConnectionPool 对齐)
|
|
68
|
+
connection_budget: 可选的全局连接预算(ConnectionBudget)
|
|
63
69
|
"""
|
|
64
70
|
self.config = config
|
|
65
71
|
self._max = max_connections
|
|
@@ -68,6 +74,7 @@ class AsyncConnectionPool:
|
|
|
68
74
|
self._health_check_interval = health_check_interval
|
|
69
75
|
# 客户端工厂:默认为 AsyncSSHClient;测试可注入 mock
|
|
70
76
|
self._client_factory = client_factory or AsyncSSHClient
|
|
77
|
+
self._connection_budget = connection_budget
|
|
71
78
|
|
|
72
79
|
# 容器
|
|
73
80
|
self._connections: list[AsyncSSHClient] = []
|
|
@@ -190,10 +197,16 @@ class AsyncConnectionPool:
|
|
|
190
197
|
# ------------------------------------------------------------------
|
|
191
198
|
async def _create_connection(self) -> AsyncSSHClient:
|
|
192
199
|
client = self._client_factory(self.config)
|
|
200
|
+
if self._connection_budget is not None:
|
|
201
|
+
await self._connection_budget.acquire_async()
|
|
193
202
|
try:
|
|
194
203
|
await client.connect()
|
|
195
204
|
except Exception: # noqa: BLE001
|
|
196
|
-
# 信号量由 acquire() 的 except
|
|
205
|
+
# 信号量由 acquire() 的 except 统一释放,此处不再释放。
|
|
206
|
+
# 连接预算仅在成功建连后由 _close_connection 释放;
|
|
207
|
+
# 建连失败立即归还预算,避免预算泄漏
|
|
208
|
+
if self._connection_budget is not None:
|
|
209
|
+
await self._connection_budget.release_async()
|
|
197
210
|
self._total_failed += 1
|
|
198
211
|
raise
|
|
199
212
|
self._connections.append(client)
|
|
@@ -237,8 +250,14 @@ class AsyncConnectionPool:
|
|
|
237
250
|
with contextlib.suppress(Exception):
|
|
238
251
|
await conn.disconnect()
|
|
239
252
|
self._meta.pop(id(conn), None)
|
|
240
|
-
|
|
253
|
+
tracked = conn in self._connections
|
|
254
|
+
if tracked:
|
|
241
255
|
self._connections.remove(conn)
|
|
256
|
+
# exactly-once:仅对仍被池追踪的连接释放预算。
|
|
257
|
+
# close_all 与 release 可能对同一连接重复调用 _close_connection,
|
|
258
|
+
# 用 tracked 守卫避免预算被超额释放(BoundedSemaphore 会抛 ValueError)
|
|
259
|
+
if tracked and self._connection_budget is not None:
|
|
260
|
+
await self._connection_budget.release_async()
|
|
242
261
|
|
|
243
262
|
# ------------------------------------------------------------------
|
|
244
263
|
# 后台监控
|
|
@@ -259,7 +278,9 @@ class AsyncConnectionPool:
|
|
|
259
278
|
except asyncio.CancelledError:
|
|
260
279
|
break
|
|
261
280
|
except Exception: # noqa: BLE001
|
|
262
|
-
|
|
281
|
+
# 后台清理任务必须吞掉一切非取消异常以保持存活(单次清理
|
|
282
|
+
# 失败不应终止后续周期);保留完整堆栈便于定位
|
|
283
|
+
logger.warning("connection pool monitor error", exc_info=True)
|
|
263
284
|
|
|
264
285
|
async def _cleanup_expired(self) -> None:
|
|
265
286
|
now = time.time()
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
全局连接预算(v2.6 P2.5;v2.7 真异步等待队列)
|
|
3
|
+
|
|
4
|
+
用于在**多个连接池 / 多个执行器**之间封顶进程内同时存活的 SSH 连接数,
|
|
5
|
+
避免大规模 fleet 场景下 per-pool 上限叠加导致的 fd/socket 耗尽:
|
|
6
|
+
|
|
7
|
+
池 A(max=10) + 池 B(max=10) + … 无预算时可无限叠加
|
|
8
|
+
预算(max=50) 全局硬上限 50 条存活连接
|
|
9
|
+
|
|
10
|
+
预算语义(存活连接预算):
|
|
11
|
+
- 预算单位 = **存活连接**(含池中空闲连接)。
|
|
12
|
+
- 获取时机:连接创建(``connect``)前;释放时机:连接断开 / 关闭 /
|
|
13
|
+
被清理 / 池 ``close_all``。
|
|
14
|
+
- 池归还空闲连接**不**释放预算(连接仍存活);``acquire`` 复用空闲连接
|
|
15
|
+
**不**重复获取预算。
|
|
16
|
+
|
|
17
|
+
双接口:
|
|
18
|
+
- 同步::meth:`acquire` / :meth:`release` / :meth:`acquire_context`
|
|
19
|
+
- 异步::meth:`acquire_async` / :meth:`release_async` /
|
|
20
|
+
:meth:`acquire_context_async`
|
|
21
|
+
|
|
22
|
+
同一实例可被同步与异步执行器共享:容量与等待队列由单个
|
|
23
|
+
``threading.Condition`` 保护;异步等待者注册 ``asyncio.Future``,
|
|
24
|
+
释放时通过 ``loop.call_soon_threadsafe`` 精准唤醒(v2.7 P2.5 优化:
|
|
25
|
+
取代 v2.6 的 10→50ms 轮询,无空转 wakeup)。
|
|
26
|
+
|
|
27
|
+
唤醒语义(condition-variable 模式):
|
|
28
|
+
- 唤醒只是"提示"而非预留:被唤醒者需重新竞争容量;释放方不预占槽位。
|
|
29
|
+
- 异步等待者之间 FIFO;同步等待者之间 FIFO;跨类型顺序不作保证。
|
|
30
|
+
- 等待者超时/取消时会从队列移除;若唤醒权已派发给它,
|
|
31
|
+
``_resolve_waiter`` 检测到 future 已完成会补唤醒下一个等待者,
|
|
32
|
+
不会因取消竞态丢失唤醒。
|
|
33
|
+
|
|
34
|
+
超时:``acquire_timeout=None``(默认)无限等待;配置正整数/浮点秒数时
|
|
35
|
+
超时抛出 :class:`~remote_cmd.utils.exceptions.BudgetTimeoutError`。
|
|
36
|
+
|
|
37
|
+
用法:
|
|
38
|
+
>>> from remote_cmd.core.budget import ConnectionBudget
|
|
39
|
+
>>> budget = ConnectionBudget(max_connections=50)
|
|
40
|
+
>>> with budget.acquire_context():
|
|
41
|
+
... pass # 持有预算期间创建/保有连接
|
|
42
|
+
>>> budget.get_metrics()["in_use"]
|
|
43
|
+
0
|
|
44
|
+
>>>
|
|
45
|
+
>>> async def use():
|
|
46
|
+
... async with budget.acquire_context_async():
|
|
47
|
+
... pass
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
import asyncio
|
|
51
|
+
import contextlib
|
|
52
|
+
import threading
|
|
53
|
+
import time
|
|
54
|
+
from collections import deque
|
|
55
|
+
from typing import Any, AsyncIterator, Deque, Iterator, Optional
|
|
56
|
+
|
|
57
|
+
from remote_cmd.utils.exceptions import BudgetTimeoutError, ValidationError
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConnectionBudget:
|
|
61
|
+
"""全局连接预算(同步 / 异步共用)。
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
max_connections: 全局最大存活连接数(正整数;bool 被拒绝)
|
|
65
|
+
acquire_timeout: 获取预算的等待上限(秒)。``None``(默认)
|
|
66
|
+
表示无限等待;正数表示超时后抛 ``BudgetTimeoutError``
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValidationError: 参数非法(构造时校验)
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
max_connections: int,
|
|
75
|
+
acquire_timeout: Optional[float] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
if (
|
|
78
|
+
isinstance(max_connections, bool)
|
|
79
|
+
or not isinstance(max_connections, int)
|
|
80
|
+
or max_connections <= 0
|
|
81
|
+
):
|
|
82
|
+
raise ValidationError(
|
|
83
|
+
f"max_connections must be a positive integer, got: {max_connections!r}"
|
|
84
|
+
)
|
|
85
|
+
if acquire_timeout is not None and (
|
|
86
|
+
isinstance(acquire_timeout, bool)
|
|
87
|
+
or not isinstance(acquire_timeout, (int, float))
|
|
88
|
+
or acquire_timeout <= 0
|
|
89
|
+
):
|
|
90
|
+
raise ValidationError(
|
|
91
|
+
f"acquire_timeout must be None or a positive number, got: {acquire_timeout!r}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
self._max = max_connections
|
|
95
|
+
self._acquire_timeout = acquire_timeout
|
|
96
|
+
|
|
97
|
+
# 单一互斥 + 条件变量:保护容量计数与等待队列(同步/异步共享)
|
|
98
|
+
self._cond = threading.Condition(threading.Lock())
|
|
99
|
+
self._in_use = 0
|
|
100
|
+
self._async_waiters: Deque[asyncio.Future[None]] = deque()
|
|
101
|
+
|
|
102
|
+
# 指标
|
|
103
|
+
self._total_acquired = 0
|
|
104
|
+
self._total_released = 0
|
|
105
|
+
self._total_timeouts = 0
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# 属性 / 指标
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
@property
|
|
111
|
+
def max_connections(self) -> int:
|
|
112
|
+
"""全局最大存活连接数。"""
|
|
113
|
+
return self._max
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def acquire_timeout(self) -> Optional[float]:
|
|
117
|
+
"""获取预算的等待上限(秒);``None`` 表示无限等待。"""
|
|
118
|
+
return self._acquire_timeout
|
|
119
|
+
|
|
120
|
+
def get_metrics(self) -> dict[str, Any]:
|
|
121
|
+
"""预算指标快照。"""
|
|
122
|
+
with self._cond:
|
|
123
|
+
return {
|
|
124
|
+
"max_connections": self._max,
|
|
125
|
+
"in_use": self._in_use,
|
|
126
|
+
"available": self._max - self._in_use,
|
|
127
|
+
"total_acquired": self._total_acquired,
|
|
128
|
+
"total_released": self._total_released,
|
|
129
|
+
"total_timeouts": self._total_timeouts,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------------------
|
|
133
|
+
# 同步接口
|
|
134
|
+
# ------------------------------------------------------------------
|
|
135
|
+
def acquire(self) -> None:
|
|
136
|
+
"""获取一个连接预算槽位(阻塞,受 ``acquire_timeout`` 约束)。
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
BudgetTimeoutError: 等待超过 ``acquire_timeout``
|
|
140
|
+
"""
|
|
141
|
+
timeout = self._acquire_timeout
|
|
142
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
143
|
+
with self._cond:
|
|
144
|
+
while self._in_use >= self._max:
|
|
145
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
146
|
+
if remaining is not None and remaining <= 0:
|
|
147
|
+
self._total_timeouts += 1
|
|
148
|
+
# 超时者退出前让出一次唤醒权,避免其他同步等待者
|
|
149
|
+
# 因槽位空闲却无人被通知而饥饿
|
|
150
|
+
self._cond.notify()
|
|
151
|
+
raise BudgetTimeoutError(
|
|
152
|
+
"connection budget exhausted: "
|
|
153
|
+
f"waited {timeout}s for a slot "
|
|
154
|
+
f"(max_connections={self._max})"
|
|
155
|
+
)
|
|
156
|
+
self._cond.wait(remaining)
|
|
157
|
+
self._in_use += 1
|
|
158
|
+
self._total_acquired += 1
|
|
159
|
+
|
|
160
|
+
def release(self) -> None:
|
|
161
|
+
"""释放一个连接预算槽位并唤醒一个等待者。
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
ValueError: 释放次数超过获取次数
|
|
165
|
+
"""
|
|
166
|
+
with self._cond:
|
|
167
|
+
if self._in_use <= 0:
|
|
168
|
+
raise ValueError("release without a matching acquire")
|
|
169
|
+
self._in_use -= 1
|
|
170
|
+
self._total_released += 1
|
|
171
|
+
self._wake_next_locked()
|
|
172
|
+
|
|
173
|
+
@contextlib.contextmanager
|
|
174
|
+
def acquire_context(self) -> Iterator[None]:
|
|
175
|
+
"""同步上下文管理器:进入获取预算,退出释放。"""
|
|
176
|
+
self.acquire()
|
|
177
|
+
try:
|
|
178
|
+
yield
|
|
179
|
+
finally:
|
|
180
|
+
self.release()
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# 异步接口
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
async def acquire_async(self) -> None:
|
|
186
|
+
"""获取一个连接预算槽位(asyncio 原生等待,受 ``acquire_timeout`` 约束)。
|
|
187
|
+
|
|
188
|
+
通过注册 ``asyncio.Future`` 等待释放方唤醒,不使用轮询。
|
|
189
|
+
"""
|
|
190
|
+
timeout = self._acquire_timeout
|
|
191
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
192
|
+
loop = asyncio.get_running_loop()
|
|
193
|
+
while True:
|
|
194
|
+
with self._cond:
|
|
195
|
+
if self._in_use < self._max:
|
|
196
|
+
self._in_use += 1
|
|
197
|
+
self._total_acquired += 1
|
|
198
|
+
return
|
|
199
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
200
|
+
if remaining is not None and remaining <= 0:
|
|
201
|
+
self._total_timeouts += 1
|
|
202
|
+
self._cond.notify()
|
|
203
|
+
raise BudgetTimeoutError(
|
|
204
|
+
"connection budget exhausted: "
|
|
205
|
+
f"waited {timeout}s for a slot "
|
|
206
|
+
f"(max_connections={self._max})"
|
|
207
|
+
)
|
|
208
|
+
fut: asyncio.Future[None] = loop.create_future()
|
|
209
|
+
self._async_waiters.append(fut)
|
|
210
|
+
try:
|
|
211
|
+
if remaining is None:
|
|
212
|
+
await fut
|
|
213
|
+
else:
|
|
214
|
+
await asyncio.wait_for(fut, remaining)
|
|
215
|
+
except asyncio.TimeoutError:
|
|
216
|
+
with self._cond:
|
|
217
|
+
self._discard_waiter_locked(fut)
|
|
218
|
+
self._total_timeouts += 1
|
|
219
|
+
# 若唤醒权已被派发给本 future(弹出后 resolve 前超时),
|
|
220
|
+
# _resolve_waiter 会发现 fut 已完成并补唤醒下一个等待者
|
|
221
|
+
raise BudgetTimeoutError(
|
|
222
|
+
"connection budget exhausted: "
|
|
223
|
+
f"waited {timeout}s for a slot "
|
|
224
|
+
f"(max_connections={self._max})"
|
|
225
|
+
) from None
|
|
226
|
+
except asyncio.CancelledError:
|
|
227
|
+
with self._cond:
|
|
228
|
+
self._discard_waiter_locked(fut)
|
|
229
|
+
raise
|
|
230
|
+
# 被唤醒后回到循环顶部重新竞争容量(唤醒不预留槽位)
|
|
231
|
+
|
|
232
|
+
async def release_async(self) -> None:
|
|
233
|
+
"""释放一个连接预算槽位(异步接口;与 :meth:`release` 等价)。"""
|
|
234
|
+
self.release()
|
|
235
|
+
|
|
236
|
+
@contextlib.asynccontextmanager
|
|
237
|
+
async def acquire_context_async(self) -> AsyncIterator[None]:
|
|
238
|
+
"""异步上下文管理器:进入获取预算,退出释放。"""
|
|
239
|
+
await self.acquire_async()
|
|
240
|
+
try:
|
|
241
|
+
yield
|
|
242
|
+
finally:
|
|
243
|
+
await self.release_async()
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# 内部:等待队列
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
def _discard_waiter_locked(self, fut: asyncio.Future[None]) -> None:
|
|
249
|
+
"""从异步等待队列移除 future(不存在则忽略)。须持锁调用。"""
|
|
250
|
+
with contextlib.suppress(ValueError):
|
|
251
|
+
self._async_waiters.remove(fut)
|
|
252
|
+
|
|
253
|
+
def _wake_next_locked(self) -> None:
|
|
254
|
+
"""唤醒一个等待者(异步优先 FIFO,否则通知一个同步等待者)。
|
|
255
|
+
|
|
256
|
+
唤醒不预留槽位;被唤醒者会重新检查容量。须持锁调用。
|
|
257
|
+
"""
|
|
258
|
+
while self._async_waiters:
|
|
259
|
+
fut = self._async_waiters.popleft()
|
|
260
|
+
if fut.done():
|
|
261
|
+
continue
|
|
262
|
+
loop = fut.get_loop()
|
|
263
|
+
if loop.is_closed():
|
|
264
|
+
continue
|
|
265
|
+
try:
|
|
266
|
+
loop.call_soon_threadsafe(self._resolve_waiter, fut)
|
|
267
|
+
except RuntimeError:
|
|
268
|
+
# 事件循环正在关闭:跳过该等待者,尝试下一个
|
|
269
|
+
continue
|
|
270
|
+
return
|
|
271
|
+
self._cond.notify()
|
|
272
|
+
|
|
273
|
+
def _resolve_waiter(self, fut: asyncio.Future[None]) -> None:
|
|
274
|
+
"""在事件循环线程内完成 future(或补唤醒下一个等待者)。"""
|
|
275
|
+
if fut.done():
|
|
276
|
+
# 等待者已超时/取消:唤醒权未被消费,补唤醒下一个
|
|
277
|
+
with self._cond:
|
|
278
|
+
self._wake_next_locked()
|
|
279
|
+
return
|
|
280
|
+
fut.set_result(None)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
__all__ = ["ConnectionBudget"]
|