Coverage for orchestrant/monitoring/gpu_amd.py: 53%

380 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 11:22 +0000

1"""AMD GPU probing: ADL PMLog on Windows, amdgpu sysfs on Linux.""" 

2 

3from __future__ import annotations 

4 

5import ctypes 

6import importlib 

7import sys 

8from contextlib import suppress 

9from dataclasses import dataclass 

10from functools import lru_cache 

11from pathlib import Path 

12from typing import TYPE_CHECKING, Self 

13 

14from loguru import logger 

15 

16from orchestrant.monitoring.gpu_types import GPUSnapshot 

17 

18 

19if TYPE_CHECKING: 

20 from types import ModuleType, TracebackType 

21 

22 

23# The ADL DLL load is deferred to AmdGpuProbe so importing never touches the driver. 

24AMD_AVAILABLE = bool( 

25 sys.platform == "win32" 

26 or (sys.platform.startswith("linux") and Path("/sys/class/drm").is_dir()) 

27) 

28 

29 

30@lru_cache(maxsize=1) 

31def _load_amdsmi() -> ModuleType | None: 

32 """Import AMD SMI on first use (without libamd_smi.so it raises and prints), or None.""" 

33 try: 

34 module = importlib.import_module("amdsmi") 

35 except Exception: # pragma: no cover - environment-dependent 

36 return None 

37 return module 

38 

39 

40def _read_text(path: Path) -> str | None: 

41 """Read a small sysfs file, returning None when it cannot be read.""" 

42 try: 

43 return path.read_text(encoding="utf-8").strip() 

44 except (OSError, ValueError) as exc: 

45 logger.debug("Cannot read {}: {}", path, exc) 

46 return None 

47 

48 

49def _read_int(path: Path) -> int | None: 

50 """Read an integer from a sysfs file, returning None on any failure.""" 

51 raw = _read_text(path) 

52 if raw is None: 

53 return None 

54 try: 

55 return int(raw) 

56 except ValueError: 

57 logger.debug("Not an integer in {}: {!r}", path, raw) 

58 return None 

59 

60 

61def _read_float(path: Path) -> float | None: 

62 """Read a float from a sysfs file, returning None on any failure.""" 

63 raw = _read_text(path) 

64 if raw is None: 

65 return None 

66 try: 

67 return float(raw) 

68 except ValueError: 

69 logger.debug("Not a number in {}: {!r}", path, raw) 

70 return None 

71 

72 

73def _first_sensor(sensors: dict[int, int], ids: tuple[int, ...]) -> int | None: 

74 """First present PMLog sensor among ``ids`` (priority order).""" 

75 for sensor_id in ids: 

76 if sensor_id in sensors: 

77 return sensors[sensor_id] 

78 return None 

79 

80 

81def _amdsmi_drm_card(smi: ModuleType, handle: object) -> int | None: 

82 """DRM card index AMD SMI reports for a processor handle, if any.""" 

83 try: 

84 info = smi.amdsmi_get_gpu_enumeration_info(handle) or {} 

85 except Exception as exc: 

86 logger.debug("amdsmi enumeration info failed: {}", exc) 

87 return None 

88 value = info.get("drm_card") 

89 return value if isinstance(value, int) else None 

90 

91 

92def _amdsmi_product_name(card_index: int) -> str | None: 

93 """Marketing name of an amdgpu card via optional AMD SMI; sysfs has only PCI IDs.""" 

94 smi = _load_amdsmi() 

95 if smi is None: 

96 return None 

97 try: 

98 smi.amdsmi_init() 

99 except Exception as exc: 

100 logger.debug("amdsmi init failed: {}", exc) 

101 return None 

102 try: 

103 handles = smi.amdsmi_get_processor_handles() 

104 if not handles: 

105 return None 

106 handle = None 

107 for candidate in handles: 

108 if _amdsmi_drm_card(smi, candidate) == card_index: 

109 handle = candidate 

110 break 

111 if handle is None and 0 <= card_index < len(handles): 

112 handle = handles[card_index] 

113 if handle is None: 

114 return None 

115 board = smi.amdsmi_get_gpu_board_info(handle) or {} 

116 name = board.get("product_name") or board.get("market_name") 

117 return str(name).strip() or None 

118 except Exception as exc: 

