Skip to content

Commit ab89c8e

Browse files
committed
feat(portal): add atexit handler for graceful shutdown
Register cleanup handler on first portal creation to ensure background thread and event loop are properly stopped when the interpreter exits. Prevents resource leaks and ensures pending async operations complete.
1 parent 9b7a186 commit ab89c8e

File tree

2 files changed

+63
-1
lines changed

2 files changed

+63
-1
lines changed

sqlspec/utils/portal.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import asyncio
8+
import atexit
89
import functools
910
import os
1011
import queue
@@ -257,12 +258,13 @@ def __init__(self) -> None:
257258
self._portal: Portal | None = None
258259
self._lock = threading.Lock()
259260
self._pid: int | None = None
261+
self._atexit_registered: bool = False
260262

261263
def get_or_create_portal(self) -> Portal:
262264
"""Get or create the global portal instance.
263265
264266
Lazily creates and starts the portal provider on first access.
265-
Thread-safe via locking.
267+
Thread-safe via locking. Registers an atexit handler for cleanup.
266268
267269
Returns:
268270
Global portal instance.
@@ -278,10 +280,31 @@ def get_or_create_portal(self) -> Portal:
278280
self._provider.start()
279281
self._portal = Portal(self._provider)
280282
self._pid = current_pid
283+
self._register_atexit()
281284
logger.debug("Global portal provider created and started")
282285

283286
return cast("Portal", self._portal)
284287

288+
def _register_atexit(self) -> None:
289+
"""Register atexit handler for graceful shutdown.
290+
291+
Only registers once per process to avoid duplicate cleanup.
292+
"""
293+
if not self._atexit_registered:
294+
atexit.register(self._atexit_cleanup)
295+
self._atexit_registered = True
296+
logger.debug("Portal atexit handler registered")
297+
298+
def _atexit_cleanup(self) -> None:
299+
"""Cleanup handler called at interpreter shutdown.
300+
301+
Gracefully stops the portal provider to ensure pending
302+
async operations complete before the process exits.
303+
"""
304+
if self._provider is not None and self._provider.is_running:
305+
logger.debug("Portal atexit cleanup: stopping provider")
306+
self.stop()
307+
285308
@property
286309
def is_running(self) -> bool:
287310
"""Check if global portal is running.

tests/unit/test_utils/test_portal.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,42 @@ def get_portal() -> None:
342342
assert all(p is portals[0] for p in portals)
343343

344344
manager.stop()
345+
346+
347+
def test_portal_manager_atexit_registration() -> None:
348+
"""PortalManager registers atexit handler on portal creation."""
349+
manager = PortalManager()
350+
351+
assert not manager._atexit_registered
352+
353+
manager.get_or_create_portal()
354+
355+
assert manager._atexit_registered
356+
assert manager.is_running
357+
358+
manager.stop()
359+
360+
361+
def test_portal_manager_atexit_cleanup() -> None:
362+
"""PortalManager._atexit_cleanup stops running provider."""
363+
manager = PortalManager()
364+
manager.get_or_create_portal()
365+
366+
assert manager.is_running
367+
368+
manager._atexit_cleanup()
369+
370+
assert not manager.is_running
371+
372+
373+
def test_portal_manager_atexit_cleanup_noop_when_stopped() -> None:
374+
"""PortalManager._atexit_cleanup is no-op when already stopped."""
375+
manager = PortalManager()
376+
manager.get_or_create_portal()
377+
manager.stop()
378+
379+
assert not manager.is_running
380+
381+
manager._atexit_cleanup()
382+
383+
assert not manager.is_running

0 commit comments

Comments
 (0)