Adapter wrapping fbuild SerialMonitor as SerialInterface. fbuild's SerialMonitor uses run_until_complete() internally, which crashes when called from an already-running async event loop. This adapter offloads all sync calls to a ThreadPoolExecutor to avoid the conflict.
| 128 | |
| 129 | |
| 130 | class FbuildSerialAdapter: |
| 131 | """Adapter wrapping fbuild SerialMonitor as SerialInterface. |
| 132 | |
| 133 | fbuild's SerialMonitor uses run_until_complete() internally, which crashes |
| 134 | when called from an already-running async event loop. This adapter offloads |
| 135 | all sync calls to a ThreadPoolExecutor to avoid the conflict. |
| 136 | """ |
| 137 | |
| 138 | _warned: bool = False |
| 139 | |
| 140 | def __init__( |
| 141 | self, |
| 142 | port: str, |
| 143 | baud_rate: int = 115200, |
| 144 | auto_reconnect: bool = True, |
| 145 | verbose: bool = False, |
| 146 | ) -> None: |
| 147 | from fbuild.api import SerialMonitor |
| 148 | |
| 149 | self._monitor = SerialMonitor( |
| 150 | port=port, |
| 151 | baud_rate=baud_rate, |
| 152 | auto_reconnect=auto_reconnect, |
| 153 | verbose=verbose, |
| 154 | ) |
| 155 | self._executor = ThreadPoolExecutor(max_workers=1) |
| 156 | |
| 157 | def _warn_once(self) -> None: |
| 158 | if not FbuildSerialAdapter._warned: |
| 159 | FbuildSerialAdapter._warned = True |
| 160 | warnings.warn( |
| 161 | "Converting sync serial monitor (fbuild) to async via thread " |
| 162 | "executor. This will be removed when fbuild provides native " |
| 163 | "async support.", |
| 164 | stacklevel=3, |
| 165 | ) |
| 166 | |
| 167 | async def _run_in_thread(self, func, *args): # type: ignore[no-untyped-def] |
| 168 | self._warn_once() |
| 169 | loop = asyncio.get_running_loop() |
| 170 | return await loop.run_in_executor(self._executor, func, *args) |
| 171 | |
| 172 | async def connect(self) -> None: |
| 173 | await self._run_in_thread(self._monitor.__enter__) |
| 174 | |
| 175 | async def close(self) -> None: |
| 176 | await self._run_in_thread(self._monitor.__exit__, None, None, None) |
| 177 | # NOTE: do NOT shut down self._executor here. The autoresearch |
| 178 | # recovery path (ci/autoresearch/gpio.py) calls close() then reuses |
| 179 | # the same adapter on a new RpcClient. Shutting the executor down |
| 180 | # makes the next _run_in_thread fail with "cannot schedule new |
| 181 | # futures after shutdown" and breaks DTR-reset retries. See |
| 182 | # FastLED/fbuild#592. |
| 183 | |
| 184 | async def write(self, data: str) -> None: |
| 185 | await self._run_in_thread(self._monitor.write, data) |
| 186 | |
| 187 | async def read_lines(self, timeout: float) -> AsyncIterator[str]: |
no outgoing calls
no test coverage detected