Skip to content

Commit 1ab59cb

Browse files
authored
Merge pull request #38 from wey-gu/copilot/fix-31
Fix incomplete process cleanup leaving orphaned pglite_manager.js processes
2 parents 1a566bb + 81208c0 commit 1ab59cb

5 files changed

Lines changed: 458 additions & 15 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "uv_build"
44

55
[project]
66
name = "py-pglite"
7-
version = "0.5.2"
7+
version = "0.5.3"
88
description = "Python testing library for PGlite - in-memory PostgreSQL for tests"
99
readme = "README.md"
1010
license = "Apache-2.0"

src/py_pglite/manager.py

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,17 +278,28 @@ def _cleanup_socket(self) -> None:
278278
def _kill_existing_processes(self) -> None:
279279
"""Kill any existing PGlite processes that might conflict with this socket."""
280280
try:
281-
my_socket_dir = str(Path(self.config.socket_path).parent)
281+
# Fix for issue #31: Compare work directory, not socket directory
282+
# Socket and work directories are different by design for isolation
283+
# Use work_dir if available, otherwise fall back to socket directory
284+
if hasattr(self, "work_dir") and self.work_dir:
285+
my_target_dir = str(self.work_dir)
286+
comparison_type = "work directory"
287+
else:
288+
my_target_dir = str(Path(self.config.socket_path).parent)
289+
comparison_type = "socket directory"
290+
282291
for proc in psutil.process_iter(["pid", "name", "cmdline", "cwd"]):
283292
if proc.info["cmdline"] and any(
284293
"pglite_manager.js" in cmd for cmd in proc.info["cmdline"]
285294
):
286-
# Only kill processes in the same socket directory to avoid killing other instances
295+
# Use exact directory match to avoid killing processes in similar paths
287296
try:
288297
proc_cwd = proc.info.get("cwd", "")
289-
if my_socket_dir in proc_cwd or proc_cwd in my_socket_dir:
298+
if proc_cwd == my_target_dir:
290299
pid = proc.info["pid"]
291-
self.logger.info(f"Killing existing PGlite process: {pid}")
300+
self.logger.info(
301+
f"Killing existing PGlite process: {pid} (matching {comparison_type})"
302+
)
292303
proc.kill()
293304
proc.wait(timeout=5)
294305
except (psutil.NoSuchProcess, psutil.AccessDenied):
@@ -297,6 +308,31 @@ def _kill_existing_processes(self) -> None:
297308
except Exception as e:
298309
self.logger.warning(f"Error killing existing PGlite processes: {e}")
299310

311+
def _kill_all_pglite_processes(self) -> None:
312+
"""Kill all PGlite processes globally (more aggressive cleanup for termination)."""
313+
try:
314+
killed_processes = []
315+
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
316+
if proc.info["cmdline"] and any(
317+
"pglite_manager.js" in cmd for cmd in proc.info["cmdline"]
318+
):
319+
try:
320+
pid = proc.info["pid"]
321+
self.logger.info(f"Killing PGlite process globally: {pid}")
322+
proc.kill()
323+
proc.wait(timeout=5)
324+
killed_processes.append(pid)
325+
except (psutil.NoSuchProcess, psutil.AccessDenied):
326+
# Process already gone or can't access it
327+
continue
328+
329+
if killed_processes:
330+
self.logger.info(
331+
f"Killed {len(killed_processes)} PGlite processes: {killed_processes}"
332+
)
333+
except Exception as e:
334+
self.logger.warning(f"Error killing all PGlite processes: {e}")
335+
300336
def _install_dependencies(self, work_dir: Path) -> None:
301337
"""Install npm dependencies if needed."""
302338
if not self.config.auto_install_deps:
@@ -322,12 +358,13 @@ def start(self) -> None:
322358
self.logger.warning("PGlite process already running")
323359
return
324360

361+
# Setup work directory first so it's available for cleanup logic
362+
self.work_dir = self._setup_work_dir()
363+
325364
# Setup
326365
self._kill_existing_processes()
327366
self._cleanup_socket()
328367

329-
# Setup work directory
330-
self.work_dir = self._setup_work_dir()
331368
self._original_cwd = os.getcwd()
332369
os.chdir(self.work_dir)
333370

@@ -360,6 +397,9 @@ def start(self) -> None:
360397
bufsize=0, # Unbuffered for real-time monitoring
361398
universal_newlines=True,
362399
env=env,
400+
preexec_fn=os.setsid
401+
if hasattr(os, "setsid")
402+
else None, # Create new process group on Unix
363403
)
364404

365405
# Wait for startup with robust monitoring
@@ -462,7 +502,18 @@ def stop(self) -> None:
462502
try:
463503
# Send SIGTERM first for graceful shutdown
464504
self.logger.debug("Sending SIGTERM to PGlite process...")
465-
self.process.terminate()
505+
506+
# Try to terminate the entire process group if it exists
507+
if hasattr(os, "killpg") and hasattr(self.process, "pid"):
508+
try:
509+
# Try to kill the process group first (includes child processes)
510+
os.killpg(os.getpgid(self.process.pid), 15) # SIGTERM
511+
self.logger.debug("Sent SIGTERM to process group")
512+
except (OSError, ProcessLookupError):
513+
# Fall back to single process termination
514+
self.process.terminate()
515+
else:
516+
self.process.terminate()
466517

467518
# Wait for graceful shutdown with timeout
468519
try:
@@ -473,17 +524,32 @@ def stop(self) -> None:
473524
self.logger.warning(
474525
"PGlite process didn't stop gracefully, force killing..."
475526
)
476-
self.process.kill()
527+
528+
# Try to kill the entire process group first
529+
if hasattr(os, "killpg") and hasattr(self.process, "pid"):
530+
try:
531+
os.killpg(os.getpgid(self.process.pid), 9) # SIGKILL
532+
self.logger.debug("Sent SIGKILL to process group")
533+
except (OSError, ProcessLookupError):
534+
# Fall back to single process kill
535+
self.process.kill()
536+
else:
537+
self.process.kill()
538+
477539
try:
478540
self.process.wait(timeout=2)
479541
self.logger.info("PGlite server stopped forcefully")
480542
except subprocess.TimeoutExpired:
481543
self.logger.error("Failed to kill PGlite process!")
544+
# Use global cleanup as last resort when normal termination fails
545+
self._kill_all_pglite_processes()
482546

483547
except Exception as e:
484548
self.logger.warning(f"Error stopping PGlite: {e}")
485549
finally:
486550
self.process = None
551+
# Additional cleanup: kill any remaining pglite processes
552+
# Note: Global cleanup is only used in error conditions, not normal stop
487553
if self.config.cleanup_on_exit:
488554
self._cleanup_socket()
489555

src/py_pglite/sqlalchemy/manager.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Extends the core PGliteManager with SQLAlchemy-specific functionality.
44
"""
55

6+
import os
67
import time
78

89
from typing import Any
@@ -183,7 +184,18 @@ def stop(self) -> None:
183184
try:
184185
# Send SIGTERM first for graceful shutdown
185186
self.logger.debug("Sending SIGTERM to PGlite process...")
186-
self.process.terminate()
187+
188+
# Try to terminate the entire process group if it exists
189+
if hasattr(os, "killpg") and hasattr(self.process, "pid"):
190+
try:
191+
# Try to kill the process group first (includes child processes)
192+
os.killpg(os.getpgid(self.process.pid), 15) # SIGTERM
193+
self.logger.debug("Sent SIGTERM to process group")
194+
except (OSError, ProcessLookupError):
195+
# Fall back to single process termination
196+
self.process.terminate()
197+
else:
198+
self.process.terminate()
187199

188200
# Wait for graceful shutdown with timeout
189201
try:
@@ -194,7 +206,18 @@ def stop(self) -> None:
194206
self.logger.warning(
195207
"PGlite process didn't stop gracefully, force killing..."
196208
)
197-
self.process.kill()
209+
210+
# Try to kill the entire process group first
211+
if hasattr(os, "killpg") and hasattr(self.process, "pid"):
212+
try:
213+
os.killpg(os.getpgid(self.process.pid), 9) # SIGKILL
214+
self.logger.debug("Sent SIGKILL to process group")
215+
except (OSError, ProcessLookupError):
216+
# Fall back to single process kill
217+
self.process.kill()
218+
else:
219+
self.process.kill()
220+
198221
try:
199222
self.process.wait(timeout=2)
200223
self.logger.info("PGlite server stopped forcefully")
@@ -213,6 +236,8 @@ def stop(self) -> None:
213236
self.logger.warning(f"Error disposing engine: {e}")
214237
finally:
215238
self._shared_engine = None
239+
# Additional cleanup: kill any remaining pglite processes
240+
self._kill_all_pglite_processes()
216241
if self.config.cleanup_on_exit:
217242
self._cleanup_socket()
218243

0 commit comments

Comments
 (0)