|
18 | 18 | terminate_windows_process,
|
19 | 19 | )
|
20 | 20 |
|
| 21 | +__all__ = [ |
| 22 | + "ProcessTerminatedEarlyError", |
| 23 | + "StdioServerParameters", |
| 24 | + "stdio_client", |
| 25 | + "get_default_environment", |
| 26 | +] |
| 27 | + |
21 | 28 | # Environment variables to inherit by default
|
22 | 29 | DEFAULT_INHERITED_ENV_VARS = (
|
23 | 30 | [
|
|
38 | 45 | )
|
39 | 46 |
|
40 | 47 |
|
| 48 | +class ProcessTerminatedEarlyError(Exception): |
| 49 | + """Raised when a process terminates unexpectedly.""" |
| 50 | + |
| 51 | + def __init__(self, message: str): |
| 52 | + super().__init__(message) |
| 53 | + |
| 54 | + |
41 | 55 | def get_default_environment() -> dict[str, str]:
|
42 | 56 | """
|
43 | 57 | Returns a default environment object including only environment variables deemed
|
@@ -163,20 +177,60 @@ async def stdin_writer():
|
163 | 177 | except anyio.ClosedResourceError:
|
164 | 178 | await anyio.lowlevel.checkpoint()
|
165 | 179 |
|
| 180 | + process_error: str | None = None |
| 181 | + |
166 | 182 | async with (
|
167 | 183 | anyio.create_task_group() as tg,
|
168 | 184 | process,
|
169 | 185 | ):
|
170 | 186 | tg.start_soon(stdout_reader)
|
171 | 187 | tg.start_soon(stdin_writer)
|
| 188 | + |
| 189 | + # Add a task to monitor the process and detect early termination |
| 190 | + async def monitor_process(): |
| 191 | + nonlocal process_error |
| 192 | + try: |
| 193 | + await process.wait() |
| 194 | + # Only consider it an error if the process exits with a non-zero code |
| 195 | + # during normal operation (not when we explicitly terminate it) |
| 196 | + if process.returncode != 0 and not tg.cancel_scope.cancel_called: |
| 197 | + process_error = f"Process exited with code {process.returncode}." |
| 198 | + # Cancel the task group to stop other tasks |
| 199 | + tg.cancel_scope.cancel() |
| 200 | + except anyio.get_cancelled_exc_class(): |
| 201 | + # Task was cancelled, which is expected when we're done |
| 202 | + pass |
| 203 | + |
| 204 | + tg.start_soon(monitor_process) |
| 205 | + |
172 | 206 | try:
|
173 | 207 | yield read_stream, write_stream
|
174 | 208 | finally:
|
| 209 | + # Set a flag to indicate we're explicitly terminating the process |
| 210 | + # This prevents the monitor_process from treating our termination |
| 211 | + # as an error when we explicitly terminate it |
| 212 | + tg.cancel_scope.cancel() |
| 213 | + |
| 214 | + # Close all streams to prevent resource leaks |
| 215 | + await read_stream.aclose() |
| 216 | + await write_stream.aclose() |
| 217 | + await read_stream_writer.aclose() |
| 218 | + await write_stream_reader.aclose() |
| 219 | + |
175 | 220 | # Clean up process to prevent any dangling orphaned processes
|
176 |
| - if sys.platform == "win32": |
177 |
| - await terminate_windows_process(process) |
178 |
| - else: |
179 |
| - process.terminate() |
| 221 | + try: |
| 222 | + if sys.platform == "win32": |
| 223 | + await terminate_windows_process(process) |
| 224 | + else: |
| 225 | + process.terminate() |
| 226 | + except ProcessLookupError: |
| 227 | + # Process has already exited, which is fine |
| 228 | + pass |
| 229 | + |
| 230 | + if process_error: |
| 231 | + # Raise outside the task group so that the error is not wrapped in an |
| 232 | + # ExceptionGroup |
| 233 | + raise ProcessTerminatedEarlyError(process_error) |
180 | 234 |
|
181 | 235 |
|
182 | 236 | def _get_executable_command(command: str) -> str:
|
|
0 commit comments