remote-cmd-manager 2.3.0__tar.gz → 2.4.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.
Files changed (81) hide show
  1. {remote_cmd_manager-2.3.0/remote_cmd_manager.egg-info → remote_cmd_manager-2.4.0}/PKG-INFO +17 -15
  2. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/README.md +16 -14
  3. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/__init__.py +1 -1
  4. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/_version.py +1 -1
  5. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_host_runner.py +60 -3
  6. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/async_batch_executor.py +15 -1
  7. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/batch_executor.py +23 -2
  8. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/logging_utils.py +7 -2
  9. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0/remote_cmd_manager.egg-info}/PKG-INFO +17 -15
  10. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_async_batch_executor.py +118 -0
  11. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_batch_executor.py +100 -1
  12. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_logging_utils.py +14 -0
  13. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/LICENSE +0 -0
  14. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/MANIFEST.in +0 -0
  15. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/config.example.yaml +0 -0
  16. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/examples/basic_usage.py +0 -0
  17. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/examples/deploy_script.py +0 -0
  18. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/examples/nginx_batch_update.py +0 -0
  19. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/examples/system_health_check.py +0 -0
  20. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/pyproject.toml +0 -0
  21. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/cli/__init__.py +0 -0
  22. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/cli/main.py +0 -0
  23. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/__init__.py +0 -0
  24. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/async_connection_pool.py +0 -0
  25. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/async_ssh_client.py +0 -0
  26. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/host.py +0 -0
  27. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/host_manager.py +0 -0
  28. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/ssh_client.py +0 -0
  29. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/core/sync_connection_pool.py +0 -0
  30. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/__init__.py +0 -0
  31. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/host_repository.py +0 -0
  32. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/json_host_repository.py +0 -0
  33. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/repository/sqlite_host_repository.py +0 -0
  34. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/__init__.py +0 -0
  35. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_pool_policy.py +0 -0
  36. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/_types.py +0 -0
  37. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/credential_provider.py +0 -0
  38. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/host_service.py +0 -0
  39. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/retry_policy.py +0 -0
  40. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/ssh_service.py +0 -0
  41. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/storage_factory.py +0 -0
  42. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/service/task_runner.py +0 -0
  43. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/__init__.py +0 -0
  44. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/config.py +0 -0
  45. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/credential_guard.py +0 -0
  46. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/crypto.py +0 -0
  47. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd/utils/exceptions.py +0 -0
  48. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/SOURCES.txt +0 -0
  49. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/dependency_links.txt +0 -0
  50. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/entry_points.txt +0 -0
  51. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/requires.txt +0 -0
  52. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/remote_cmd_manager.egg-info/top_level.txt +0 -0
  53. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/requirements.txt +0 -0
  54. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/setup.cfg +0 -0
  55. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/__init__.py +0 -0
  56. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/conftest.py +0 -0
  57. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/integration/conftest.py +0 -0
  58. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/integration/test_ssh_connection.py +0 -0
  59. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/performance/__init__.py +0 -0
  60. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/performance/conftest.py +0 -0
  61. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/performance/test_benchmarks.py +0 -0
  62. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_async_ssh_client.py +0 -0
  63. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_cli.py +0 -0
  64. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_config.py +0 -0
  65. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_credential_provider.py +0 -0
  66. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_crypto.py +0 -0
  67. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_exceptions.py +0 -0
  68. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_host.py +0 -0
  69. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_host_manager.py +0 -0
  70. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_host_service.py +0 -0
  71. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_keyring_provider.py +0 -0
  72. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_pool_policy.py +0 -0
  73. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_release_metadata.py +0 -0
  74. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_repository.py +0 -0
  75. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_retry_policy.py +0 -0
  76. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_sqlite_repository.py +0 -0
  77. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_ssh_client.py +0 -0
  78. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_ssh_service.py +0 -0
  79. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_storage_factory.py +0 -0
  80. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.0}/tests/test_sync_connection_pool.py +0 -0
  81. {remote_cmd_manager-2.3.0 → remote_cmd_manager-2.4.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.0
3
+ Version: 2.4.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,28 +89,30 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
89
89
 
90
90
  ---
91
91
 
92
- ## v2.3.0 Release Highlights
92
+ ## v2.4.0 Release Highlights
93
93
 
94
- v2.3.0 makes blocking SFTP operations timeout-safe, hardens async cancellation cleanup,
95
- and aligns the supported Python range and release tooling. See the
94
+ v2.4.0 adds an opt-in retained-output cap for batch execution and cleans up logging
95
+ handler resources, with default behavior and public result schemas unchanged. See the
96
96
  [full migration notes](./CHANGELOG.md) before upgrading automated callers.
97
97
 
98
- ### SFTP Timeouts & Reliability
98
+ ### Bounded Batch Output
99
99
 
100
- - Blocking SFTP operations now support an inactivity timeout (default: `ConnectionConfig.timeout`, 30 s): uploads, downloads, directory listing, directory creation, removal, and file-info calls accept an optional `timeout` argument.
101
- - The synchronous `SSHClient` applies the timeout to the Paramiko SFTP channel, so a stalled read/write aborts the actual operation and raises `SSHFileTransferError`.
102
- - The asynchronous `AsyncSSHClient` bounds SFTP channel startup with a timeout and uses a progress-based inactivity watchdog to cancel stalled transfers, awaiting the aborted operation and preserving caller cancellation during cleanup.
103
- - Timed-out or cancelled SFTP sessions are closed and discarded, so a stale or desynchronized channel can never be reused.
100
+ - `BatchExecutor` / `AsyncBatchExecutor` accept an optional `max_output_bytes` (default `None`): `None` keeps full `stdout`/`stderr`, while a positive value caps each host's retained streams and appends a `[output truncated: N bytes omitted]` marker.
101
+ - Truncation is deterministic and UTF-8 byte-boundary safe, applies to `stdout` and `stderr` independently, and never changes command success/failure or exit codes.
102
+ - The cap bounds the retained `BatchResult`; the SSH client may still hold full output transiently during execution, so it is not a process-wide RSS limit.
104
103
 
105
- ### Release Engineering
104
+ ### Reliability
106
105
 
107
- - Python 3.9–3.14 are officially supported and CI-tested; `Requires-Python` remains `>=3.9`.
108
- - CI runs release-metadata validation for CHANGELOG.md-only changes.
109
- - Publish builds pin `build==1.5.0` and `twine==7.0.0`.
106
+ - `setup_logging` now closes previous root-logger handlers before removing them, avoiding unclosed-file resource warnings when logging is reconfigured.
107
+ - Connection-pool lifetime wording in the documentation now matches the actual behavior: internal pools are created lazily per host and closed as soon as that host finishes (including its retries).
108
+
109
+ ### Compatibility
110
+
111
+ - Default behavior is unchanged (`max_output_bytes=None` retains full output) and the public `BatchResult` / `BatchHostResult` schemas are unchanged.
110
112
 
111
113
  ## Table of Contents
112
114
 
113
- - [v2.3.0 Release Highlights](#v230-release-highlights)
115
+ - [v2.4.0 Release Highlights](#v240-release-highlights)
114
116
  - [Why Remote CMD?](#why-remote-cmd)
115
117
  - [Quick Start](#quick-start)
116
118
  - [Use Cases](#use-cases)
@@ -258,7 +260,7 @@ with SSHClient(config) as client:
258
260
  | **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
259
261
  | **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
260
262
  | **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
261
- | **Batch Ops** | Run commands across any host group, synchronously or asynchronously |
263
+ | **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
262
264
  | **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
263
265
  | **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
264
266
  | **Connection Test** | Test all host SSH connections and report status |
@@ -36,28 +36,30 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
36
36
 
37
37
  ---
38
38
 
39
- ## v2.3.0 Release Highlights
39
+ ## v2.4.0 Release Highlights
40
40
 
41
- v2.3.0 makes blocking SFTP operations timeout-safe, hardens async cancellation cleanup,
42
- and aligns the supported Python range and release tooling. See the
41
+ v2.4.0 adds an opt-in retained-output cap for batch execution and cleans up logging
42
+ handler resources, with default behavior and public result schemas unchanged. See the
43
43
  [full migration notes](./CHANGELOG.md) before upgrading automated callers.
44
44
 
45
- ### SFTP Timeouts & Reliability
45
+ ### Bounded Batch Output
46
46
 
47
- - Blocking SFTP operations now support an inactivity timeout (default: `ConnectionConfig.timeout`, 30 s): uploads, downloads, directory listing, directory creation, removal, and file-info calls accept an optional `timeout` argument.
48
- - The synchronous `SSHClient` applies the timeout to the Paramiko SFTP channel, so a stalled read/write aborts the actual operation and raises `SSHFileTransferError`.
49
- - The asynchronous `AsyncSSHClient` bounds SFTP channel startup with a timeout and uses a progress-based inactivity watchdog to cancel stalled transfers, awaiting the aborted operation and preserving caller cancellation during cleanup.
50
- - Timed-out or cancelled SFTP sessions are closed and discarded, so a stale or desynchronized channel can never be reused.
47
+ - `BatchExecutor` / `AsyncBatchExecutor` accept an optional `max_output_bytes` (default `None`): `None` keeps full `stdout`/`stderr`, while a positive value caps each host's retained streams and appends a `[output truncated: N bytes omitted]` marker.
48
+ - Truncation is deterministic and UTF-8 byte-boundary safe, applies to `stdout` and `stderr` independently, and never changes command success/failure or exit codes.
49
+ - The cap bounds the retained `BatchResult`; the SSH client may still hold full output transiently during execution, so it is not a process-wide RSS limit.
51
50
 
52
- ### Release Engineering
51
+ ### Reliability
53
52
 
54
- - Python 3.9–3.14 are officially supported and CI-tested; `Requires-Python` remains `>=3.9`.
55
- - CI runs release-metadata validation for CHANGELOG.md-only changes.
56
- - Publish builds pin `build==1.5.0` and `twine==7.0.0`.
53
+ - `setup_logging` now closes previous root-logger handlers before removing them, avoiding unclosed-file resource warnings when logging is reconfigured.
54
+ - Connection-pool lifetime wording in the documentation now matches the actual behavior: internal pools are created lazily per host and closed as soon as that host finishes (including its retries).
55
+
56
+ ### Compatibility
57
+
58
+ - Default behavior is unchanged (`max_output_bytes=None` retains full output) and the public `BatchResult` / `BatchHostResult` schemas are unchanged.
57
59
 
58
60
  ## Table of Contents
59
61
 
60
- - [v2.3.0 Release Highlights](#v230-release-highlights)
62
+ - [v2.4.0 Release Highlights](#v240-release-highlights)
61
63
  - [Why Remote CMD?](#why-remote-cmd)
62
64
  - [Quick Start](#quick-start)
63
65
  - [Use Cases](#use-cases)
@@ -205,7 +207,7 @@ with SSHClient(config) as client:
205
207
  | **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
206
208
  | **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
207
209
  | **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
208
- | **Batch Ops** | Run commands across any host group, synchronously or asynchronously |
210
+ | **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
209
211
  | **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
210
212
  | **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
211
213
  | **Connection Test** | Test all host SSH connections and report status |
@@ -48,7 +48,7 @@ Remote CMD - SSH 远程服务器管理工具
48
48
  - 文档: 参见 docs/ 目录
49
49
 
50
50
  Author: Vae-Scrooge
51
- Version: 2.3.0(单一真相源见 remote_cmd._version)
51
+ Version: 2.4.0(单一真相源见 remote_cmd._version)
52
52
  License: MIT
53
53
  """
54
54
 
@@ -9,4 +9,4 @@
9
9
  - ``__version__`` 由 ``remote_cmd/__init__.py`` 导入并继续作为公共 API 导出。
10
10
  """
11
11
 
12
- __version__ = "2.3.0"
12
+ __version__ = "2.4.0"
@@ -19,17 +19,71 @@
19
19
  ... )
20
20
  """
21
21
 
22
- from typing import Union
22
+ from typing import Optional, Union
23
23
 
24
24
  from remote_cmd.core.host import Host
25
25
  from remote_cmd.core.ssh_client import CommandResult, ConnectionConfig
26
26
  from remote_cmd.service._types import BatchHostResult
27
27
  from remote_cmd.service.host_service import HostService
28
+ from remote_cmd.utils.exceptions import ValidationError
29
+
30
+ # 输出截断标记:追加在被截断的输出流末尾(确定性、便于测试与用户识别)
31
+ OUTPUT_TRUNCATION_MARKER = "\n[output truncated: {omitted} bytes omitted]"
28
32
 
29
33
  # host 解析结果:成功返回 Host,失败返回带错误信息的 BatchHostResult
30
34
  ResolveOutcome = Union[Host, BatchHostResult]
31
35
 
32
36
 
37
+ def validate_max_output_bytes(value: Optional[int]) -> Optional[int]:
38
+ """校验批量结果的单流输出上限并原样返回。
39
+
40
+ Args:
41
+ value: ``None``(不截断)或正整数(每台主机每个输出流保留的最大字节数)
42
+
43
+ Returns:
44
+ Optional[int]: 校验后的值
45
+
46
+ Raises:
47
+ ValidationError: 非 None、非正整数(bool 亦被拒绝,避免 True/False
48
+ 被当作 1/0 使用)
49
+ """
50
+ if value is None:
51
+ return None
52
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
53
+ raise ValidationError(
54
+ f"max_output_bytes must be None or a positive integer, got: {value!r}"
55
+ )
56
+ return value
57
+
58
+
59
+ def truncate_output(text: str, max_bytes: Optional[int]) -> str:
60
+ """按 UTF-8 字节上限确定性地截断单个输出流。
61
+
62
+ ``max_bytes`` 为 ``None`` 时原样返回(保持既有行为,绝不静默截断)。
63
+ 超过上限时按 UTF-8 字节切片(丢弃被切断的多字节序列尾部)并追加固定
64
+ 格式的截断标记,标记不计入 ``max_bytes``。截断只影响保留的输出文本,
65
+ 不改变命令的成功/失败语义与退出码。
66
+
67
+ Args:
68
+ text: 已解码的输出文本
69
+ max_bytes: 保留的最大字节数(按 ``text.encode("utf-8")`` 度量),
70
+ 或 ``None`` 表示不截断
71
+
72
+ Returns:
73
+ str: 原文本或截断后的文本(含标记)
74
+ """
75
+ if max_bytes is None or not text:
76
+ return text
77
+ encoded = text.encode("utf-8", errors="replace")
78
+ if len(encoded) <= max_bytes:
79
+ return text
80
+ prefix = encoded[:max_bytes].decode("utf-8", errors="ignore")
81
+ # omitted 按“原始字节数 - 实际保留字节数”计算:UTF-8 边界处的
82
+ # 残字节不会被错误计入保留量
83
+ omitted = len(encoded) - len(prefix.encode("utf-8"))
84
+ return prefix + OUTPUT_TRUNCATION_MARKER.format(omitted=omitted)
85
+
86
+
33
87
  def build_connection_config(host: Host, timeout: int) -> ConnectionConfig:
34
88
  """由主机配置构造 SSH 连接配置。
35
89
 
@@ -92,6 +146,7 @@ def to_host_result(
92
146
  command: str,
93
147
  cmd_result: CommandResult,
94
148
  duration: float,
149
+ max_output_bytes: Optional[int] = None,
95
150
  ) -> BatchHostResult:
96
151
  """由命令结果构造成功的单主机结果。
97
152
 
@@ -100,6 +155,8 @@ def to_host_result(
100
155
  command: 执行的命令
101
156
  cmd_result: 命令执行结果
102
157
  duration: 本次执行耗时(秒)
158
+ max_output_bytes: 每个输出流保留的最大字节数;``None`` 表示
159
+ 保留完整输出(既有行为)。仅影响保留文本,不影响成功/失败
103
160
 
104
161
  Returns:
105
162
  BatchHostResult: 成功结果
@@ -108,8 +165,8 @@ def to_host_result(
108
165
  host=host_name,
109
166
  success=cmd_result.success,
110
167
  command=command,
111
- stdout=cmd_result.stdout,
112
- stderr=cmd_result.stderr,
168
+ stdout=truncate_output(cmd_result.stdout, max_output_bytes),
169
+ stderr=truncate_output(cmd_result.stderr, max_output_bytes),
113
170
  exit_code=cmd_result.exit_code,
114
171
  duration=duration,
115
172
  )
@@ -36,6 +36,7 @@ from remote_cmd.service._host_runner import (
36
36
  build_connection_config,
37
37
  resolve_host_or_error,
38
38
  to_host_result,
39
+ validate_max_output_bytes,
39
40
  )
40
41
  from remote_cmd.service._types import BatchHostResult, BatchResult, ProgressCallback
41
42
  from remote_cmd.service.host_service import HostService
@@ -61,6 +62,11 @@ class AsyncBatchExecutor:
61
62
  内部按主机惰性创建 AsyncConnectionPool,该主机执行结束后
62
63
  立即关闭;内部池上限 1 条连接,整批并发存活连接数受
63
64
  ``max_concurrency`` 约束。
65
+ max_output_bytes: 每台主机每个输出流(stdout/stderr)保留的最大
66
+ 字节数(UTF-8),默认 ``None`` 保留完整输出(既有行为)。
67
+ 设置正整数时输出会被确定性截断并追加 ``[output truncated: N
68
+ bytes omitted]`` 标记;截断不改变命令成功/失败与退出码。
69
+ 仅约束保留的批量结果,客户端在执行期间仍可能短暂持有完整输出
64
70
 
65
71
  连接池所有权约定(与同步 BatchExecutor 一致):
66
72
 
@@ -77,6 +83,7 @@ class AsyncBatchExecutor:
77
83
  max_concurrency: int = 10,
78
84
  command_timeout: int = 30,
79
85
  pool_factory: Optional[PoolFactory] = None,
86
+ max_output_bytes: Optional[int] = None,
80
87
  ) -> None:
81
88
  if max_concurrency < 1:
82
89
  raise ValueError(f"max_concurrency must be >= 1, got: {max_concurrency}")
@@ -86,6 +93,7 @@ class AsyncBatchExecutor:
86
93
  self._max_concurrency = max_concurrency
87
94
  self._command_timeout = command_timeout
88
95
  self._pool_factory = pool_factory
96
+ self._max_output_bytes = validate_max_output_bytes(max_output_bytes)
89
97
 
90
98
  async def execute(
91
99
  self,
@@ -318,7 +326,13 @@ class AsyncBatchExecutor:
318
326
  command,
319
327
  timeout=self._command_timeout,
320
328
  )
321
- return to_host_result(host_name, command, cmd_result, time.time() - start)
329
+ return to_host_result(
330
+ host_name,
331
+ command,
332
+ cmd_result,
333
+ time.time() - start,
334
+ max_output_bytes=self._max_output_bytes,
335
+ )
322
336
  except Exception as e: # noqa: BLE001
323
337
  last_error = str(e)
324
338
  last_duration = time.time() - start
@@ -40,6 +40,7 @@ from remote_cmd.service._host_runner import (
40
40
  build_connection_config,
41
41
  resolve_host_or_error,
42
42
  to_host_result,
43
+ validate_max_output_bytes,
43
44
  )
44
45
  from remote_cmd.service._types import (
45
46
  BatchHostResult,
@@ -84,6 +85,11 @@ class BatchExecutor:
84
85
  立即关闭;内部池 ``max_connections=1``,整批并发存活连接数受
85
86
  ``max_concurrency`` 约束。工厂返回的池类型须与内核匹配
86
87
  (见 PoolFactory 注释)。
88
+ max_output_bytes: 每台主机每个输出流(stdout/stderr)保留的最大
89
+ 字节数(UTF-8),默认 ``None`` 保留完整输出(既有行为)。
90
+ 设置正整数时输出会被确定性截断并追加 ``[output truncated: N
91
+ bytes omitted]`` 标记;截断不改变命令成功/失败与退出码。
92
+ 仅约束保留的批量结果,客户端在执行期间仍可能短暂持有完整输出
87
93
 
88
94
  连接池所有权约定(与 AsyncBatchExecutor 一致):
89
95
 
@@ -105,6 +111,7 @@ class BatchExecutor:
105
111
  command_timeout: int = 30,
106
112
  use_async: bool = False,
107
113
  pool_factory: Optional[PoolFactory] = None,
114
+ max_output_bytes: Optional[int] = None,
108
115
  ) -> None:
109
116
  if max_concurrency < 1:
110
117
  raise ValueError(f"max_concurrency must be >= 1, got: {max_concurrency}")
@@ -115,6 +122,7 @@ class BatchExecutor:
115
122
  self._command_timeout = command_timeout
116
123
  self._use_async = use_async
117
124
  self._pool_factory = pool_factory
125
+ self._max_output_bytes = validate_max_output_bytes(max_output_bytes)
118
126
  # 延迟导入以避免在未安装 asyncssh 的环境下的导入失败
119
127
  # 使用前向引用避免在模块加载期引入 asyncssh 硬依赖(开启 use_async 时才惰性导入)
120
128
  self._async_executor: Optional["AsyncBatchExecutor"] = None # noqa: UP037
@@ -126,6 +134,7 @@ class BatchExecutor:
126
134
  max_concurrency=max_concurrency,
127
135
  command_timeout=command_timeout,
128
136
  pool_factory=pool_factory,
137
+ max_output_bytes=self._max_output_bytes,
129
138
  )
130
139
 
131
140
  def execute(
@@ -458,7 +467,13 @@ class BatchExecutor:
458
467
  # 连接池模式:复用主机连接,避免每次操作握手
459
468
  with pool.acquire_context() as client:
460
469
  cmd_result = client.execute(command, timeout=self._command_timeout)
461
- return to_host_result(host_name, command, cmd_result, time.time() - start)
470
+ return to_host_result(
471
+ host_name,
472
+ command,
473
+ cmd_result,
474
+ time.time() - start,
475
+ max_output_bytes=self._max_output_bytes,
476
+ )
462
477
 
463
478
  # 非连接池路径:try/finally 确保即使 execute() 抛异常,
464
479
  # disconnect() 也会执行,避免 SSH 连接泄漏
@@ -469,7 +484,13 @@ class BatchExecutor:
469
484
  finally:
470
485
  client.disconnect()
471
486
 
472
- return to_host_result(host_name, command, cmd_result, time.time() - start)
487
+ return to_host_result(
488
+ host_name,
489
+ command,
490
+ cmd_result,
491
+ time.time() - start,
492
+ max_output_bytes=self._max_output_bytes,
493
+ )
473
494
 
474
495
  except Exception as e: # noqa: BLE001
475
496
  duration = time.time() - start
@@ -12,6 +12,7 @@
12
12
  - 错误消息中的密码使用 [REDACTED] 替换
13
13
  """
14
14
 
15
+ import contextlib
15
16
  import logging
16
17
  import re
17
18
  import sys
@@ -119,8 +120,12 @@ def setup_logging(
119
120
  root_logger = logging.getLogger()
120
121
  root_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
121
122
 
122
- # 清除已有处理器
123
- root_logger.handlers.clear()
123
+ # 清除已有处理器:先关闭再移除,避免遗留打开的文件句柄
124
+ # (例如 RotatingFileHandler 的日志文件;重复配置会触发 ResourceWarning)
125
+ for handler in list(root_logger.handlers):
126
+ root_logger.removeHandler(handler)
127
+ with contextlib.suppress(Exception):
128
+ handler.close()
124
129
 
125
130
  # 创建格式化器
126
131
  if structured:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remote_cmd_manager
3
- Version: 2.3.0
3
+ Version: 2.4.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,28 +89,30 @@ pip install remote_cmd_manager && remote-cmd host add web-01 192.168.1.10 ubuntu
89
89
 
90
90
  ---
91
91
 
92
- ## v2.3.0 Release Highlights
92
+ ## v2.4.0 Release Highlights
93
93
 
94
- v2.3.0 makes blocking SFTP operations timeout-safe, hardens async cancellation cleanup,
95
- and aligns the supported Python range and release tooling. See the
94
+ v2.4.0 adds an opt-in retained-output cap for batch execution and cleans up logging
95
+ handler resources, with default behavior and public result schemas unchanged. See the
96
96
  [full migration notes](./CHANGELOG.md) before upgrading automated callers.
97
97
 
98
- ### SFTP Timeouts & Reliability
98
+ ### Bounded Batch Output
99
99
 
100
- - Blocking SFTP operations now support an inactivity timeout (default: `ConnectionConfig.timeout`, 30 s): uploads, downloads, directory listing, directory creation, removal, and file-info calls accept an optional `timeout` argument.
101
- - The synchronous `SSHClient` applies the timeout to the Paramiko SFTP channel, so a stalled read/write aborts the actual operation and raises `SSHFileTransferError`.
102
- - The asynchronous `AsyncSSHClient` bounds SFTP channel startup with a timeout and uses a progress-based inactivity watchdog to cancel stalled transfers, awaiting the aborted operation and preserving caller cancellation during cleanup.
103
- - Timed-out or cancelled SFTP sessions are closed and discarded, so a stale or desynchronized channel can never be reused.
100
+ - `BatchExecutor` / `AsyncBatchExecutor` accept an optional `max_output_bytes` (default `None`): `None` keeps full `stdout`/`stderr`, while a positive value caps each host's retained streams and appends a `[output truncated: N bytes omitted]` marker.
101
+ - Truncation is deterministic and UTF-8 byte-boundary safe, applies to `stdout` and `stderr` independently, and never changes command success/failure or exit codes.
102
+ - The cap bounds the retained `BatchResult`; the SSH client may still hold full output transiently during execution, so it is not a process-wide RSS limit.
104
103
 
105
- ### Release Engineering
104
+ ### Reliability
106
105
 
107
- - Python 3.9–3.14 are officially supported and CI-tested; `Requires-Python` remains `>=3.9`.
108
- - CI runs release-metadata validation for CHANGELOG.md-only changes.
109
- - Publish builds pin `build==1.5.0` and `twine==7.0.0`.
106
+ - `setup_logging` now closes previous root-logger handlers before removing them, avoiding unclosed-file resource warnings when logging is reconfigured.
107
+ - Connection-pool lifetime wording in the documentation now matches the actual behavior: internal pools are created lazily per host and closed as soon as that host finishes (including its retries).
108
+
109
+ ### Compatibility
110
+
111
+ - Default behavior is unchanged (`max_output_bytes=None` retains full output) and the public `BatchResult` / `BatchHostResult` schemas are unchanged.
110
112
 
111
113
  ## Table of Contents
112
114
 
113
- - [v2.3.0 Release Highlights](#v230-release-highlights)
115
+ - [v2.4.0 Release Highlights](#v240-release-highlights)
114
116
  - [Why Remote CMD?](#why-remote-cmd)
115
117
  - [Quick Start](#quick-start)
116
118
  - [Use Cases](#use-cases)
@@ -258,7 +260,7 @@ with SSHClient(config) as client:
258
260
  | **File Transfer** | Upload/download via SFTP (`remote-cmd upload/download`) |
259
261
  | **Host Management** | CRUD with pluggable JSON or **SQLite** persistence |
260
262
  | **Tag System** | Filter hosts by tag (e.g., `production`, `web`, `db`) |
261
- | **Batch Ops** | Run commands across any host group, synchronously or asynchronously |
263
+ | **Batch Ops** | Run commands across any host group, synchronously or asynchronously; optional per-host retained-output cap (`max_output_bytes`) |
262
264
  | **Async Kernel** | `AsyncSSHClient` / `AsyncConnectionPool` / `AsyncBatchExecutor` via the `[async]` extra |
263
265
  | **Task Runner** | Track and schedule long-running remote tasks with statuses (`TaskRunner`) |
264
266
  | **Connection Test** | Test all host SSH connections and report status |
@@ -11,6 +11,7 @@ from remote_cmd.core.async_connection_pool import AsyncConnectionPool
11
11
  from remote_cmd.core.async_ssh_client import AsyncSSHClient
12
12
  from remote_cmd.core.host import Host
13
13
  from remote_cmd.core.ssh_client import CommandResult, ConnectionConfig
14
+ from remote_cmd.service._host_runner import OUTPUT_TRUNCATION_MARKER
14
15
  from remote_cmd.service.async_batch_executor import AsyncBatchExecutor
15
16
  from remote_cmd.service.batch_executor import BatchExecutor, BatchHostResult
16
17
  from remote_cmd.utils.exceptions import (
@@ -18,6 +19,7 @@ from remote_cmd.utils.exceptions import (
18
19
  PoolClosedError,
19
20
  SSHAuthenticationError,
20
21
  SSHTimeoutError,
22
+ ValidationError,
21
23
  )
22
24
 
23
25
  # ============================================================================
@@ -940,3 +942,119 @@ class TestAsyncBatchExecutorInternalPoolLifecycle:
940
942
  assert FlakyAsyncClient.created[0].calls == 3
941
943
  assert pools[0]._closed
942
944
  assert not FlakyAsyncClient.created[0].is_connected()
945
+
946
+
947
+ # ============================================================================
948
+ # v2.4:max_output_bytes 保留输出上限(异步语义与同步对齐)
949
+ # ============================================================================
950
+
951
+
952
+ def _marker(omitted: int) -> str:
953
+ return OUTPUT_TRUNCATION_MARKER.format(omitted=omitted)
954
+
955
+
956
+ class TestAsyncBatchExecutorOutputCap:
957
+ """异步执行器 max_output_bytes 语义(默认 None = 完整输出)"""
958
+
959
+ @pytest.mark.asyncio
960
+ async def test_default_none_retains_full_output(self, mock_async_client_class):
961
+ cm, instance = mock_async_client_class
962
+ payload = "x" * 8192
963
+ instance.execute = AsyncMock(return_value=CommandResult("uptime", payload, payload, 0))
964
+ host = Host(name="srv1", hostname="10.0.0.1", username="admin")
965
+ ex = AsyncBatchExecutor(host_service=make_mock_service([host]))
966
+ result = await ex.execute(["srv1"], "uptime")
967
+ host_result = result.results["srv1"]
968
+ assert host_result.stdout == payload
969
+ assert host_result.stderr == payload
970
+ assert "[output truncated" not in host_result.stdout
971
+
972
+ @pytest.mark.asyncio
973
+ async def test_positive_cap_truncates_stdout_and_stderr(self, mock_async_client_class):
974
+ cm, instance = mock_async_client_class
975
+ instance.execute = AsyncMock(return_value=CommandResult("uptime", "o" * 500, "e" * 700, 0))
976
+ host = Host(name="srv1", hostname="10.0.0.1", username="admin")
977
+ ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=100)
978
+ result = await ex.execute(["srv1"], "uptime")
979
+ host_result = result.results["srv1"]
980
+ assert host_result.stdout == "o" * 100 + _marker(400)
981
+ assert host_result.stderr == "e" * 100 + _marker(600)
982
+ assert host_result.success is True
983
+
984
+ @pytest.mark.asyncio
985
+ async def test_below_and_exact_boundary_unchanged(self, mock_async_client_class):
986
+ cm, instance = mock_async_client_class
987
+ instance.execute = AsyncMock(return_value=CommandResult("uptime", "exact", "small", 0))
988
+ host = Host(name="srv1", hostname="10.0.0.1", username="admin")
989
+
990
+ ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=5)
991
+ result = await ex.execute(["srv1"], "uptime")
992
+ assert result.results["srv1"].stdout == "exact"
993
+ assert result.results["srv1"].stderr == "small"
994
+
995
+ ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=64)
996
+ result = await ex.execute(["srv1"], "uptime")
997
+ assert result.results["srv1"].stdout == "exact"
998
+
999
+ @pytest.mark.asyncio
1000
+ async def test_cap_preserves_failure_semantics(self, mock_async_client_class):
1001
+ cm, instance = mock_async_client_class
1002
+ instance.execute = AsyncMock(
1003
+ return_value=CommandResult("uptime", "x" * 1000, "e" * 1000, 7)
1004
+ )
1005
+ host = Host(name="srv1", hostname="10.0.0.1", username="admin")
1006
+ ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=16)
1007
+ result = await ex.execute(["srv1"], "uptime")
1008
+ host_result = result.results["srv1"]
1009
+ assert host_result.success is False
1010
+ assert host_result.exit_code == 7
1011
+ assert host_result.stdout.startswith("x" * 16)
1012
+ assert host_result.stderr.startswith("e" * 16)
1013
+
1014
+ @pytest.mark.parametrize("bad", [0, -1, True, False, 1.5, "64"])
1015
+ def test_invalid_max_output_bytes_rejected(self, bad):
1016
+ with pytest.raises(ValidationError, match="max_output_bytes"):
1017
+ AsyncBatchExecutor(host_service=MagicMock(), max_output_bytes=bad)
1018
+
1019
+ @pytest.mark.asyncio
1020
+ async def test_large_synthetic_output_bounded(self, mock_async_client_class):
1021
+ cm, instance = mock_async_client_class
1022
+ chunk = "z" * (256 * 1024)
1023
+ instance.execute = AsyncMock(return_value=CommandResult("uptime", chunk, chunk, 0))
1024
+ hosts = [
1025
+ Host(name=f"srv{i}", hostname=f"10.0.0.{i + 1}", username="admin") for i in range(10)
1026
+ ]
1027
+ ex = AsyncBatchExecutor(host_service=make_mock_service(hosts), max_output_bytes=4096)
1028
+ result = await ex.execute([h.name for h in hosts], "uptime")
1029
+ assert result.success == 10
1030
+ retained = sum(len(r.stdout) + len(r.stderr) for r in result.results.values())
1031
+ assert retained <= 10 * 2 * (4096 + 64)
1032
+ assert retained < 10 * 2 * len(chunk) // 10
1033
+
1034
+
1035
+ class TestOutputCapParity:
1036
+ @pytest.mark.asyncio
1037
+ async def test_sync_and_async_truncation_equivalent(self, mock_async_client_class):
1038
+ cm, instance = mock_async_client_class
1039
+ stdout = ("o" * 5000) + ("é" * 100)
1040
+ stderr = "e" * 3000
1041
+ instance.execute = AsyncMock(return_value=CommandResult("uptime", stdout, stderr, 7))
1042
+ host = Host(name="srv1", hostname="10.0.0.1", username="admin")
1043
+
1044
+ async_ex = AsyncBatchExecutor(host_service=make_mock_service([host]), max_output_bytes=257)
1045
+ async_result = await async_ex.execute(["srv1"], "uptime")
1046
+
1047
+ with patch("remote_cmd.service.batch_executor.SSHClient") as mock_cls:
1048
+ inst = MagicMock()
1049
+ mock_cls.return_value = inst
1050
+ inst.execute.return_value = CommandResult("uptime", stdout, stderr, 7)
1051
+ sync_ex = BatchExecutor(host_service=make_mock_service([host]), max_output_bytes=257)
1052
+ sync_result = sync_ex.execute(["srv1"], "uptime")
1053
+
1054
+ sync_host = sync_result.results["srv1"]
1055
+ async_host = async_result.results["srv1"]
1056
+ assert sync_host.stdout == async_host.stdout
1057
+ assert sync_host.stderr == async_host.stderr
1058
+ assert sync_host.exit_code == async_host.exit_code == 7
1059
+ assert sync_host.success is async_host.success is False
1060
+ assert "[output truncated" in sync_host.stdout
@@ -8,8 +8,13 @@ import pytest
8
8
  from remote_cmd.core.host import Host
9
9
  from remote_cmd.core.ssh_client import CommandResult
10
10
  from remote_cmd.core.sync_connection_pool import SyncConnectionPool
11
+ from remote_cmd.service._host_runner import OUTPUT_TRUNCATION_MARKER
11
12
  from remote_cmd.service.batch_executor import BatchExecutor, BatchHostResult, BatchResult
12
- from remote_cmd.utils.exceptions import CredentialError, SSHAuthenticationError
13
+ from remote_cmd.utils.exceptions import (
14
+ CredentialError,
15
+ SSHAuthenticationError,
16
+ ValidationError,
17
+ )
13
18
 
14
19
 
15
20
  def make_mock_service(hosts: list):
@@ -847,3 +852,97 @@ class TestBatchExecutorInternalPoolLifecycle:
847
852
  assert AuthFailClient.created[0].calls == 1
848
853
  assert pools[0]._closed
849
854
  assert not AuthFailClient.created[0].is_connected()
855
+
856
+
857
+ # ============================================================================
858
+ # v2.4:max_output_bytes 保留输出上限
859
+ # ============================================================================
860
+
861
+
862
+ def _marker(omitted: int) -> str:
863
+ return OUTPUT_TRUNCATION_MARKER.format(omitted=omitted)
864
+
865
+
866
+ class TestBatchExecutorOutputCap:
867
+ """同步执行器 max_output_bytes 语义(默认 None = 完整输出)"""
868
+
869
+ @staticmethod
870
+ def _run(stdout="", stderr="", exit_code=0, max_output_bytes=None, hosts=1):
871
+ host_list = [
872
+ Host(name=f"srv{i}", hostname=f"10.0.0.{i + 1}", username="admin") for i in range(hosts)
873
+ ]
874
+ service = make_mock_service(host_list)
875
+ with patch("remote_cmd.service.batch_executor.SSHClient") as mock_cls:
876
+ inst = MagicMock()
877
+ mock_cls.return_value = inst
878
+ inst.execute.return_value = CommandResult("true", stdout, stderr, exit_code)
879
+ executor = BatchExecutor(host_service=service, max_output_bytes=max_output_bytes)
880
+ return executor.execute([h.name for h in host_list], "true")
881
+
882
+ def test_default_none_retains_full_output(self):
883
+ payload = "x" * 8192
884
+ result = self._run(stdout=payload, stderr=payload)
885
+ host_result = result.results["srv0"]
886
+ assert host_result.stdout == payload
887
+ assert host_result.stderr == payload
888
+ assert "[output truncated" not in host_result.stdout
889
+
890
+ def test_positive_cap_truncates_stdout(self):
891
+ result = self._run(stdout="x" * 1000, max_output_bytes=64)
892
+ host_result = result.results["srv0"]
893
+ assert host_result.stdout == "x" * 64 + _marker(936)
894
+ assert host_result.stderr == ""
895
+ assert host_result.success is True
896
+
897
+ def test_positive_cap_truncates_stderr(self):
898
+ result = self._run(stderr="e" * 500, max_output_bytes=100)
899
+ host_result = result.results["srv0"]
900
+ assert host_result.stderr == "e" * 100 + _marker(400)
901
+ assert host_result.stdout == ""
902
+
903
+ def test_output_below_limit_unchanged(self):
904
+ result = self._run(stdout="small", stderr="tiny", max_output_bytes=1024)
905
+ host_result = result.results["srv0"]
906
+ assert host_result.stdout == "small"
907
+ assert host_result.stderr == "tiny"
908
+ assert "[output truncated" not in host_result.stdout + host_result.stderr
909
+
910
+ def test_exact_boundary_unchanged(self):
911
+ payload = "x" * 64
912
+ result = self._run(stdout=payload, stderr=payload, max_output_bytes=64)
913
+ host_result = result.results["srv0"]
914
+ assert host_result.stdout == payload
915
+ assert host_result.stderr == payload
916
+
917
+ def test_unicode_boundary_not_split(self):
918
+ # "é" 为 2 字节;上限 5 字节时只能完整保留 2 个字符(保留 4 字节)
919
+ result = self._run(stdout="é" * 10, max_output_bytes=5)
920
+ out = result.results["srv0"].stdout
921
+ assert out.startswith("é" * 2)
922
+ assert "é" * 3 not in out
923
+ assert "\ufffd" not in out
924
+ assert out.endswith(_marker(20 - 4))
925
+
926
+ def test_cap_preserves_success_and_failure_semantics(self):
927
+ ok = self._run(stdout="x" * 1000, exit_code=0, max_output_bytes=10)
928
+ assert ok.results["srv0"].success is True
929
+ assert ok.results["srv0"].exit_code == 0
930
+
931
+ bad = self._run(stdout="x" * 1000, exit_code=1, max_output_bytes=10)
932
+ assert bad.results["srv0"].success is False
933
+ assert bad.results["srv0"].exit_code == 1
934
+
935
+ @pytest.mark.parametrize("bad", [0, -1, True, False, 1.5, "64"])
936
+ def test_invalid_max_output_bytes_rejected(self, bad):
937
+ with pytest.raises(ValidationError, match="max_output_bytes"):
938
+ BatchExecutor(host_service=MagicMock(), max_output_bytes=bad)
939
+
940
+ @patch("remote_cmd.service.batch_executor.SSHClient")
941
+ def test_large_synthetic_output_bounded(self, _mock_ssh_class):
942
+ chunk = "z" * (256 * 1024)
943
+ result = self._run(stdout=chunk, stderr=chunk, hosts=20, max_output_bytes=4096)
944
+ assert result.success == 20
945
+ retained = sum(len(r.stdout) + len(r.stderr) for r in result.results.values())
946
+ marker_allowance = 64 # 标记本身很小,给足空间
947
+ assert retained <= 20 * 2 * (4096 + marker_allowance)
948
+ assert retained < 20 * 2 * len(chunk) // 10
@@ -194,6 +194,20 @@ class TestSetupLogging:
194
194
  second = set(logging.getLogger().handlers)
195
195
  assert not (first & second)
196
196
 
197
+ def test_previous_file_handler_closed_before_removal(self, tmp_path):
198
+ """测试:重新配置日志时旧的文件处理器被关闭(无未关闭句柄)"""
199
+ root = logging.getLogger()
200
+ try:
201
+ setup_logging(log_file=str(tmp_path / "a.log"))
202
+ first = [h for h in root.handlers if isinstance(h, logging.FileHandler)][-1]
203
+ assert first.stream is not None
204
+
205
+ setup_logging(log_file=str(tmp_path / "b.log"))
206
+ assert first.stream is None # close() 释放底层文件流
207
+ assert first not in root.handlers
208
+ finally:
209
+ setup_logging()
210
+
197
211
 
198
212
  class TestLoggerAdapter:
199
213
  """带上下文日志适配器测试"""