mcp-yieldshell 1.1.0__tar.gz → 1.1.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.
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/PKG-INFO +10 -5
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/README.md +9 -4
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/pyproject.toml +1 -1
- mcp_yieldshell-1.1.2/src/mcp_yieldshell/execution/containment.py +71 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/managed.py +5 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/manager.py +130 -20
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/ring_buffer.py +7 -1
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_lifecycle_hardening.py +325 -1
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_release.py +69 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_ring_buffer.py +3 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/.gitignore +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/__init__.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/__main__.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/config.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/__init__.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/spawn.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/policy.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/security.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/server.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/side_effects.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/types.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/__init__.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_config.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_integration.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_protocol.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_security.py +0 -0
- {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_side_effects.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-yieldshell
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: A drop-in shell MCP that auto-yields long-running commands into managed background executions.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: mcp<2,>=1.9.0
|
|
@@ -227,7 +227,7 @@ Read stdout and/or stderr from a running or completed execution.
|
|
|
227
227
|
* **Returns**:
|
|
228
228
|
* `execution_id`, `status`, `exit_code`, `signal`, and `stdin_error` when initial input delivery failed. `stdout` and `stderr` text are included based on the `streams` filter — `"both"` includes both, `"stdout"` includes only `stdout`, and `"stderr"` includes only `stderr`.
|
|
229
229
|
* Cursor fields: `start_seq` (position of the first byte returned), `next_seq` (cursor to use in the next `since_seq` read), and `latest_seq` (how far output has advanced overall — when it exceeds `next_seq`, more is waiting).
|
|
230
|
-
* Withheld-output flags: `capped` means more output is available and reading again continues from `next_seq`; `evicted` means output fell out of the ring buffer
|
|
230
|
+
* Withheld-output flags: `capped` means more output is available and reading again continues from `next_seq`; `evicted` means output became unrecoverable before it could be read. This normally means it fell out of the ring buffer; it can also mean retention concurrently removed the completed record. When either is set, a `hint` field names the concrete follow-up action.
|
|
231
231
|
|
|
232
232
|
### `write`
|
|
233
233
|
Write text input to the standard input (`stdin`) of a running execution.
|
|
@@ -337,7 +337,7 @@ To avoid sending duplicate data over the MCP protocol (which can consume context
|
|
|
337
337
|
6. Requests that pass `since_seq`, pass `tail_lines`, or narrow `streams` are treated as **out-of-band**: they neither consult nor advance the server-side cursor. This means a peek at the tail or at stderr cannot silently skip output the polling stream has not seen yet.
|
|
338
338
|
7. Two distinct flags report output the response did not carry, and they call for different responses:
|
|
339
339
|
* `capped` — the response cap stopped the read short. The remainder is still buffered; read again to continue, or pass the returned `next_seq` as `since_seq`. The `hint` field spells out the call.
|
|
340
|
-
* `evicted` — output exceeded the ring buffer's capacity before it was read
|
|
340
|
+
* `evicted` — output is permanently gone. This normally means it exceeded the ring buffer's capacity before it was read; a completed record reaped concurrently with a capped response is also reported as unrecoverable instead of offering a dangling follow-up read. Retrying cannot recover it; poll more often, raise `YIELDSHELL_MAX_BUFFER_BYTES`, or increase terminal retention as indicated by the response hint.
|
|
341
341
|
8. `latest_seq` reports how far output has advanced overall. `latest_seq > next_seq` means more is already waiting; `latest_seq == next_seq` means the caller is fully caught up.
|
|
342
342
|
9. Because retention (`YIELDSHELL_MAX_BUFFER_BYTES`, 256 KB per stream) is much larger than the per-response cap (`YIELDSHELL_MAX_OUTPUT_BYTES`, 20 KB), a cursor normally stays resolvable across polling gaps.
|
|
343
343
|
|
|
@@ -383,6 +383,7 @@ Configure the server by setting these environment variables prior to launch:
|
|
|
383
383
|
* **Agent Process Termination**: `KILLS_AGENT_PROCESS` covers commands that may terminate the MCP client, agent, or related process running the agent workflow (e.g., `kill` commands targeting the agent PID, or commands that cause the agent to exit). This is distinct from `STOPS_OR_RESTARTS_SERVICES`, which covers OS-level services. Blocked by default; operators can override.
|
|
384
384
|
* **Path Validation**: CWD path verification resolves traversal and symlinks, then requires the target to be an allowed root itself or a true descendant. Lexically similar siblings and symlink escapes are rejected before spawn.
|
|
385
385
|
* **Additive Environments**: The `env` argument overlays existing env parameters. It merges with the parent process environment instead of completely replacing it, protecting critical OS vars.
|
|
386
|
+
* **Linux Descendant Discovery**: Each Linux execution receives a random internal environment token. If a descendant creates a new session or process group, procfs discovery keeps ordinary daemonized descendants associated with the execution so `stop`, runtime timeout, and shutdown can signal them too. This is lifecycle containment, not a security sandbox: deliberately replacing the inherited environment can evade discovery.
|
|
386
387
|
* **Opt-in Best-effort Redaction**: Redaction is disabled unless `YIELDSHELL_REDACT_ENV_REGEX` is configured. When enabled, matching environment values are snapshotted at startup, ordered longest first, and values shorter than 8 characters are excluded to avoid corrupting ordinary output. Matching values supplied through an `env` overlay are added for that process. Redaction is applied while streams are drained, including across subprocess chunks and incremental read pages. Restart the server to refresh the parent-environment snapshot after changes. Redaction does not remove variables from subprocess environments, and secrets not selected by the regex or printed through transformed formats might not be caught.
|
|
387
388
|
|
|
388
389
|
## Lifecycle and Compatibility
|
|
@@ -391,7 +392,10 @@ YieldShell gives graceful termination 10 seconds before force-killing, waits up
|
|
|
391
392
|
to 5 seconds for a POSIX process group to disappear, and allows up to 3 seconds
|
|
392
393
|
for final stream draining. On stdio server shutdown, all live managed
|
|
393
394
|
executions are terminated concurrently using the same bounded graceful/forced
|
|
394
|
-
policy. Already-terminal records are not changed by shutdown.
|
|
395
|
+
policy. Already-terminal records are not changed by shutdown. If a process
|
|
396
|
+
group survives force-kill or a pending spawn ignores cancellation, shutdown
|
|
397
|
+
fails visibly, remains incomplete, and can be retried instead of silently
|
|
398
|
+
orphaning live work.
|
|
395
399
|
|
|
396
400
|
`execution_id` is an opaque, server-generated string. Clients must pass it
|
|
397
401
|
through unchanged rather than parsing or pattern-matching it. Its current
|
|
@@ -421,7 +425,8 @@ unchanged to `read`, `write`, `wait`, and `stop`. Calls that send the OS
|
|
|
421
425
|
|
|
422
426
|
## Platform Support
|
|
423
427
|
|
|
424
|
-
* **
|
|
428
|
+
* **Linux**: Fully supported. Commands start in distinct sessions (`start_new_session=True`), and `stop`, runtime timeout, and shutdown target the process group. Procfs token discovery also tracks ordinary descendants that explicitly create another session or process group.
|
|
429
|
+
* **macOS**: Supported with POSIX process-group controls. Descendants that explicitly leave the managed process group are outside that mechanism and may persist.
|
|
425
430
|
* **Windows**: Supported with best-effort process controls. Windows lacks native POSIX process-group signals, so `stop`, `timeout_ms`, and server shutdown act on the primary process; child subprocesses might persist if they do not exit cleanly.
|
|
426
431
|
|
|
427
432
|
---
|
|
@@ -219,7 +219,7 @@ Read stdout and/or stderr from a running or completed execution.
|
|
|
219
219
|
* **Returns**:
|
|
220
220
|
* `execution_id`, `status`, `exit_code`, `signal`, and `stdin_error` when initial input delivery failed. `stdout` and `stderr` text are included based on the `streams` filter — `"both"` includes both, `"stdout"` includes only `stdout`, and `"stderr"` includes only `stderr`.
|
|
221
221
|
* Cursor fields: `start_seq` (position of the first byte returned), `next_seq` (cursor to use in the next `since_seq` read), and `latest_seq` (how far output has advanced overall — when it exceeds `next_seq`, more is waiting).
|
|
222
|
-
* Withheld-output flags: `capped` means more output is available and reading again continues from `next_seq`; `evicted` means output fell out of the ring buffer
|
|
222
|
+
* Withheld-output flags: `capped` means more output is available and reading again continues from `next_seq`; `evicted` means output became unrecoverable before it could be read. This normally means it fell out of the ring buffer; it can also mean retention concurrently removed the completed record. When either is set, a `hint` field names the concrete follow-up action.
|
|
223
223
|
|
|
224
224
|
### `write`
|
|
225
225
|
Write text input to the standard input (`stdin`) of a running execution.
|
|
@@ -329,7 +329,7 @@ To avoid sending duplicate data over the MCP protocol (which can consume context
|
|
|
329
329
|
6. Requests that pass `since_seq`, pass `tail_lines`, or narrow `streams` are treated as **out-of-band**: they neither consult nor advance the server-side cursor. This means a peek at the tail or at stderr cannot silently skip output the polling stream has not seen yet.
|
|
330
330
|
7. Two distinct flags report output the response did not carry, and they call for different responses:
|
|
331
331
|
* `capped` — the response cap stopped the read short. The remainder is still buffered; read again to continue, or pass the returned `next_seq` as `since_seq`. The `hint` field spells out the call.
|
|
332
|
-
* `evicted` — output exceeded the ring buffer's capacity before it was read
|
|
332
|
+
* `evicted` — output is permanently gone. This normally means it exceeded the ring buffer's capacity before it was read; a completed record reaped concurrently with a capped response is also reported as unrecoverable instead of offering a dangling follow-up read. Retrying cannot recover it; poll more often, raise `YIELDSHELL_MAX_BUFFER_BYTES`, or increase terminal retention as indicated by the response hint.
|
|
333
333
|
8. `latest_seq` reports how far output has advanced overall. `latest_seq > next_seq` means more is already waiting; `latest_seq == next_seq` means the caller is fully caught up.
|
|
334
334
|
9. Because retention (`YIELDSHELL_MAX_BUFFER_BYTES`, 256 KB per stream) is much larger than the per-response cap (`YIELDSHELL_MAX_OUTPUT_BYTES`, 20 KB), a cursor normally stays resolvable across polling gaps.
|
|
335
335
|
|
|
@@ -375,6 +375,7 @@ Configure the server by setting these environment variables prior to launch:
|
|
|
375
375
|
* **Agent Process Termination**: `KILLS_AGENT_PROCESS` covers commands that may terminate the MCP client, agent, or related process running the agent workflow (e.g., `kill` commands targeting the agent PID, or commands that cause the agent to exit). This is distinct from `STOPS_OR_RESTARTS_SERVICES`, which covers OS-level services. Blocked by default; operators can override.
|
|
376
376
|
* **Path Validation**: CWD path verification resolves traversal and symlinks, then requires the target to be an allowed root itself or a true descendant. Lexically similar siblings and symlink escapes are rejected before spawn.
|
|
377
377
|
* **Additive Environments**: The `env` argument overlays existing env parameters. It merges with the parent process environment instead of completely replacing it, protecting critical OS vars.
|
|
378
|
+
* **Linux Descendant Discovery**: Each Linux execution receives a random internal environment token. If a descendant creates a new session or process group, procfs discovery keeps ordinary daemonized descendants associated with the execution so `stop`, runtime timeout, and shutdown can signal them too. This is lifecycle containment, not a security sandbox: deliberately replacing the inherited environment can evade discovery.
|
|
378
379
|
* **Opt-in Best-effort Redaction**: Redaction is disabled unless `YIELDSHELL_REDACT_ENV_REGEX` is configured. When enabled, matching environment values are snapshotted at startup, ordered longest first, and values shorter than 8 characters are excluded to avoid corrupting ordinary output. Matching values supplied through an `env` overlay are added for that process. Redaction is applied while streams are drained, including across subprocess chunks and incremental read pages. Restart the server to refresh the parent-environment snapshot after changes. Redaction does not remove variables from subprocess environments, and secrets not selected by the regex or printed through transformed formats might not be caught.
|
|
379
380
|
|
|
380
381
|
## Lifecycle and Compatibility
|
|
@@ -383,7 +384,10 @@ YieldShell gives graceful termination 10 seconds before force-killing, waits up
|
|
|
383
384
|
to 5 seconds for a POSIX process group to disappear, and allows up to 3 seconds
|
|
384
385
|
for final stream draining. On stdio server shutdown, all live managed
|
|
385
386
|
executions are terminated concurrently using the same bounded graceful/forced
|
|
386
|
-
policy. Already-terminal records are not changed by shutdown.
|
|
387
|
+
policy. Already-terminal records are not changed by shutdown. If a process
|
|
388
|
+
group survives force-kill or a pending spawn ignores cancellation, shutdown
|
|
389
|
+
fails visibly, remains incomplete, and can be retried instead of silently
|
|
390
|
+
orphaning live work.
|
|
387
391
|
|
|
388
392
|
`execution_id` is an opaque, server-generated string. Clients must pass it
|
|
389
393
|
through unchanged rather than parsing or pattern-matching it. Its current
|
|
@@ -413,7 +417,8 @@ unchanged to `read`, `write`, `wait`, and `stop`. Calls that send the OS
|
|
|
413
417
|
|
|
414
418
|
## Platform Support
|
|
415
419
|
|
|
416
|
-
* **
|
|
420
|
+
* **Linux**: Fully supported. Commands start in distinct sessions (`start_new_session=True`), and `stop`, runtime timeout, and shutdown target the process group. Procfs token discovery also tracks ordinary descendants that explicitly create another session or process group.
|
|
421
|
+
* **macOS**: Supported with POSIX process-group controls. Descendants that explicitly leave the managed process group are outside that mechanism and may persist.
|
|
417
422
|
* **Windows**: Supported with best-effort process controls. Windows lacks native POSIX process-group signals, so `stop`, `timeout_ms`, and server shutdown act on the primary process; child subprocesses might persist if they do not exit cleanly.
|
|
418
423
|
|
|
419
424
|
---
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Linux containment helpers for descendants that leave their process group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
EXECUTION_TOKEN_ENV = "_MCP_YIELDSHELL_EXECUTION_TOKEN"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def new_execution_token() -> str | None:
|
|
14
|
+
"""Return a process-discovery token on platforms that expose procfs."""
|
|
15
|
+
if not sys.platform.startswith("linux") or not os.path.isdir("/proc"):
|
|
16
|
+
return None
|
|
17
|
+
return secrets.token_hex(16)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def contained_process_ids(token: str | None) -> set[int]:
|
|
21
|
+
"""Find live processes whose initial environment carries ``token``."""
|
|
22
|
+
if token is None:
|
|
23
|
+
return set()
|
|
24
|
+
expected = f"{EXECUTION_TOKEN_ENV}={token}".encode()
|
|
25
|
+
found: set[int] = set()
|
|
26
|
+
try:
|
|
27
|
+
entries = os.scandir("/proc")
|
|
28
|
+
except OSError:
|
|
29
|
+
return found
|
|
30
|
+
with entries:
|
|
31
|
+
for entry in entries:
|
|
32
|
+
if not entry.name.isdecimal():
|
|
33
|
+
continue
|
|
34
|
+
pid = int(entry.name)
|
|
35
|
+
if pid == os.getpid():
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
with open(f"/proc/{pid}/environ", "rb") as environ_file:
|
|
39
|
+
environment = environ_file.read()
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
continue
|
|
42
|
+
if expected in environment.split(b"\0"):
|
|
43
|
+
found.add(pid)
|
|
44
|
+
return found
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def contained_processes_exist(token: str | None) -> bool:
|
|
48
|
+
"""Return whether any process carrying ``token`` is still observable."""
|
|
49
|
+
return bool(contained_process_ids(token))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def signal_contained_processes(
|
|
53
|
+
token: str | None,
|
|
54
|
+
sig: signal.Signals,
|
|
55
|
+
*,
|
|
56
|
+
exclude_process_group_id: int | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Signal tagged processes outside the original managed process group."""
|
|
59
|
+
for pid in contained_process_ids(token):
|
|
60
|
+
if exclude_process_group_id is not None:
|
|
61
|
+
try:
|
|
62
|
+
if os.getpgid(pid) == exclude_process_group_id:
|
|
63
|
+
continue
|
|
64
|
+
except ProcessLookupError:
|
|
65
|
+
continue
|
|
66
|
+
except PermissionError:
|
|
67
|
+
pass
|
|
68
|
+
try:
|
|
69
|
+
os.kill(pid, sig)
|
|
70
|
+
except (ProcessLookupError, PermissionError):
|
|
71
|
+
pass
|
|
@@ -30,6 +30,8 @@ class ManagedExecution:
|
|
|
30
30
|
"_seq_source",
|
|
31
31
|
"_timeout_triggered",
|
|
32
32
|
"sensitive_env",
|
|
33
|
+
"containment_token",
|
|
34
|
+
"contained_processes_exited",
|
|
33
35
|
)
|
|
34
36
|
|
|
35
37
|
def __init__(
|
|
@@ -39,6 +41,7 @@ class ManagedExecution:
|
|
|
39
41
|
max_buffer_bytes: int,
|
|
40
42
|
process_group_id: int | None = None,
|
|
41
43
|
sensitive_env: SensitiveEnv = (),
|
|
44
|
+
containment_token: str | None = None,
|
|
42
45
|
) -> None:
|
|
43
46
|
self.info = info
|
|
44
47
|
self.proc = proc
|
|
@@ -61,6 +64,8 @@ class ManagedExecution:
|
|
|
61
64
|
self.stdin_error: str | None = None
|
|
62
65
|
self._timeout_triggered = False
|
|
63
66
|
self.sensitive_env = sensitive_env
|
|
67
|
+
self.containment_token = containment_token
|
|
68
|
+
self.contained_processes_exited = False
|
|
64
69
|
|
|
65
70
|
@property
|
|
66
71
|
def latest_seq(self) -> int:
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import codecs
|
|
7
7
|
import os
|
|
8
|
+
import signal
|
|
8
9
|
import sys
|
|
9
10
|
import time
|
|
10
11
|
import uuid
|
|
@@ -25,6 +26,12 @@ from ..security import (
|
|
|
25
26
|
)
|
|
26
27
|
from ..side_effects import validate_side_effects
|
|
27
28
|
from ..types import ExecutionInfo, ExecutionStatus, SideEffect
|
|
29
|
+
from .containment import (
|
|
30
|
+
EXECUTION_TOKEN_ENV,
|
|
31
|
+
contained_processes_exist,
|
|
32
|
+
new_execution_token,
|
|
33
|
+
signal_contained_processes,
|
|
34
|
+
)
|
|
28
35
|
from .managed import ManagedExecution
|
|
29
36
|
from .ring_buffer import RingBuffer, read_buffers, tail_start_seq
|
|
30
37
|
from .spawn import kill_process, spawn_process, terminate_process
|
|
@@ -64,7 +71,12 @@ class ExecutionManager:
|
|
|
64
71
|
for task in tasks:
|
|
65
72
|
task.cancel()
|
|
66
73
|
if tasks:
|
|
67
|
-
await asyncio.
|
|
74
|
+
done, _ = await asyncio.wait(
|
|
75
|
+
tasks,
|
|
76
|
+
timeout=PENDING_SPAWN_SHUTDOWN_MS / 1000.0,
|
|
77
|
+
)
|
|
78
|
+
if done:
|
|
79
|
+
await asyncio.gather(*done, return_exceptions=True)
|
|
68
80
|
|
|
69
81
|
def _release_spawn_reservation(
|
|
70
82
|
self, spawn_owner: asyncio.Task[Any] | None
|
|
@@ -80,6 +92,7 @@ class ExecutionManager:
|
|
|
80
92
|
proc: asyncio.subprocess.Process,
|
|
81
93
|
process_group_id: int | None,
|
|
82
94
|
drain_tasks: list[asyncio.Task[None]],
|
|
95
|
+
containment_token: str | None = None,
|
|
83
96
|
) -> None:
|
|
84
97
|
for task in drain_tasks:
|
|
85
98
|
if not task.done():
|
|
@@ -87,6 +100,11 @@ class ExecutionManager:
|
|
|
87
100
|
if drain_tasks:
|
|
88
101
|
await asyncio.gather(*drain_tasks, return_exceptions=True)
|
|
89
102
|
await kill_process(proc, process_group_id)
|
|
103
|
+
signal_contained_processes(
|
|
104
|
+
containment_token,
|
|
105
|
+
signal.SIGKILL,
|
|
106
|
+
exclude_process_group_id=process_group_id,
|
|
107
|
+
)
|
|
90
108
|
try:
|
|
91
109
|
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
92
110
|
except (asyncio.TimeoutError, ProcessLookupError):
|
|
@@ -187,6 +205,18 @@ class ExecutionManager:
|
|
|
187
205
|
fields["hint"] = " ".join(hints)
|
|
188
206
|
return fields
|
|
189
207
|
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _mark_snapshot_unrecoverable(snapshot: dict[str, Any]) -> None:
|
|
210
|
+
"""Replace a paging promise after its execution record was reaped."""
|
|
211
|
+
if not snapshot["capped"]:
|
|
212
|
+
return
|
|
213
|
+
snapshot["capped"] = False
|
|
214
|
+
snapshot["evicted"] = True
|
|
215
|
+
snapshot["hint"] = (
|
|
216
|
+
"Additional execution output was omitted and cannot be recovered "
|
|
217
|
+
"because retention removed the completed record."
|
|
218
|
+
)
|
|
219
|
+
|
|
190
220
|
def _redact(self, mp: ManagedExecution, text: str) -> str:
|
|
191
221
|
return redact_text(self._config, text, mp.sensitive_env)
|
|
192
222
|
|
|
@@ -354,6 +384,9 @@ class ExecutionManager:
|
|
|
354
384
|
|
|
355
385
|
try:
|
|
356
386
|
env = build_env(self._config, env_overlay)
|
|
387
|
+
containment_token = new_execution_token()
|
|
388
|
+
if containment_token is not None:
|
|
389
|
+
env[EXECUTION_TOKEN_ENV] = containment_token
|
|
357
390
|
effective_yield = self._clamp_yield_ms(yield_ms)
|
|
358
391
|
effective_timeout = self._clamp_timeout_ms(timeout_ms)
|
|
359
392
|
effective_max_output = self._max_output(max_output_bytes)
|
|
@@ -389,6 +422,7 @@ class ExecutionManager:
|
|
|
389
422
|
self._config.max_buffer_bytes,
|
|
390
423
|
process_group_id,
|
|
391
424
|
collect_sensitive_env(self._config, env_overlay),
|
|
425
|
+
containment_token,
|
|
392
426
|
)
|
|
393
427
|
|
|
394
428
|
# Start drain tasks immediately after spawn to prevent blocking on full pipe buffers
|
|
@@ -417,12 +451,22 @@ class ExecutionManager:
|
|
|
417
451
|
except asyncio.CancelledError:
|
|
418
452
|
self._release_spawn_reservation(spawn_owner)
|
|
419
453
|
await asyncio.shield(
|
|
420
|
-
self._reject_spawned_execution(
|
|
454
|
+
self._reject_spawned_execution(
|
|
455
|
+
proc,
|
|
456
|
+
process_group_id,
|
|
457
|
+
drain_tasks,
|
|
458
|
+
containment_token,
|
|
459
|
+
)
|
|
421
460
|
)
|
|
422
461
|
raise
|
|
423
462
|
|
|
424
463
|
if reject_for_shutdown:
|
|
425
|
-
await self._reject_spawned_execution(
|
|
464
|
+
await self._reject_spawned_execution(
|
|
465
|
+
proc,
|
|
466
|
+
process_group_id,
|
|
467
|
+
drain_tasks,
|
|
468
|
+
containment_token,
|
|
469
|
+
)
|
|
426
470
|
return self._shutdown_reject_response()
|
|
427
471
|
|
|
428
472
|
mp.completion_task = asyncio.create_task(
|
|
@@ -462,6 +506,11 @@ class ExecutionManager:
|
|
|
462
506
|
|
|
463
507
|
# Prepare output for response
|
|
464
508
|
snapshot = self._read_snapshot(mp, effective_max_output)
|
|
509
|
+
addressable = self._executions.get(execution_id) is mp
|
|
510
|
+
if not addressable:
|
|
511
|
+
# Retention removed the terminal record before this response was
|
|
512
|
+
# assembled. Do not emit a dangling read hint.
|
|
513
|
+
self._mark_snapshot_unrecoverable(snapshot)
|
|
465
514
|
# Every branch carries the cursor so a caller can keep reading without
|
|
466
515
|
# replaying output it already received.
|
|
467
516
|
common = {"duration_ms": round(duration_ms, 1), **snapshot}
|
|
@@ -472,7 +521,6 @@ class ExecutionManager:
|
|
|
472
521
|
# Response matrix: failed_to_start returns before a record is created;
|
|
473
522
|
# every retained result is addressable by its opaque execution ID and
|
|
474
523
|
# exposes the initial shell's OS process ID when one is available.
|
|
475
|
-
addressable = self._executions.get(execution_id) is mp
|
|
476
524
|
identifiers: dict[str, Any] = {}
|
|
477
525
|
if addressable:
|
|
478
526
|
identifiers["execution_id"] = execution_id
|
|
@@ -562,21 +610,50 @@ class ExecutionManager:
|
|
|
562
610
|
|
|
563
611
|
def _process_group_exists(self, mp: ManagedExecution) -> bool:
|
|
564
612
|
if mp.process_group_exited:
|
|
565
|
-
return
|
|
613
|
+
return self._contained_processes_exist(mp)
|
|
566
614
|
if sys.platform == "win32":
|
|
567
615
|
exists = mp.proc.returncode is None
|
|
568
616
|
if not exists:
|
|
569
617
|
mp.process_group_exited = True
|
|
570
|
-
return exists
|
|
618
|
+
return exists or self._contained_processes_exist(mp)
|
|
571
619
|
try:
|
|
572
620
|
os.killpg(self._process_group_id(mp), 0)
|
|
573
621
|
except ProcessLookupError:
|
|
574
622
|
mp.process_group_exited = True
|
|
575
|
-
return
|
|
623
|
+
return self._contained_processes_exist(mp)
|
|
576
624
|
except PermissionError:
|
|
577
625
|
return True
|
|
578
626
|
return True
|
|
579
627
|
|
|
628
|
+
@staticmethod
|
|
629
|
+
def _contained_processes_exist(mp: ManagedExecution) -> bool:
|
|
630
|
+
if mp.contained_processes_exited:
|
|
631
|
+
return False
|
|
632
|
+
exists = contained_processes_exist(mp.containment_token)
|
|
633
|
+
if not exists:
|
|
634
|
+
# Once the original group is gone and no tagged descendants exist,
|
|
635
|
+
# no process remains that could create another tagged descendant.
|
|
636
|
+
mp.contained_processes_exited = True
|
|
637
|
+
return exists
|
|
638
|
+
|
|
639
|
+
@staticmethod
|
|
640
|
+
def _signal_contained_processes(
|
|
641
|
+
mp: ManagedExecution, sig: signal.Signals
|
|
642
|
+
) -> None:
|
|
643
|
+
signal_contained_processes(
|
|
644
|
+
mp.containment_token,
|
|
645
|
+
sig,
|
|
646
|
+
exclude_process_group_id=mp.process_group_id,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
async def _terminate_execution(self, mp: ManagedExecution) -> None:
|
|
650
|
+
await terminate_process(mp.proc, mp.process_group_id)
|
|
651
|
+
self._signal_contained_processes(mp, signal.SIGTERM)
|
|
652
|
+
|
|
653
|
+
async def _kill_execution(self, mp: ManagedExecution) -> None:
|
|
654
|
+
await kill_process(mp.proc, mp.process_group_id)
|
|
655
|
+
self._signal_contained_processes(mp, signal.SIGKILL)
|
|
656
|
+
|
|
580
657
|
async def _drain_stream(
|
|
581
658
|
self,
|
|
582
659
|
stream: asyncio.StreamReader | None,
|
|
@@ -805,23 +882,24 @@ class ExecutionManager:
|
|
|
805
882
|
await asyncio.sleep(timeout_sec)
|
|
806
883
|
except asyncio.CancelledError:
|
|
807
884
|
return
|
|
808
|
-
#
|
|
809
|
-
#
|
|
810
|
-
|
|
885
|
+
# Managed processes may have exited naturally while the completion
|
|
886
|
+
# tracker is still waiting to publish the subprocess return code.
|
|
887
|
+
# Containment disappearance is authoritative; do not relabel that race.
|
|
888
|
+
if not self._process_group_exists(mp):
|
|
811
889
|
return
|
|
812
890
|
if not self._has_live_work(mp):
|
|
813
891
|
return
|
|
814
892
|
mp._timeout_triggered = True
|
|
815
893
|
mp.info.status = ExecutionStatus.TIMED_OUT
|
|
816
894
|
# Graceful termination
|
|
817
|
-
await
|
|
895
|
+
await self._terminate_execution(mp)
|
|
818
896
|
grace_period = GRACEFUL_STOP_MS / 1000.0
|
|
819
897
|
await self._wait_for_process_group_exit(mp, timeout_sec=grace_period)
|
|
820
898
|
|
|
821
899
|
if self._process_group_exists(mp):
|
|
822
900
|
# Force kill any children that survived graceful termination, even if
|
|
823
901
|
# the shell process already exited.
|
|
824
|
-
await
|
|
902
|
+
await self._kill_execution(mp)
|
|
825
903
|
await self._wait_for_process_group_exit(
|
|
826
904
|
mp, timeout_sec=PROCESS_GROUP_EXIT_MS / 1000.0
|
|
827
905
|
)
|
|
@@ -984,6 +1062,8 @@ class ExecutionManager:
|
|
|
984
1062
|
|
|
985
1063
|
effective_max = self._max_output(max_output_bytes)
|
|
986
1064
|
snapshot = self._read_snapshot(mp, effective_max, since_seq, tail_lines)
|
|
1065
|
+
if self._executions.get(execution_id) is not mp:
|
|
1066
|
+
self._mark_snapshot_unrecoverable(snapshot)
|
|
987
1067
|
|
|
988
1068
|
return self._with_stdin_error(mp, {
|
|
989
1069
|
"execution_id": execution_id,
|
|
@@ -1051,6 +1131,8 @@ class ExecutionManager:
|
|
|
1051
1131
|
pass
|
|
1052
1132
|
else:
|
|
1053
1133
|
await terminate_process(mp.proc)
|
|
1134
|
+
if sys.platform != "win32":
|
|
1135
|
+
self._signal_contained_processes(mp, sig)
|
|
1054
1136
|
|
|
1055
1137
|
# Wait for grace period
|
|
1056
1138
|
# Reserve time for force-kill observation, final drain, and subprocess
|
|
@@ -1062,7 +1144,7 @@ class ExecutionManager:
|
|
|
1062
1144
|
|
|
1063
1145
|
if self._process_group_exists(mp):
|
|
1064
1146
|
# The primary shell may exit before a resistant descendant.
|
|
1065
|
-
await
|
|
1147
|
+
await self._kill_execution(mp)
|
|
1066
1148
|
await self._wait_for_process_group_exit(
|
|
1067
1149
|
mp, timeout_sec=PROCESS_GROUP_EXIT_MS / 1000.0
|
|
1068
1150
|
)
|
|
@@ -1195,12 +1277,28 @@ class ExecutionManager:
|
|
|
1195
1277
|
name="yieldshell-shutdown",
|
|
1196
1278
|
)
|
|
1197
1279
|
self._shutdown_task = task
|
|
1198
|
-
|
|
1280
|
+
try:
|
|
1281
|
+
await asyncio.shield(task)
|
|
1282
|
+
except BaseException:
|
|
1283
|
+
# A failed teardown must be retryable. Keep an in-progress task
|
|
1284
|
+
# shared when only this caller was cancelled, but release a settled
|
|
1285
|
+
# failed task so a later shutdown call can try containment again.
|
|
1286
|
+
async with self._shutdown_lock:
|
|
1287
|
+
if self._shutdown_task is task and task.done():
|
|
1288
|
+
self._shutdown_task = None
|
|
1289
|
+
raise
|
|
1199
1290
|
|
|
1200
1291
|
async def _run_shutdown(self) -> None:
|
|
1201
1292
|
"""Perform the shutdown sequence once, independently of caller cancellation."""
|
|
1202
1293
|
await self._wait_for_no_pending_spawns()
|
|
1203
1294
|
await self._cancel_pending_spawns()
|
|
1295
|
+
async with self._shutdown_lock:
|
|
1296
|
+
pending_spawns = self._pending_spawns
|
|
1297
|
+
if pending_spawns:
|
|
1298
|
+
raise RuntimeError(
|
|
1299
|
+
"Server shutdown could not settle "
|
|
1300
|
+
f"{pending_spawns} pending spawn call(s)"
|
|
1301
|
+
)
|
|
1204
1302
|
|
|
1205
1303
|
while True:
|
|
1206
1304
|
live = [
|
|
@@ -1217,7 +1315,7 @@ class ExecutionManager:
|
|
|
1217
1315
|
running_at_start = {id(mp) for mp in live if not self._is_terminal(mp)}
|
|
1218
1316
|
|
|
1219
1317
|
await asyncio.gather(
|
|
1220
|
-
*(
|
|
1318
|
+
*(self._terminate_execution(mp) for mp in live),
|
|
1221
1319
|
return_exceptions=True,
|
|
1222
1320
|
)
|
|
1223
1321
|
|
|
@@ -1229,7 +1327,7 @@ class ExecutionManager:
|
|
|
1229
1327
|
|
|
1230
1328
|
survivors = [mp for mp in live if self._process_group_exists(mp)]
|
|
1231
1329
|
await asyncio.gather(
|
|
1232
|
-
*(
|
|
1330
|
+
*(self._kill_execution(mp) for mp in survivors),
|
|
1233
1331
|
return_exceptions=True,
|
|
1234
1332
|
)
|
|
1235
1333
|
await asyncio.gather(
|
|
@@ -1252,8 +1350,10 @@ class ExecutionManager:
|
|
|
1252
1350
|
*(self._settle_completion(mp) for mp in live),
|
|
1253
1351
|
return_exceptions=True,
|
|
1254
1352
|
)
|
|
1353
|
+
survivors = [mp for mp in live if self._process_group_exists(mp)]
|
|
1354
|
+
survivor_ids = {id(mp) for mp in survivors}
|
|
1255
1355
|
for mp in live:
|
|
1256
|
-
if
|
|
1356
|
+
if id(mp) not in survivor_ids:
|
|
1257
1357
|
self._mark_ended(mp)
|
|
1258
1358
|
|
|
1259
1359
|
for mp in live:
|
|
@@ -1266,9 +1366,13 @@ class ExecutionManager:
|
|
|
1266
1366
|
if mp.stdin_task is not None and not mp.stdin_task.done():
|
|
1267
1367
|
mp.stdin_task.cancel()
|
|
1268
1368
|
self._close_output_pipes(mp)
|
|
1269
|
-
if
|
|
1270
|
-
|
|
1271
|
-
|
|
1369
|
+
if (
|
|
1370
|
+
id(mp) not in survivor_ids
|
|
1371
|
+
and id(mp) in running_at_start
|
|
1372
|
+
and mp.info.status in (
|
|
1373
|
+
ExecutionStatus.RUNNING,
|
|
1374
|
+
ExecutionStatus.COMPLETED,
|
|
1375
|
+
)
|
|
1272
1376
|
):
|
|
1273
1377
|
mp.info.status = ExecutionStatus.STOPPED
|
|
1274
1378
|
mp.completion_event.set()
|
|
@@ -1287,6 +1391,12 @@ class ExecutionManager:
|
|
|
1287
1391
|
if tasks:
|
|
1288
1392
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
1289
1393
|
|
|
1394
|
+
if survivors:
|
|
1395
|
+
raise RuntimeError(
|
|
1396
|
+
"Server shutdown could not terminate process group(s): "
|
|
1397
|
+
+ ", ".join(mp.info.execution_id for mp in survivors)
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1290
1400
|
async with self._shutdown_lock:
|
|
1291
1401
|
self._shutdown_complete = True
|
|
1292
1402
|
return
|
|
@@ -117,9 +117,15 @@ class RingBuffer:
|
|
|
117
117
|
|
|
118
118
|
def clear(self) -> None:
|
|
119
119
|
"""Clear all buffered data."""
|
|
120
|
+
if self._chunks:
|
|
121
|
+
last_start, last_chunk = self._chunks[-1]
|
|
122
|
+
cleared_until = last_start + len(last_chunk)
|
|
123
|
+
self._evicted_until_seq = max(
|
|
124
|
+
self._evicted_until_seq or cleared_until,
|
|
125
|
+
cleared_until,
|
|
126
|
+
)
|
|
120
127
|
self._chunks.clear()
|
|
121
128
|
self._retained_bytes = 0
|
|
122
|
-
self._evicted_until_seq = None
|
|
123
129
|
|
|
124
130
|
|
|
125
131
|
def read_buffers(
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import os
|
|
5
|
+
import shutil
|
|
5
6
|
import signal
|
|
6
7
|
import sys
|
|
7
8
|
import time
|
|
@@ -161,6 +162,56 @@ class TestConcurrentShutdown:
|
|
|
161
162
|
assert manager._shutdown_complete is True
|
|
162
163
|
assert managed.info.status == ExecutionStatus.STOPPED
|
|
163
164
|
|
|
165
|
+
@pytest.mark.asyncio
|
|
166
|
+
async def test_force_kill_survivor_keeps_shutdown_retryable(self, monkeypatch):
|
|
167
|
+
manager = ExecutionManager(Config())
|
|
168
|
+
process = MagicMock()
|
|
169
|
+
process.pid = 12345
|
|
170
|
+
process.returncode = None
|
|
171
|
+
process.stdin = None
|
|
172
|
+
process.stdout = None
|
|
173
|
+
process.stderr = None
|
|
174
|
+
info = ExecutionInfo(
|
|
175
|
+
execution_id="shutdown-survivor",
|
|
176
|
+
process_id=process.pid,
|
|
177
|
+
command="resistant",
|
|
178
|
+
cwd=os.getcwd(),
|
|
179
|
+
name=None,
|
|
180
|
+
status=ExecutionStatus.RUNNING,
|
|
181
|
+
started_at=time.time(),
|
|
182
|
+
start_monotonic=time.monotonic(),
|
|
183
|
+
)
|
|
184
|
+
managed = ManagedExecution(info, process, 100, process_group_id=process.pid)
|
|
185
|
+
manager._executions[info.execution_id] = managed
|
|
186
|
+
group_alive = True
|
|
187
|
+
|
|
188
|
+
monkeypatch.setattr(
|
|
189
|
+
manager, "_process_group_exists", lambda _mp: group_alive
|
|
190
|
+
)
|
|
191
|
+
monkeypatch.setattr(
|
|
192
|
+
"mcp_yieldshell.execution.manager.terminate_process", AsyncMock()
|
|
193
|
+
)
|
|
194
|
+
monkeypatch.setattr(
|
|
195
|
+
"mcp_yieldshell.execution.manager.kill_process", AsyncMock()
|
|
196
|
+
)
|
|
197
|
+
monkeypatch.setattr("mcp_yieldshell.execution.manager.GRACEFUL_STOP_MS", 0)
|
|
198
|
+
monkeypatch.setattr(
|
|
199
|
+
"mcp_yieldshell.execution.manager.PROCESS_GROUP_EXIT_MS", 0
|
|
200
|
+
)
|
|
201
|
+
monkeypatch.setattr("mcp_yieldshell.execution.manager.FINAL_DRAIN_MS", 0)
|
|
202
|
+
|
|
203
|
+
with pytest.raises(RuntimeError, match="shutdown.*process group"):
|
|
204
|
+
await manager.shutdown()
|
|
205
|
+
|
|
206
|
+
assert manager._shutdown_complete is False
|
|
207
|
+
assert managed.info.status == ExecutionStatus.RUNNING
|
|
208
|
+
|
|
209
|
+
group_alive = False
|
|
210
|
+
await manager.shutdown()
|
|
211
|
+
|
|
212
|
+
assert manager._shutdown_complete is True
|
|
213
|
+
assert managed.info.status == ExecutionStatus.STOPPED
|
|
214
|
+
|
|
164
215
|
|
|
165
216
|
class TestAutomaticRetention:
|
|
166
217
|
@pytest.mark.asyncio
|
|
@@ -188,8 +239,10 @@ class TestAutomaticRetention:
|
|
|
188
239
|
)
|
|
189
240
|
|
|
190
241
|
assert result["status"] == "completed"
|
|
191
|
-
assert result["capped"] is
|
|
242
|
+
assert result["capped"] is False
|
|
243
|
+
assert result["evicted"] is True
|
|
192
244
|
assert "execution_id" not in result
|
|
245
|
+
assert "cannot be recovered" in result["hint"]
|
|
193
246
|
|
|
194
247
|
@pytest.mark.asyncio
|
|
195
248
|
async def test_reaps_every_terminal_state_and_ids_become_unknown(self, monkeypatch):
|
|
@@ -264,6 +317,37 @@ class TestAutomaticRetention:
|
|
|
264
317
|
result = await manager.cleanup(completed_older_than_ms=0)
|
|
265
318
|
assert result["removed"] == 1
|
|
266
319
|
|
|
320
|
+
@pytest.mark.asyncio
|
|
321
|
+
async def test_wait_does_not_offer_paging_after_concurrent_cleanup(
|
|
322
|
+
self, monkeypatch
|
|
323
|
+
):
|
|
324
|
+
manager = ExecutionManager(Config())
|
|
325
|
+
started = await manager.execute_command("printf abcdef", side_effects=NONE)
|
|
326
|
+
execution_id = started["execution_id"]
|
|
327
|
+
managed = manager.get_execution(execution_id)
|
|
328
|
+
assert managed is not None
|
|
329
|
+
managed.info.ended_at = time.time() - 1
|
|
330
|
+
|
|
331
|
+
async def cleanup_during_wait(_managed, _timeout_sec):
|
|
332
|
+
cleanup = await manager.cleanup(completed_older_than_ms=0)
|
|
333
|
+
assert cleanup["removed"] == 1
|
|
334
|
+
return True
|
|
335
|
+
|
|
336
|
+
monkeypatch.setattr(manager, "_await_live_work_end", cleanup_during_wait)
|
|
337
|
+
|
|
338
|
+
result = await manager.wait_execution(
|
|
339
|
+
execution_id,
|
|
340
|
+
timeout_ms=100,
|
|
341
|
+
max_output_bytes=2,
|
|
342
|
+
since_seq=1,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
assert manager.get_execution(execution_id) is None
|
|
346
|
+
assert result["stdout"] == "ab"
|
|
347
|
+
assert result["capped"] is False
|
|
348
|
+
assert result["evicted"] is True
|
|
349
|
+
assert "cannot be recovered" in result["hint"]
|
|
350
|
+
|
|
267
351
|
@pytest.mark.asyncio
|
|
268
352
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups only")
|
|
269
353
|
async def test_live_descendant_counts_toward_limit_and_survives_cleanup(
|
|
@@ -326,6 +410,25 @@ class TestAutomaticRetention:
|
|
|
326
410
|
monkeypatch.setattr(os, "killpg", lambda *_: None)
|
|
327
411
|
assert manager._process_group_exists(mp) is False
|
|
328
412
|
|
|
413
|
+
@pytest.mark.asyncio
|
|
414
|
+
async def test_absent_linux_descendants_are_not_rescanned(self, monkeypatch):
|
|
415
|
+
manager = ExecutionManager(Config())
|
|
416
|
+
await manager.execute_command("echo done", side_effects=NONE)
|
|
417
|
+
execution_id = manager.list_executions(limit=1)["executions"][0]["execution_id"]
|
|
418
|
+
managed = manager.get_execution(execution_id)
|
|
419
|
+
assert managed is not None
|
|
420
|
+
managed.process_group_exited = True
|
|
421
|
+
managed.containment_token = "test-token"
|
|
422
|
+
managed.contained_processes_exited = False
|
|
423
|
+
scan = MagicMock(return_value=False)
|
|
424
|
+
monkeypatch.setattr(
|
|
425
|
+
"mcp_yieldshell.execution.manager.contained_processes_exist", scan
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
assert manager._process_group_exists(managed) is False
|
|
429
|
+
assert manager._process_group_exists(managed) is False
|
|
430
|
+
scan.assert_called_once_with("test-token")
|
|
431
|
+
|
|
329
432
|
@pytest.mark.asyncio
|
|
330
433
|
async def test_concurrent_spawn_reservation_enforces_process_limit(self, monkeypatch):
|
|
331
434
|
monkeypatch.setenv("YIELDSHELL_MAX_PROCESSES", "1")
|
|
@@ -343,6 +446,42 @@ class TestAutomaticRetention:
|
|
|
343
446
|
await manager.stop_execution(running["execution_id"], force_after_ms=100)
|
|
344
447
|
|
|
345
448
|
|
|
449
|
+
class TestTimeoutRaces:
|
|
450
|
+
@pytest.mark.asyncio
|
|
451
|
+
async def test_timeout_does_not_relabel_an_already_exited_process_group(
|
|
452
|
+
self, monkeypatch
|
|
453
|
+
):
|
|
454
|
+
manager = ExecutionManager(Config())
|
|
455
|
+
process = MagicMock()
|
|
456
|
+
process.pid = 12345
|
|
457
|
+
process.returncode = None
|
|
458
|
+
process.stdin = None
|
|
459
|
+
process.stdout = None
|
|
460
|
+
process.stderr = None
|
|
461
|
+
info = ExecutionInfo(
|
|
462
|
+
execution_id="timeout-race",
|
|
463
|
+
process_id=process.pid,
|
|
464
|
+
command="already exited",
|
|
465
|
+
cwd=os.getcwd(),
|
|
466
|
+
name=None,
|
|
467
|
+
status=ExecutionStatus.RUNNING,
|
|
468
|
+
started_at=time.time(),
|
|
469
|
+
start_monotonic=time.monotonic(),
|
|
470
|
+
)
|
|
471
|
+
managed = ManagedExecution(info, process, 100, process_group_id=process.pid)
|
|
472
|
+
terminate = AsyncMock()
|
|
473
|
+
monkeypatch.setattr(manager, "_process_group_exists", lambda _mp: False)
|
|
474
|
+
monkeypatch.setattr(
|
|
475
|
+
"mcp_yieldshell.execution.manager.terminate_process", terminate
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
await manager._handle_timeout(managed, timeout_sec=0)
|
|
479
|
+
|
|
480
|
+
assert managed.info.status == ExecutionStatus.RUNNING
|
|
481
|
+
assert managed._timeout_triggered is False
|
|
482
|
+
terminate.assert_not_awaited()
|
|
483
|
+
|
|
484
|
+
|
|
346
485
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups only")
|
|
347
486
|
class TestManagerShutdown:
|
|
348
487
|
@pytest.fixture(autouse=True)
|
|
@@ -395,6 +534,153 @@ class TestManagerShutdown:
|
|
|
395
534
|
with pytest.raises(ProcessLookupError):
|
|
396
535
|
os.killpg(group, 0)
|
|
397
536
|
|
|
537
|
+
@pytest.mark.asyncio
|
|
538
|
+
@pytest.mark.skipif(
|
|
539
|
+
not sys.platform.startswith("linux") or shutil.which("setsid") is None,
|
|
540
|
+
reason="Linux procfs and setsid are required",
|
|
541
|
+
)
|
|
542
|
+
async def test_shutdown_kills_descendant_that_leaves_process_group(self):
|
|
543
|
+
manager = ExecutionManager(Config())
|
|
544
|
+
child_pid: int | None = None
|
|
545
|
+
try:
|
|
546
|
+
result = await manager.execute_command(
|
|
547
|
+
"setsid sh -c 'trap \"\" HUP TERM; echo CHILD=$$; exec sleep 30'",
|
|
548
|
+
yield_ms=0,
|
|
549
|
+
timeout_ms=0,
|
|
550
|
+
side_effects=NONE,
|
|
551
|
+
)
|
|
552
|
+
for _ in range(50):
|
|
553
|
+
page = await manager.read_execution_output(
|
|
554
|
+
result["execution_id"], since_seq=1
|
|
555
|
+
)
|
|
556
|
+
if page["stdout"].startswith("CHILD="):
|
|
557
|
+
child_pid = int(page["stdout"].split("=", 1)[1])
|
|
558
|
+
break
|
|
559
|
+
await asyncio.sleep(0.02)
|
|
560
|
+
assert child_pid is not None
|
|
561
|
+
managed = manager.get_execution(result["execution_id"])
|
|
562
|
+
assert managed is not None and managed.process_group_id is not None
|
|
563
|
+
assert os.getpgid(child_pid) != managed.process_group_id
|
|
564
|
+
|
|
565
|
+
await manager.shutdown()
|
|
566
|
+
|
|
567
|
+
stat_path = f"/proc/{child_pid}/stat"
|
|
568
|
+
if os.path.exists(stat_path):
|
|
569
|
+
with open(stat_path) as stat_file:
|
|
570
|
+
assert stat_file.read().split()[2] == "Z"
|
|
571
|
+
finally:
|
|
572
|
+
if child_pid is not None:
|
|
573
|
+
try:
|
|
574
|
+
os.kill(child_pid, signal.SIGKILL)
|
|
575
|
+
except ProcessLookupError:
|
|
576
|
+
pass
|
|
577
|
+
if not manager._shutdown_complete:
|
|
578
|
+
await manager.shutdown()
|
|
579
|
+
|
|
580
|
+
@pytest.mark.asyncio
|
|
581
|
+
@pytest.mark.skipif(
|
|
582
|
+
not sys.platform.startswith("linux") or shutil.which("setsid") is None,
|
|
583
|
+
reason="Linux procfs and setsid are required",
|
|
584
|
+
)
|
|
585
|
+
async def test_daemonized_descendant_remains_managed_after_shell_exits(self):
|
|
586
|
+
manager = ExecutionManager(Config())
|
|
587
|
+
child_pid: int | None = None
|
|
588
|
+
try:
|
|
589
|
+
result = await manager.execute_command(
|
|
590
|
+
"setsid sh -c 'trap \"\" HUP TERM; exec sleep 30' "
|
|
591
|
+
">/dev/null 2>&1 & echo CHILD=$!",
|
|
592
|
+
yield_ms=0,
|
|
593
|
+
timeout_ms=0,
|
|
594
|
+
side_effects=NONE,
|
|
595
|
+
)
|
|
596
|
+
execution_id = result["execution_id"]
|
|
597
|
+
for _ in range(100):
|
|
598
|
+
page = await manager.read_execution_output(
|
|
599
|
+
execution_id, since_seq=1
|
|
600
|
+
)
|
|
601
|
+
if page["stdout"].startswith("CHILD="):
|
|
602
|
+
child_pid = int(page["stdout"].split("=", 1)[1])
|
|
603
|
+
managed = manager.get_execution(execution_id)
|
|
604
|
+
if (
|
|
605
|
+
child_pid is not None
|
|
606
|
+
and managed is not None
|
|
607
|
+
and managed.info.status == ExecutionStatus.COMPLETED
|
|
608
|
+
):
|
|
609
|
+
break
|
|
610
|
+
await asyncio.sleep(0.02)
|
|
611
|
+
|
|
612
|
+
assert child_pid is not None
|
|
613
|
+
managed = manager.get_execution(execution_id)
|
|
614
|
+
assert managed is not None
|
|
615
|
+
assert managed.info.status == ExecutionStatus.COMPLETED
|
|
616
|
+
assert manager.list_executions()["executions"][0]["status"] == "running"
|
|
617
|
+
|
|
618
|
+
await manager.shutdown()
|
|
619
|
+
|
|
620
|
+
stat_path = f"/proc/{child_pid}/stat"
|
|
621
|
+
if os.path.exists(stat_path):
|
|
622
|
+
with open(stat_path) as stat_file:
|
|
623
|
+
assert stat_file.read().split()[2] == "Z"
|
|
624
|
+
finally:
|
|
625
|
+
if child_pid is not None:
|
|
626
|
+
try:
|
|
627
|
+
os.kill(child_pid, signal.SIGKILL)
|
|
628
|
+
except ProcessLookupError:
|
|
629
|
+
pass
|
|
630
|
+
if not manager._shutdown_complete:
|
|
631
|
+
await manager.shutdown()
|
|
632
|
+
|
|
633
|
+
@pytest.mark.asyncio
|
|
634
|
+
@pytest.mark.parametrize("termination", ("stop", "timeout"))
|
|
635
|
+
@pytest.mark.skipif(
|
|
636
|
+
not sys.platform.startswith("linux") or shutil.which("setsid") is None,
|
|
637
|
+
reason="Linux procfs and setsid are required",
|
|
638
|
+
)
|
|
639
|
+
async def test_stop_and_timeout_kill_descendant_outside_group(
|
|
640
|
+
self, termination
|
|
641
|
+
):
|
|
642
|
+
manager = ExecutionManager(Config())
|
|
643
|
+
child_pid: int | None = None
|
|
644
|
+
try:
|
|
645
|
+
result = await manager.execute_command(
|
|
646
|
+
"setsid sh -c 'trap \"\" HUP TERM; echo CHILD=$$; exec sleep 30'",
|
|
647
|
+
yield_ms=0,
|
|
648
|
+
timeout_ms=100 if termination == "timeout" else 0,
|
|
649
|
+
side_effects=NONE,
|
|
650
|
+
)
|
|
651
|
+
execution_id = result["execution_id"]
|
|
652
|
+
for _ in range(50):
|
|
653
|
+
page = await manager.read_execution_output(
|
|
654
|
+
execution_id, since_seq=1
|
|
655
|
+
)
|
|
656
|
+
if page["stdout"].startswith("CHILD="):
|
|
657
|
+
child_pid = int(page["stdout"].split("=", 1)[1])
|
|
658
|
+
break
|
|
659
|
+
await asyncio.sleep(0.02)
|
|
660
|
+
assert child_pid is not None
|
|
661
|
+
|
|
662
|
+
if termination == "stop":
|
|
663
|
+
stopped = await manager.stop_execution(
|
|
664
|
+
execution_id, force_after_ms=100
|
|
665
|
+
)
|
|
666
|
+
assert stopped["stopped"] is True
|
|
667
|
+
else:
|
|
668
|
+
waited = await manager.wait_execution(execution_id, timeout_ms=3_000)
|
|
669
|
+
assert waited["status"] == "timed_out"
|
|
670
|
+
assert waited["wait_result"] == "exited"
|
|
671
|
+
|
|
672
|
+
stat_path = f"/proc/{child_pid}/stat"
|
|
673
|
+
if os.path.exists(stat_path):
|
|
674
|
+
with open(stat_path) as stat_file:
|
|
675
|
+
assert stat_file.read().split()[2] == "Z"
|
|
676
|
+
finally:
|
|
677
|
+
if child_pid is not None:
|
|
678
|
+
try:
|
|
679
|
+
os.kill(child_pid, signal.SIGKILL)
|
|
680
|
+
except ProcessLookupError:
|
|
681
|
+
pass
|
|
682
|
+
await manager.shutdown()
|
|
683
|
+
|
|
398
684
|
@pytest.mark.asyncio
|
|
399
685
|
async def test_shutdown_force_kills_group_ignoring_termination(self):
|
|
400
686
|
manager = ExecutionManager(Config())
|
|
@@ -935,6 +1221,44 @@ class TestShutdownSpawnRace:
|
|
|
935
1221
|
assert manager._pending_spawn_tasks == set()
|
|
936
1222
|
assert manager._shutdown_complete is True
|
|
937
1223
|
|
|
1224
|
+
@pytest.mark.asyncio
|
|
1225
|
+
async def test_shutdown_reports_spawn_that_ignores_cancellation(
|
|
1226
|
+
self, monkeypatch
|
|
1227
|
+
):
|
|
1228
|
+
monkeypatch.setattr(
|
|
1229
|
+
"mcp_yieldshell.execution.manager.PENDING_SPAWN_SHUTDOWN_MS", 20
|
|
1230
|
+
)
|
|
1231
|
+
spawn_started = asyncio.Event()
|
|
1232
|
+
release_spawn = asyncio.Event()
|
|
1233
|
+
|
|
1234
|
+
async def stubborn_spawn(*_args, **_kwargs):
|
|
1235
|
+
spawn_started.set()
|
|
1236
|
+
try:
|
|
1237
|
+
await release_spawn.wait()
|
|
1238
|
+
except asyncio.CancelledError:
|
|
1239
|
+
await release_spawn.wait()
|
|
1240
|
+
return TestInitialStdinLifecycle.FakeProcess(None)
|
|
1241
|
+
|
|
1242
|
+
monkeypatch.setattr(
|
|
1243
|
+
"mcp_yieldshell.execution.manager.spawn_process", stubborn_spawn
|
|
1244
|
+
)
|
|
1245
|
+
manager = ExecutionManager(Config())
|
|
1246
|
+
execute_task = asyncio.create_task(
|
|
1247
|
+
manager.execute_command("sleep 30", yield_ms=0, side_effects=NONE)
|
|
1248
|
+
)
|
|
1249
|
+
await spawn_started.wait()
|
|
1250
|
+
|
|
1251
|
+
try:
|
|
1252
|
+
with pytest.raises(RuntimeError, match="pending spawn"):
|
|
1253
|
+
await asyncio.wait_for(manager.shutdown(), timeout=0.5)
|
|
1254
|
+
assert manager._shutdown_complete is False
|
|
1255
|
+
finally:
|
|
1256
|
+
release_spawn.set()
|
|
1257
|
+
await execute_task
|
|
1258
|
+
await manager.shutdown()
|
|
1259
|
+
|
|
1260
|
+
assert manager._shutdown_complete is True
|
|
1261
|
+
|
|
938
1262
|
|
|
939
1263
|
class TestWaitDeadline:
|
|
940
1264
|
@pytest.mark.asyncio
|
|
@@ -227,6 +227,75 @@ class TestReleaseLockfileRefresh:
|
|
|
227
227
|
assert remote_branch == local_head
|
|
228
228
|
assert remote_tag == local_head
|
|
229
229
|
|
|
230
|
+
def test_release_resumes_after_commit_if_tagging_failed(
|
|
231
|
+
self, release_module, fake_repo, tmp_path, monkeypatch
|
|
232
|
+
):
|
|
233
|
+
repo = fake_repo["repo"]
|
|
234
|
+
remote = tmp_path / "remote.git"
|
|
235
|
+
subprocess.run(
|
|
236
|
+
["git", "init", "--bare", "-q", str(remote)],
|
|
237
|
+
check=True,
|
|
238
|
+
capture_output=True,
|
|
239
|
+
)
|
|
240
|
+
subprocess.run(
|
|
241
|
+
["git", "remote", "add", "origin", str(remote)],
|
|
242
|
+
cwd=repo,
|
|
243
|
+
check=True,
|
|
244
|
+
capture_output=True,
|
|
245
|
+
)
|
|
246
|
+
monkeypatch.chdir(repo)
|
|
247
|
+
original_run_cmd = release_module.run_cmd
|
|
248
|
+
|
|
249
|
+
def fail_first_tag(cmd, check=True):
|
|
250
|
+
if cmd == "git tag v0.4.0":
|
|
251
|
+
raise SystemExit(1)
|
|
252
|
+
return original_run_cmd(cmd, check=check)
|
|
253
|
+
|
|
254
|
+
release_module.run_cmd = fail_first_tag
|
|
255
|
+
monkeypatch.setattr(sys, "argv", ["release.py", "0.4.0", "-y"])
|
|
256
|
+
with pytest.raises(SystemExit):
|
|
257
|
+
release_module.main()
|
|
258
|
+
|
|
259
|
+
assert release_module.get_current_version() == "0.4.0"
|
|
260
|
+
assert (
|
|
261
|
+
subprocess.run(
|
|
262
|
+
["git", "log", "-1", "--format=%s"],
|
|
263
|
+
cwd=repo,
|
|
264
|
+
check=True,
|
|
265
|
+
capture_output=True,
|
|
266
|
+
text=True,
|
|
267
|
+
).stdout.strip()
|
|
268
|
+
== "chore: bump version to v0.4.0"
|
|
269
|
+
)
|
|
270
|
+
assert (
|
|
271
|
+
subprocess.run(
|
|
272
|
+
["git", "tag", "--list", "v0.4.0"],
|
|
273
|
+
cwd=repo,
|
|
274
|
+
check=True,
|
|
275
|
+
capture_output=True,
|
|
276
|
+
text=True,
|
|
277
|
+
).stdout
|
|
278
|
+
== ""
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
release_module.run_cmd = original_run_cmd
|
|
282
|
+
release_module.main()
|
|
283
|
+
|
|
284
|
+
local_head = subprocess.run(
|
|
285
|
+
["git", "rev-parse", "HEAD"],
|
|
286
|
+
cwd=repo,
|
|
287
|
+
check=True,
|
|
288
|
+
capture_output=True,
|
|
289
|
+
text=True,
|
|
290
|
+
).stdout.strip()
|
|
291
|
+
remote_tag = subprocess.run(
|
|
292
|
+
["git", "--git-dir", str(remote), "rev-parse", "refs/tags/v0.4.0^{commit}"],
|
|
293
|
+
check=True,
|
|
294
|
+
capture_output=True,
|
|
295
|
+
text=True,
|
|
296
|
+
).stdout.strip()
|
|
297
|
+
assert remote_tag == local_head
|
|
298
|
+
|
|
230
299
|
def test_uv_lock_failure_aborts_release(self, release_module, fake_repo, monkeypatch):
|
|
231
300
|
"""If ``uv lock`` fails, the release script exits non-zero before any commit/tag."""
|
|
232
301
|
# Make the uv shim fail.
|
|
@@ -262,6 +262,9 @@ class TestRingBufferUTF8:
|
|
|
262
262
|
assert buf.byte_count == total
|
|
263
263
|
result = buf.read()
|
|
264
264
|
assert result["text"] == ""
|
|
265
|
+
assert result["start_seq"] == 6
|
|
266
|
+
assert result["next_seq"] == 6
|
|
267
|
+
assert result["evicted"] is True
|
|
265
268
|
|
|
266
269
|
|
|
267
270
|
class TestTailStartSeq:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|