|
5 | 5 | if TYPE_CHECKING: |
6 | 6 | from afterpython._typing import NodeEnv |
7 | 7 |
|
| 8 | +import contextlib |
| 9 | +import os |
| 10 | +import signal |
8 | 11 | import subprocess |
9 | 12 | import time |
10 | 13 |
|
@@ -116,15 +119,26 @@ def dev( |
116 | 119 | paths = ctx.obj["paths"] |
117 | 120 |
|
118 | 121 | def cleanup_processes(): |
119 | | - """Clean up all MyST server processes""" |
| 122 | + """Clean up all MyST server processes. |
| 123 | +
|
| 124 | + Each spawned 'ap {content_type}' is its own session leader (start_new_session=True |
| 125 | + below), so its grandchild `myst start` shares the same process group. proc.terminate() |
| 126 | + would only SIGTERM the outer Python wrapper — which is blocked in subprocess.run and |
| 127 | + doesn't forward the signal — leaving myst orphaned. Signal the whole group instead. |
| 128 | + """ |
120 | 129 | click.echo("\nShutting down MyST servers...") |
121 | 130 | for proc in myst_processes: |
122 | 131 | try: |
123 | | - proc.terminate() |
| 132 | + pgid = os.getpgid(proc.pid) |
| 133 | + except ProcessLookupError: |
| 134 | + continue |
| 135 | + try: |
| 136 | + os.killpg(pgid, signal.SIGTERM) |
124 | 137 | proc.wait(timeout=5) |
125 | 138 | except subprocess.TimeoutExpired: |
126 | | - proc.kill() |
127 | | - except Exception: |
| 139 | + with contextlib.suppress(ProcessLookupError): |
| 140 | + os.killpg(pgid, signal.SIGKILL) |
| 141 | + except ProcessLookupError: |
128 | 142 | pass |
129 | 143 |
|
130 | 144 | # Determine which content types to run |
@@ -184,6 +198,9 @@ def cleanup_processes(): |
184 | 198 | *(["--execute"] if execute else []), |
185 | 199 | *ctx.args, |
186 | 200 | ], |
| 201 | + # New session so cleanup_processes can SIGTERM the whole group and |
| 202 | + # take the grandchild `myst start` down with the wrapper. |
| 203 | + start_new_session=True, |
187 | 204 | # stdout=subprocess.DEVNULL, # Suppress output (optional) |
188 | 205 | # stderr=subprocess.DEVNULL, # Suppress errors (optional) |
189 | 206 | ) |
|
0 commit comments