Minimal async JSON-RPC 2.0 client for stdio transport Uses threads for blocking IO but provides async interface.
| 63 | |
| 64 | |
| 65 | class JsonRpcClient: |
| 66 | """ |
| 67 | Minimal async JSON-RPC 2.0 client for stdio transport |
| 68 | |
| 69 | Uses threads for blocking IO but provides async interface. |
| 70 | """ |
| 71 | |
| 72 | def __init__(self, process): |
| 73 | """ |
| 74 | Create client from subprocess.Popen with stdin/stdout pipes |
| 75 | |
| 76 | Args: |
| 77 | process: subprocess.Popen with stdin=PIPE, stdout=PIPE |
| 78 | """ |
| 79 | self.process = process |
| 80 | self.pending_requests: dict[str, asyncio.Future] = {} |
| 81 | self._pending_inline_callbacks: dict[str, Callable[[Any], None]] = {} |
| 82 | self.notification_handler: Callable[[str, dict], None] | None = None |
| 83 | self.request_handlers: dict[str, RequestHandler] = {} |
| 84 | self._running = False |
| 85 | self._read_thread: threading.Thread | None = None |
| 86 | self._stderr_thread: threading.Thread | None = None |
| 87 | self._loop: asyncio.AbstractEventLoop | None = None |
| 88 | self._write_lock = threading.Lock() |
| 89 | self._pending_lock = threading.Lock() |
| 90 | self._process_exit_error: str | None = None |
| 91 | self._stderr_output: list[str] = [] |
| 92 | self._stderr_lock = threading.Lock() |
| 93 | self.on_close: Callable[[], None] | None = None |
| 94 | |
| 95 | def start(self, loop: asyncio.AbstractEventLoop | None = None): |
| 96 | """Start listening for messages in background thread""" |
| 97 | if not self._running: |
| 98 | self._running = True |
| 99 | # Always use the provided loop or get the running loop |
| 100 | self._loop = loop or asyncio.get_running_loop() |
| 101 | self._read_thread = threading.Thread(target=self._read_loop, daemon=True) |
| 102 | self._read_thread.start() |
| 103 | # Start stderr reader thread if process has stderr |
| 104 | if hasattr(self.process, "stderr") and self.process.stderr: |
| 105 | self._stderr_thread = threading.Thread(target=self._stderr_loop, daemon=True) |
| 106 | self._stderr_thread.start() |
| 107 | |
| 108 | def _stderr_loop(self): |
| 109 | """Read stderr in background to capture error messages""" |
| 110 | try: |
| 111 | while self._running: |
| 112 | if not self.process.stderr: |
| 113 | break |
| 114 | line = self.process.stderr.readline() |
| 115 | if not line: |
| 116 | break |
| 117 | stderr_line = line.decode("utf-8") if isinstance(line, bytes) else line |
| 118 | logger.warning("[CLI] %s", stderr_line.rstrip()) |
| 119 | with self._stderr_lock: |
| 120 | self._stderr_output.append(stderr_line) |
| 121 | except Exception: |
| 122 | logger.debug("Error reading Copilot CLI stderr", exc_info=True) |
no outgoing calls
searching dependent graphs…