119 logger.debug("amdsmi name lookup failed: {}", exc) 

120 return None 

121 finally: 

122 with suppress(Exception): 

123 smi.amdsmi_shut_down() 

124 

125 

126class _SysfsBackend: 

127 """Linux amdgpu backend over ``/sys/class/drm/card*/device``.""" 

128 

129 backend = "sysfs" 

130 

131 def __init__(self, gpu_index: int = 0, root: Path | None = None) -> None: 

132 """Select the ``gpu_index``-th AMD card below ``root``, largest VRAM first.""" 

133 self.available = False 

134 self.name = "N/A" 

135 self.memory_type = "" 

136 self.memory_bandwidth_mbps = 0 

137 self.memory_total_bytes = 0 

138 self.handle: object | None = None 

139 self._device: Path | None = None 

140 self._hwmon: Path | None = None 

141 

142 cards = self._discover(root or Path("/sys/class/drm")) 

143 if not 0 <= gpu_index < len(cards): 

144 logger.debug( 

145 "AMD GPU index {} out of range ({} card(s) found)", 

146 gpu_index, 

147 len(cards), 

148 ) 

149 return 

150 

151 device = cards[gpu_index] 

152 self._device = device 

153 self._hwmon = self._find_hwmon(device) 

154 self.memory_total_bytes = _read_int(device / "mem_info_vram_total") or 0 

155 card_name = device.parent.name 

156 card_number = card_name.removeprefix("card") 

157 self.name = ( 

158 (_amdsmi_product_name(int(card_number)) if card_number.isdigit() else None) 

159 or _read_text(device / "product_name") 

160 or f"AMD GPU ({card_name})" 

161 ) 

162 self.handle = device 

163 self.available = True 

164 logger.success("AMD GPU monitoring initialized: {} (sysfs)", self.name) 

165 

166 @staticmethod 

167 def _discover(root: Path) -> list[Path]: 

168 """AMD device directories under drm, largest VRAM first.""" 

169 cards: list[tuple[int, int, Path]] = [] 

170 for device in root.glob("card[0-9]*/device"): 

171 vendor = (_read_text(device / "vendor") or "").lower() 

172 if vendor != "0x1002": 

173 continue 

174 card_name = device.parent.name 

175 card_number = card_name.removeprefix("card") 

176 if not card_number.isdigit(): 

177 continue 

178 vram = _read_int(device / "mem_info_vram_total") or 0 

179 cards.append((-vram, int(card_number), device)) 

180 cards.sort() 

181 return [device for _vram, _number, device in cards] 

182 

183 @staticmethod 

184 def _find_hwmon(device: Path) -> Path | None: 

185 """The ``amdgpu`` hwmon directory, or the first hwmon that has temp.""" 

186 fallback = None 

187 for candidate in sorted((device / "hwmon").glob("hwmon*")): 

188 if (_read_text(candidate / "name") or "") == "amdgpu": 

189 return candidate 

190 if fallback is None and (candidate / "temp1_input").exists(): 

191 fallback = candidate 

192 return fallback 

193 

194 def read(self) -> GPUSnapshot | None: 

195 """One sysfs read of utilization, VRAM, temperature and power.""" 

196 if not self.available or self._device is None: 

197 return None 

198 device = self._device 

199 temperature = 0.0 

200 power = 0.0 

201 if self._hwmon is not None: 

202 temp_raw = _read_float(self._hwmon / "temp1_input") 

203 temperature = (temp_raw or 0.0) / 1000.0 

204 power_raw = _read_float(self._hwmon / "power1_average") 

205 if power_raw is None: 

206 power_raw = _read_float(self._hwmon / "power1_input") 

207 power = (power_raw or 0.0) / 1_000_000.0 

208 return GPUSnapshot( 

209 utilization=float(_read_int(device / "gpu_busy_percent") or 0), 

210 memory_used_bytes=_read_int(device / "mem_info_vram_used") or 0, 

211 memory_total_bytes=self.memory_total_bytes, 

212 temperature_celsius=temperature, 

213 power_watts=power, 

214 vendor="amd", 

215 ) 

216 

217 def shutdown(self) -> None: 

218 """Nothing to release: sysfs reads hold no resources.""" 

219 self.available = False 

220 

221 

