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.
Files changed (27) hide show
  1. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/PKG-INFO +10 -5
  2. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/README.md +9 -4
  3. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/pyproject.toml +1 -1
  4. mcp_yieldshell-1.1.2/src/mcp_yieldshell/execution/containment.py +71 -0
  5. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/managed.py +5 -0
  6. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/manager.py +130 -20
  7. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/ring_buffer.py +7 -1
  8. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_lifecycle_hardening.py +325 -1
  9. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_release.py +69 -0
  10. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_ring_buffer.py +3 -0
  11. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/.gitignore +0 -0
  12. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/__init__.py +0 -0
  13. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/__main__.py +0 -0
  14. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/config.py +0 -0
  15. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/__init__.py +0 -0
  16. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/execution/spawn.py +0 -0
  17. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/policy.py +0 -0
  18. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/security.py +0 -0
  19. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/server.py +0 -0
  20. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/side_effects.py +0 -0
  21. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/src/mcp_yieldshell/types.py +0 -0
  22. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/__init__.py +0 -0
  23. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_config.py +0 -0
  24. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_integration.py +0 -0
  25. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_protocol.py +0 -0
  26. {mcp_yieldshell-1.1.0 → mcp_yieldshell-1.1.2}/tests/test_security.py +0 -0
  27. {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.0
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 before it was read and cannot be recovered. When either is set, a `hint` field names the concrete follow-up call.
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 and is permanently gone. Retrying cannot recover it; poll more often or raise `YIELDSHELL_MAX_BUFFER_BYTES`. Reads report eviction only when their requested range overlaps evicted data.
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
- * **POSIX (Linux & macOS)**: Fully supported. Spawns processes in distinct sessions (`start_new_session=True`), allowing `stop`, runtime timeout, and server shutdown signals (`SIGTERM`/`SIGKILL`) to target the entire process group. This cleans up child processes started by managed commands.
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 before it was read and cannot be recovered. When either is set, a `hint` field names the concrete follow-up call.
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 and is permanently gone. Retrying cannot recover it; poll more often or raise `YIELDSHELL_MAX_BUFFER_BYTES`. Reads report eviction only when their requested range overlaps evicted data.
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
- * **POSIX (Linux & macOS)**: Fully supported. Spawns processes in distinct sessions (`start_new_session=True`), allowing `stop`, runtime timeout, and server shutdown signals (`SIGTERM`/`SIGKILL`) to target the entire process group. This cleans up child processes started by managed commands.
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
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-yieldshell"
3
- version = "1.1.0"
3
+ version = "1.1.2"
4
4
  description = "A drop-in shell MCP that auto-yields long-running commands into managed background executions."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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.gather(*tasks, return_exceptions=True)
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(proc, process_group_id, drain_tasks)
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(proc, process_group_id, drain_tasks)
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 False
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 False
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
- # The subprocess may have exited naturally while its completion tracker
809
- # was waiting to run. Do not relabel that race as a timeout.
810
- if mp.proc.returncode is not None and not self._process_group_exists(mp):
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 terminate_process(mp.proc, mp.process_group_id)
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 kill_process(mp.proc, mp.process_group_id)
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 kill_process(mp.proc, mp.process_group_id)
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
- await asyncio.shield(task)
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
- *(terminate_process(mp.proc, mp.process_group_id) for mp in live),
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
- *(kill_process(mp.proc, mp.process_group_id) for mp in survivors),
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 not self._process_group_exists(mp):
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 id(mp) in running_at_start and mp.info.status in (
1270
- ExecutionStatus.RUNNING,
1271
- ExecutionStatus.COMPLETED,
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 True
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: