Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions python/grass/pygrass/rpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,6 @@ def _fatal_error(lock, conn, data):

def _stop(lock, conn, data):
conn.close()
lock.release()
sys.exit()


Expand All @@ -205,11 +204,9 @@ def error_handler(data):
"""This function will be called in case of a fatal error in libgis"""
# sys.stderr.write("Error handler was called\n")
# We send an exception that will be handled in
# the parent process, then close the pipe
# and release any possible lock
# the parent process, then close the pipe.
conn.send(FatalError("G_fatal_error() was called in the server process"))
conn.close()
lock.release()

CALLBACK = CFUNCTYPE(c_void_p, c_void_p)
CALLBACK.restype = c_void_p
Expand Down
19 changes: 18 additions & 1 deletion python/grass/pygrass/rpc/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import logging
import multiprocessing.util
import sys
import threading
import time
Expand All @@ -26,6 +27,10 @@

logger: logging.Logger = logging.getLogger(__name__)

# The function is not documented (although multiprocessing.queues uses it),
# so do not fail if it is missing.
_mp_is_exiting = getattr(multiprocessing.util, "is_exiting", lambda: False)


###############################################################################

Expand Down Expand Up @@ -131,10 +136,16 @@ def thread_checker(self):
"""Check every 200 micro seconds if the server process is alive"""
while True:
time.sleep(0.2)
self._check_restart_server(caller="Server check thread")
# Check before restarting so that a server is not restarted
# once stop was requested.
with self.threadLock:
if self.stopThread is True:
return
try:
self._check_restart_server(caller="Server check thread")
except FatalError:
# Raised only when the server was terminated at exit.
return

def start_server(self):
"""This function must be re-implemented in the subclasses"""
Expand All @@ -159,6 +170,10 @@ def _check_restart_server(self, caller="main thread") -> None:
with self.threadLock:
if self.server is not None and self.server.is_alive() is True:
return
# Do not restart a server that multiprocessing terminated at exit.
if _mp_is_exiting():
msg = f"The libgis server was terminated at exit, caller: {caller}"
raise FatalError(msg)
if self.client_conn is not None:
self.client_conn.close()
if self.server_conn is not None:
Expand Down Expand Up @@ -206,6 +221,8 @@ def stop(self):
0,
]
)
# Let the server exit on its own before falling back to terminate.
self.server.join(timeout=5)
self.server.terminate()
# A process still starting up reopens the lock semaphore by name,
# so it must be gone before the reference is dropped below.
Expand Down
25 changes: 25 additions & 0 deletions python/grass/pygrass/rpc/tests/grass_pygrass_rpc_base_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Copyright (C) 2026 by the GRASS Development Team
# SPDX-License-Identifier: GPL-2.0-or-later

"""Test the RPC server base class"""

import pytest

from grass.exceptions import FatalError
from grass.pygrass.rpc import base


def test_no_restart_at_exit(monkeypatch):
"""A server terminated at exit is not restarted and the check raises"""
provider = base.RPCServerBase()
try:
monkeypatch.setattr(base, "_mp_is_exiting", lambda: True)
server = provider.server
server.terminate()
server.join()
with pytest.raises(FatalError, match="terminated at exit"):
provider.check_server()
assert provider.server is server
assert not provider.is_server_alive()
finally:
provider.stop()
5 changes: 1 addition & 4 deletions python/grass/temporal/c_libraries_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,6 @@ def _convert_timestamp_from_grass(ts):
def _stop(lock: _LockLike, conn: Connection, data) -> None:
libgis.G_debug(1, "Stop C-interface server")
conn.close()
lock.release()
sys.exit()


Expand All @@ -1196,11 +1195,9 @@ def error_handler(data) -> None:
"""This function will be called in case of a fatal error in libgis"""
# sys.stderr.write("Error handler was called\n")
# We send an exception that will be handled in
# the parent process, then close the pipe
# and release any possible lock
# the parent process, then close the pipe.
conn.send(FatalError())
conn.close()
lock.release()

CALLBACK = CFUNCTYPE(c_void_p, c_void_p)
CALLBACK.restype = c_void_p
Expand Down
5 changes: 4 additions & 1 deletion python/grass/temporal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from __future__ import annotations

import atexit
import multiprocessing.util # noqa: F401
import os
import sqlite3
from datetime import datetime
Expand Down Expand Up @@ -491,7 +492,9 @@ def stop_subprocesses() -> None:
c_library_interface.stop()


# We register this function to be called at exit
# Importing multiprocessing.util registers the multiprocessing exit handler,
# so this function, registered later, runs before it (atexit is LIFO) and stops
# the servers before multiprocessing terminates them.
atexit.register(stop_subprocesses)


Expand Down
Loading