222# Windows: AMD Display Library (ADL) 

223 

224_ADL_MAX_PATH = 256 

225_ADL_OK = 0 

226# ADL reports the PCI vendor ID in decimal (1002), not as 0x1002. 

227_ADL_VENDOR_AMD = 1002 

228_PMLOG_MAX_SENSORS = 256 

229 

230# ADL_PMLOG_SENSORS IDs in preference order: the first supported sensor wins. 

231_PMLOG_TEMP_IDS = (8, 27, 28, 29, 9) # EDGE, HOTSPOT, GFX, SOC, MEM 

232_PMLOG_POWER_IDS = (23, 73, 30, 17) # ASIC, BOARD, GFX, SOC 

233_PMLOG_ACTIVITY_GFX = 19 

234 

235 

236class _AdlUnavailableError(RuntimeError): 

237 """Raised when the ADL library or its context cannot be brought up.""" 

238 

239 

240class _AdlAdapterInfo(ctypes.Structure): 

241 """Modern ``AdapterInfo`` layout (1572 bytes on x64); pyadl's smaller one fails with -3.""" 

242 

243 _fields_ = [ 

244 ("iSize", ctypes.c_int), 

245 ("iAdapterIndex", ctypes.c_int), 

246 ("strUDID", ctypes.c_char * _ADL_MAX_PATH), 

247 ("iBusNumber", ctypes.c_int), 

248 ("iDeviceNumber", ctypes.c_int), 

249 ("iFunctionNumber", ctypes.c_int), 

250 ("iVendorID", ctypes.c_int), 

251 ("strAdapterName", ctypes.c_char * _ADL_MAX_PATH), 

252 ("strDisplayName", ctypes.c_char * _ADL_MAX_PATH), 

253 ("iPresent", ctypes.c_int), 

254 ("iExist", ctypes.c_int), 

255 ("strDriverPath", ctypes.c_char * _ADL_MAX_PATH), 

256 ("strDriverPathExt", ctypes.c_char * _ADL_MAX_PATH), 

257 ("strPNPString", ctypes.c_char * _ADL_MAX_PATH), 

258 ("iOSDisplayIndex", ctypes.c_int), 

259 ] 

260 

261 

262class _AdlMemoryInfo(ctypes.Structure): 

263 """``ADL2_Adapter_MemoryInfo_Get`` payload; padding guards newer fields.""" 

264 

265 _fields_ = [ 

266 ("iMemorySize", ctypes.c_longlong), 

267 ("strMemoryType", ctypes.c_char * _ADL_MAX_PATH), 

268 ("iMemoryBandwidth", ctypes.c_longlong), 

269 ("reserved", ctypes.c_char * 512), 

270 ] 

271 

272 

273class _AdlSingleSensor(ctypes.Structure): 

274 """One PMLog sensor: a supported flag plus its current value.""" 

275 

276 _fields_ = [("supported", ctypes.c_int), ("value", ctypes.c_int)] 

277 

278 

279class _AdlPMLogData(ctypes.Structure): 

280 """Output of ``ADL2_New_QueryPMLogData_Get`` (256 sensor slots).""" 

281 

282 _fields_ = [ 

283 ("iSize", ctypes.c_int), 

284 ("sensors", _AdlSingleSensor * _PMLOG_MAX_SENSORS), 

285 ] 

286 

287 

288@dataclass(frozen=True) 

289class _AdlAdapter: 

290 """One deduplicated AMD display adapter as ADL sees it.""" 

291 

292 index: int 

293 name: str 

294 pnp_id: str 

295 memory_total_bytes: int 

296 memory_type: str 

297 memory_bandwidth_mbps: int 

298 

299 

300def _decode(raw: bytes) -> str: 

301 """Decode a fixed-size ADL C string, trimming at the first NUL.""" 

302 return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace").strip() 

303 

304 

305class _AdlApi: 

306 """ctypes binding over the ADL DLL; tests substitute its three public methods.""" 

307 

308 _MALLOC_CB = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_int) 

309 

310 # Annotation only: a linux-targeting checker sees __init__'s binding as unreachable. 

311 _lib: ctypes.CDLL 

312 _msvcrt: ctypes.CDLL 

313 

314 def __init__(self) -> None: 

315 """Load ``atiadlxx.dll`` and open an ADL2 context.""" 

