|
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