316 if sys.platform != "win32": 

317 msg = "ADL is only available on Windows" 

318 raise _AdlUnavailableError(msg) 

319 self._lib = self._load_dll() 

320 self._msvcrt = ctypes.CDLL("msvcrt.dll") 

321 self._malloc = self._MALLOC_CB(self._alloc) 

322 self._ctx: ctypes.c_void_p | None = ctypes.c_void_p() 

323 self._bind_required() 

324 self._bind_optional() 

325 if self._create(self._malloc, 1, ctypes.byref(self._ctx)) != _ADL_OK: 

326 msg = "ADL2_Main_Control_Create failed" 

327 raise _AdlUnavailableError(msg) 

328 

329 def _alloc(self, size: int) -> int | None: 

330 """Allocator callback ADL calls for its internal buffers.""" 

331 return self._msvcrt.malloc(size) 

332 

333 @staticmethod 

334 def _load_dll() -> ctypes.CDLL: 

335 """Load the 64-bit ADL DLL, falling back to the 32-bit one.""" 

336 last_error: OSError | None = None 

337 for dll in ("atiadlxx.dll", "atiadlxy.dll"): 

338 try: 

339 return ctypes.CDLL(dll) 

340 except OSError as exc: 

341 last_error = exc 

342 msg = f"ADL library not loadable: {last_error}" 

343 raise _AdlUnavailableError(msg) 

344 

345 def _bind_required(self) -> None: 

346 """Bind the functions every ADL version exposes.""" 

347 lib = self._lib 

348 self._create = lib.ADL2_Main_Control_Create 

349 self._create.restype = ctypes.c_int 

350 self._create.argtypes = [ 

351 self._MALLOC_CB, 

352 ctypes.c_int, 

353 ctypes.POINTER(ctypes.c_void_p), 

354 ] 

355 self._destroy = lib.ADL2_Main_Control_Destroy 

356 self._destroy.restype = ctypes.c_int 

357 self._destroy.argtypes = [ctypes.c_void_p] 

358 self._number = lib.ADL2_Adapter_NumberOfAdapters_Get 

359 self._number.restype = ctypes.c_int 

360 self._number.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)] 

361 self._adapter_info = lib.ADL2_Adapter_AdapterInfo_Get 

362 self._adapter_info.restype = ctypes.c_int 

363 self._adapter_info.argtypes = [ 

364 ctypes.c_void_p, 

365 ctypes.POINTER(_AdlAdapterInfo), 

366 ctypes.c_int, 

367 ] 

368 self._adapter_id = lib.ADL2_Adapter_ID_Get 

369 self._adapter_id.restype = ctypes.c_int 

370 self._adapter_id.argtypes = [ 

371 ctypes.c_void_p, 

372 ctypes.c_int, 

373 ctypes.POINTER(ctypes.c_int), 

374 ] 

375 self._query_pmlog = lib.ADL2_New_QueryPMLogData_Get 

376 self._query_pmlog.restype = ctypes.c_int 

377 self._query_pmlog.argtypes = [ 

378 ctypes.c_void_p, 

379 ctypes.c_int, 

380 ctypes.POINTER(_AdlPMLogData), 

381 ] 

382 

383 def _bind_optional(self) -> None: 

384 """Bind functions older drivers may not export.""" 

385 try: 

386 self._memory_info = self._lib.ADL2_Adapter_MemoryInfo_Get 

387 self._memory_info.restype = ctypes.c_int 

388 self._memory_info.argtypes = [ 

389 ctypes.c_void_p, 

390 ctypes.c_int, 

391 ctypes.POINTER(_AdlMemoryInfo), 

392 ] 

393 except AttributeError: 

394 self._memory_info = None 

395 try: 

396 self._vram_usage = self._lib.ADL2_Adapter_DedicatedVRAMUsage_Get 

397 self._vram_usage.restype = ctypes.c_int 

398 self._vram_usage.argtypes = [ 

399 ctypes.c_void_p, 

400 ctypes.c_int, 

401 ctypes.POINTER(ctypes.c_int), 

402 ] 

403 except AttributeError: 

404 self._vram_usage = None 

405 

406 def list_adapters(self) -> list[_AdlAdapter]: 

407 """AMD adapters, deduplicated by adapter ID and with VRAM size.""" 

408 context = self._ctx 

409 if context is None: 

410 return [] 

411 number = ctypes.c_int(0) 

412 if self._number(context, ctypes.byref(number)) != _ADL_OK or number.value <= 0: 

413 return [] 

414 entries = (_AdlAdapterInfo * number.value)() 

415 if ( 

416 self._adapter_info( 

417 context, 

418 ctypes.cast(entries, ctypes.POINTER(_AdlAdapterInfo)), 

419 ctypes.sizeof(entries), 

420 ) 

421 != _ADL_OK 

422 ): 

423 return [] 

424 adapters: list[_AdlAdapter] = [] 

425 seen: set[int] = set() 

426 for entry in entries: 

427 if entry.iVendorID != _ADL_VENDOR_AMD: 

428 continue 

429 adapter_id = ctypes.c_int(0) 

430 if ( 

431 self._adapter_id(context, entry.iAdapterIndex, ctypes.byref(adapter_id)) 

432 != _ADL_OK 

433 ): 

434 continue 

435 if adapter_id.value in seen: 

436 continue 

437 seen.add(adapter_id.value) 

438 memory_total = 0 

439 memory_type = "" 

440 bandwidth = 0 

441 memory = _AdlMemoryInfo() 

442 if ( 

443 self._memory_info is not None 

444 and self._memory_info( 

445 context, entry.iAdapterIndex, ctypes.byref(memory) 

446 ) 

447 == _ADL_OK 

448 ): 

449 memory_total = int(memory.iMemorySize) 

450 memory_type = _decode(memory.strMemoryType) 

451 bandwidth = int(memory.iMemoryBandwidth) 

452 adapters.append( 

453 _AdlAdapter( 

454 index=entry.iAdapterIndex, 

455 name=_decode(entry.strAdapterName) 

456 or f"AMD GPU {entry.iAdapterIndex}", 

457 pnp_id=_decode(entry.strPNPString), 

458 memory_total_bytes=memory_total, 

459 memory_type=memory_type, 

460 memory_bandwidth_mbps=bandwidth, 

461 ) 

462 ) 

463 return adapters 

464 

465 def read_sensors(self, adapter_index: int) -> dict[int, int]: 

466 """Current PMLog sensor values, keyed by ``ADL_PMLOG_*`` ID.""" 

467 context = self._ctx 

468 if context is None: 

469 return {} 

470 data = _AdlPMLogData() 

471 data.iSize = ctypes.sizeof(data) 

472 if self._query_pmlog(context, adapter_index, ctypes.byref(data)) != _ADL_OK: 

473 return {} 

474 return {i: s.value for i, s in enumerate(data.sensors) if s.supported} 

475 

476 def vram_used_mb(self, adapter_index: int) -> int | None: 

477 """Dedicated VRAM in use, in MiB, when the driver reports it.""" 

478 context = self._ctx 

479 if context is None or self._vram_usage is None: 

480 return None 

481 usage = ctypes.c_int(-1) 

482 if ( 

483 self._vram_usage(context, adapter_index, ctypes.byref(usage)) != _ADL_OK 

484 or usage.value < 0 

485 ): 

486 return None 

487 return int(usage.value) 

488 

489 def close(self) -> None: 

490 """Destroy the ADL context. Safe to call more than once.""" 

491 if self._ctx is None: 

492 return 

493 with suppress(Exception): 

494 self._destroy(self._ctx) 

495 self._ctx = None 

496 

497 

498class _AdlBackend: 

499 """Windows AMD backend over the ADL PMLog sensors.""" 

500 

501 backend = "adl" 

502 

503 def __init__(self, gpu_index: int = 0, api: _AdlApi | None = None) -> None: 

504 """Open ADL and select the AMD adapter with the most VRAM.""" 

505 self.available = False 

506 self.name = "N/A" 

507 self.memory_type = "" 

508 self.memory_bandwidth_mbps = 0 

509 self.memory_total_bytes = 0 

510 self.handle: object | None = None 

511 self._api: _AdlApi | None = None 

512 self._index = -1 

513 

514 try: 

515 adl = api if api is not None else _AdlApi() 

516 except Exception as exc: 

517 logger.debug("AMD ADL backend unavailable: {}", exc) 

518 return 

519 try: 

520 adapters = sorted( 

521 adl.list_adapters(), 

522 key=lambda adapter: (-adapter.memory_total_bytes, adapter.index), 

523 ) 

524 except Exception as exc: 

525 logger.debug("AMD adapter enumeration failed: {}", exc) 

526 adl.close() 

527 return 

528 if not adapters: 

529 adl.close() 

530 logger.debug("No AMD display adapter reported by ADL") 

531 return 

532 if not 0 <= gpu_index < len(adapters): 

533 adl.close() 

534 logger.debug( 

535 "AMD GPU index {} out of range ({} adapter(s) found)", 

536 gpu_index, 

537 len(adapters), 

538 ) 

539 return 

540 chosen = adapters[gpu_index] 

541 self._api = adl 

542 self._index = chosen.index 

543 self.name = chosen.name 

544 self.memory_type = chosen.memory_type 

545 self.memory_bandwidth_mbps = chosen.memory_bandwidth_mbps 

546 self.memory_total_bytes = chosen.memory_total_bytes 

547 self.handle = chosen.index 

548 self.available = True 

549 logger.success("AMD GPU monitoring initialized: {} (ADL)", self.name) 

550 

551 def read(self) -> GPUSnapshot | None: 

552 """One ADL read of utilization, VRAM, temperature and power.""" 

553 if not self.available or self._api is None: 

554 return None 

555 sensors = self._api.read_sensors(self._index) 

556 if not sensors: 

557 return None 

558 used_mb = self._api.vram_used_mb(self._index) 

559 return GPUSnapshot( 

560 utilization=float(sensors.get(_PMLOG_ACTIVITY_GFX, 0)), 

561 memory_used_bytes=(used_mb or 0) * 1024**2, 

562 memory_total_bytes=self.memory_total_bytes, 

563 temperature_celsius=float(_first_sensor(sensors, _PMLOG_TEMP_IDS) or 0), 

564 power_watts=float(_first_sensor(sensors, _PMLOG_POWER_IDS) or 0), 

565 vendor="amd", 

566 ) 

567 

568 def shutdown(self) -> None: 

569 """Destroy the ADL context and mark the backend unavailable.""" 

570 if self._api is not None: 

571 self._api.close() 

572 self._api = None 

573 self.available = False 

574 

575 

576class AmdGpuProbe: 

577 """Vendor-side probe picking the AMD backend for this platform.""" 

578 

579 def __init__(self, gpu_index: int = 0) -> None: 

580 """Construct the platform backend and adopt it when it answers.""" 

581 self.available = False 

582 self.name = "N/A" 

583 self.backend = "none" 

584 self.memory_type = "" 

585 self.memory_bandwidth_mbps = 0 

586 self.handle: object | None = None 

587 self._backend: _AdlBackend | _SysfsBackend | None = None 

588 

589 backend: _AdlBackend | _SysfsBackend | None = None 

590 if sys.platform == "win32": 

591 backend = _AdlBackend(gpu_index) 

592 elif sys.platform.startswith("linux"): 

593 backend = _SysfsBackend(gpu_index) 

594 if backend is None or not backend.available: 

595 return 

596 self._backend = backend 

597 self.available = True 

598 self.name = backend.name 

599 self.backend = backend.backend 

600 self.memory_type = backend.memory_type 

601 self.memory_bandwidth_mbps = backend.memory_bandwidth_mbps 

602 self.handle = backend.handle 

603 

604 def read(self) -> GPUSnapshot | None: 

605 """Read the current GPU metrics, or None when unavailable.""" 

606 if self._backend is None: 

607 return None 

608 return self._backend.read() 

609 

610 def shutdown(self) -> None: 

611 """Release backend resources. Safe to call multiple times.""" 

612 if self._backend is not None: 

613 self._backend.shutdown() 

614 self._backend = None 

615 self.available = False 

616 

617 def __enter__(self) -> Self: 

618 """Enter context manager.""" 

619 return self 

620 

621 def __exit__( 

622 self, 

623 _exc_type: type[BaseException] | None, 

624 _exc_val: BaseException | None, 

625 _exc_tb: TracebackType | None, 

626 ) -> None: 

627 """Exit context manager and ensure cleanup.""" 

628 self.shutdown()