From a1b8ef0f2f1b751929c78c0d57000797a6604e1f Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Wed, 15 Jul 2026 06:39:04 -0400 Subject: [PATCH 1/3] grass.pygrass: Pin fork start method for RPC servers on Python 3.14 Python 3.14 changed the default multiprocessing start method from fork to forkserver on all platforms except macOS (python/cpython#84559). The RPC servers in grass.pygrass.rpc and grass.temporal rely on fork: their worker processes call libgis via ctypes and must inherit the initialized library state and the GRASS session environment from the parent. Under forkserver the worker is forked from an early, clean process without the GRASS runtime, and the forkserver socket teardown raises FileNotFoundError in the server-checker thread. Define a shared multiprocessing context in grass.pygrass.rpc.base that pins fork where it was the previous default, and reuse it in the three start_server implementations. macOS (already spawn) and Windows (no fork) keep their default start method, so the change is a no-op there and on Python <= 3.13. Root-caused and drafted with the help of Claude (Opus 4.8). --- python/grass/pygrass/rpc/__init__.py | 9 ++++---- python/grass/pygrass/rpc/base.py | 21 +++++++++++++++---- .../grass/temporal/c_libraries_interface.py | 9 ++++---- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/python/grass/pygrass/rpc/__init__.py b/python/grass/pygrass/rpc/__init__.py index 22b3a1b1013..af7e2d6eba3 100644 --- a/python/grass/pygrass/rpc/__init__.py +++ b/python/grass/pygrass/rpc/__init__.py @@ -12,7 +12,6 @@ import sys from ctypes import CFUNCTYPE, c_void_p -from multiprocessing import Lock, Pipe, Process import grass.lib.gis as libgis from grass.exceptions import FatalError @@ -22,7 +21,7 @@ from grass.pygrass.vector import VectorTopo from grass.pygrass.vector.basic import Bbox -from .base import RPCServerBase +from .base import MP_CONTEXT, RPCServerBase ############################################################################### ############################################################################### @@ -249,9 +248,9 @@ def __init__(self): def start_server(self): """This function must be re-implemented in the subclasses""" - self.client_conn, self.server_conn = Pipe(True) - self.lock = Lock() - self.server = Process( + self.client_conn, self.server_conn = MP_CONTEXT.Pipe(True) + self.lock = MP_CONTEXT.Lock() + self.server = MP_CONTEXT.Process( target=data_provider_server, args=(self.lock, self.server_conn) ) self.server.daemon = True diff --git a/python/grass/pygrass/rpc/base.py b/python/grass/pygrass/rpc/base.py index a19f79f355e..71c82608f71 100644 --- a/python/grass/pygrass/rpc/base.py +++ b/python/grass/pygrass/rpc/base.py @@ -16,7 +16,7 @@ import sys import threading import time -from multiprocessing import Lock, Pipe, Process +import multiprocessing as mp from typing import TYPE_CHECKING, NoReturn from grass.exceptions import FatalError @@ -28,6 +28,17 @@ logger: logging.Logger = logging.getLogger(__name__) +# Python 3.14 changed the default multiprocessing start method from "fork" to +# "forkserver" on all platforms except macOS. The RPC worker process relies on +# "fork" semantics: subclasses run libgis via ctypes in the worker, which must +# inherit the initialized library state and the GRASS session environment from +# the parent. Pin "fork" where it used to be the default; macOS and Windows +# keep their default start method ("spawn"). +if sys.platform != "darwin" and "fork" in mp.get_all_start_methods(): + MP_CONTEXT = mp.get_context("fork") +else: + MP_CONTEXT = mp.get_context() + ############################################################################### @@ -142,9 +153,11 @@ def start_server(self): """This function must be re-implemented in the subclasses""" logger.debug("Start the libgis server") - self.client_conn, self.server_conn = Pipe(True) - self.lock = Lock() - self.server = Process(target=dummy_server, args=(self.lock, self.server_conn)) + self.client_conn, self.server_conn = MP_CONTEXT.Pipe(True) + self.lock = MP_CONTEXT.Lock() + self.server = MP_CONTEXT.Process( + target=dummy_server, args=(self.lock, self.server_conn) + ) self.server.daemon = True self.server.start() diff --git a/python/grass/temporal/c_libraries_interface.py b/python/grass/temporal/c_libraries_interface.py index 4094bf2c033..fac6c64fc0c 100644 --- a/python/grass/temporal/c_libraries_interface.py +++ b/python/grass/temporal/c_libraries_interface.py @@ -16,7 +16,6 @@ import sys from ctypes import CFUNCTYPE, POINTER, byref, c_int, c_void_p, cast from datetime import datetime -from multiprocessing import Lock, Pipe, Process from typing import TYPE_CHECKING, Any, Literal import grass.lib.date as libdate @@ -27,7 +26,7 @@ import grass.lib.vector as libvector from grass.exceptions import FatalError from grass.pygrass.raster import RasterRow -from grass.pygrass.rpc.base import RPCServerBase +from grass.pygrass.rpc.base import MP_CONTEXT, RPCServerBase from grass.pygrass.utils import decode from grass.pygrass.vector import VectorTopo from grass.script.utils import encode @@ -1471,9 +1470,9 @@ def __init__(self) -> None: RPCServerBase.__init__(self) def start_server(self) -> None: - self.client_conn, self.server_conn = Pipe(True) - self.lock = Lock() - self.server = Process( + self.client_conn, self.server_conn = MP_CONTEXT.Pipe(True) + self.lock = MP_CONTEXT.Lock() + self.server = MP_CONTEXT.Process( target=c_library_server, args=(self.lock, self.server_conn) ) self.server.daemon = True From cb983655f6eb779c2f1f6566f852512a9d79fd68 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Tue, 25 Aug 2026 22:52:57 -0400 Subject: [PATCH 2/3] Update python/grass/pygrass/rpc/base.py Co-authored-by: Vaclav Petras --- python/grass/pygrass/rpc/base.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/python/grass/pygrass/rpc/base.py b/python/grass/pygrass/rpc/base.py index 71c82608f71..ffc09b43aaf 100644 --- a/python/grass/pygrass/rpc/base.py +++ b/python/grass/pygrass/rpc/base.py @@ -28,12 +28,12 @@ logger: logging.Logger = logging.getLogger(__name__) -# Python 3.14 changed the default multiprocessing start method from "fork" to -# "forkserver" on all platforms except macOS. The RPC worker process relies on -# "fork" semantics: subclasses run libgis via ctypes in the worker, which must -# inherit the initialized library state and the GRASS session environment from -# the parent. Pin "fork" where it used to be the default; macOS and Windows -# keep their default start method ("spawn"). +# Python 3.14 changed the default multiprocessing start method from "fork" +# to "forkserver" (on platforms where "fork" is available, except macOS), +# which breaks the RPC server processes. Use "fork" wherever it is available, +# i.e. the default before 3.14, except on macOS, which has defaulted to +# "spawn" since Python 3.8 and where "fork" is unsafe. Everywhere else +# (Windows) keep the platform default. if sys.platform != "darwin" and "fork" in mp.get_all_start_methods(): MP_CONTEXT = mp.get_context("fork") else: From f8caefd389aa058fc869a8aaeb9fd7aa34ed5fa3 Mon Sep 17 00:00:00 2001 From: Anna Petrasova Date: Wed, 23 Sep 2026 11:07:10 -0400 Subject: [PATCH 3/3] address remaining cases --- python/grass/pygrass/messages/__init__.py | 23 ++++-- python/grass/pygrass/rpc/__init__.py | 10 ++- python/grass/pygrass/rpc/base.py | 20 ++--- python/grass/script/tests/utils_test.py | 12 +++ python/grass/script/utils.py | 25 ++++++ .../grass/temporal/c_libraries_interface.py | 11 +-- python/grass/temporal/extract.py | 11 +-- python/grass/temporal/mapcalc.py | 7 +- ...c_libraries_interface_start_method_test.py | 80 +++++++++++++++++++ 9 files changed, 159 insertions(+), 40 deletions(-) create mode 100644 python/grass/temporal/tests/grass_temporal_c_libraries_interface_start_method_test.py diff --git a/python/grass/pygrass/messages/__init__.py b/python/grass/pygrass/messages/__init__.py index 5735398dafc..9617bcc1891 100644 --- a/python/grass/pygrass/messages/__init__.py +++ b/python/grass/pygrass/messages/__init__.py @@ -17,15 +17,16 @@ from __future__ import annotations import sys -from multiprocessing import Lock, Pipe, Process from typing import TYPE_CHECKING, Literal, NoReturn import grass.lib.gis as libgis from grass.exceptions import FatalError +from grass.script.utils import _get_multiprocessing_context if TYPE_CHECKING: from multiprocessing.connection import Connection from multiprocessing.context import _LockLike + from multiprocessing.process import BaseProcess _MessagesLiteral = Literal[ "INFO", "IMPORTANT", "VERBOSE", "WARNING", "ERROR", "FATAL" @@ -177,21 +178,27 @@ class Messenger: client_conn: Connection server_conn: Connection - server: Process + server: BaseProcess def __init__(self, raise_on_error: bool = False) -> None: self.raise_on_error = raise_on_error - self.client_conn, self.server_conn = Pipe() - self.lock = Lock() - self.server = Process(target=message_server, args=(self.lock, self.server_conn)) + ctx = _get_multiprocessing_context() + self.client_conn, self.server_conn = ctx.Pipe() + self.lock = ctx.Lock() + self.server = ctx.Process( + target=message_server, args=(self.lock, self.server_conn) + ) self.server.daemon = True self.server.start() def start_server(self) -> None: """Start the messenger server and open the pipe""" - self.client_conn, self.server_conn = Pipe() - self.lock = Lock() - self.server = Process(target=message_server, args=(self.lock, self.server_conn)) + ctx = _get_multiprocessing_context() + self.client_conn, self.server_conn = ctx.Pipe() + self.lock = ctx.Lock() + self.server = ctx.Process( + target=message_server, args=(self.lock, self.server_conn) + ) self.server.daemon = True self.server.start() diff --git a/python/grass/pygrass/rpc/__init__.py b/python/grass/pygrass/rpc/__init__.py index af7e2d6eba3..da171a4d17e 100644 --- a/python/grass/pygrass/rpc/__init__.py +++ b/python/grass/pygrass/rpc/__init__.py @@ -20,8 +20,9 @@ from grass.pygrass.raster import RasterRow, raster2numpy_img from grass.pygrass.vector import VectorTopo from grass.pygrass.vector.basic import Bbox +from grass.script.utils import _get_multiprocessing_context -from .base import MP_CONTEXT, RPCServerBase +from .base import RPCServerBase ############################################################################### ############################################################################### @@ -248,9 +249,10 @@ def __init__(self): def start_server(self): """This function must be re-implemented in the subclasses""" - self.client_conn, self.server_conn = MP_CONTEXT.Pipe(True) - self.lock = MP_CONTEXT.Lock() - self.server = MP_CONTEXT.Process( + ctx = _get_multiprocessing_context() + self.client_conn, self.server_conn = ctx.Pipe(True) + self.lock = ctx.Lock() + self.server = ctx.Process( target=data_provider_server, args=(self.lock, self.server_conn) ) self.server.daemon = True diff --git a/python/grass/pygrass/rpc/base.py b/python/grass/pygrass/rpc/base.py index ffc09b43aaf..18b55ca46fe 100644 --- a/python/grass/pygrass/rpc/base.py +++ b/python/grass/pygrass/rpc/base.py @@ -16,10 +16,10 @@ import sys import threading import time -import multiprocessing as mp from typing import TYPE_CHECKING, NoReturn from grass.exceptions import FatalError +from grass.script.utils import _get_multiprocessing_context if TYPE_CHECKING: from multiprocessing.connection import Connection @@ -28,17 +28,6 @@ logger: logging.Logger = logging.getLogger(__name__) -# Python 3.14 changed the default multiprocessing start method from "fork" -# to "forkserver" (on platforms where "fork" is available, except macOS), -# which breaks the RPC server processes. Use "fork" wherever it is available, -# i.e. the default before 3.14, except on macOS, which has defaulted to -# "spawn" since Python 3.8 and where "fork" is unsafe. Everywhere else -# (Windows) keep the platform default. -if sys.platform != "darwin" and "fork" in mp.get_all_start_methods(): - MP_CONTEXT = mp.get_context("fork") -else: - MP_CONTEXT = mp.get_context() - ############################################################################### @@ -153,9 +142,10 @@ def start_server(self): """This function must be re-implemented in the subclasses""" logger.debug("Start the libgis server") - self.client_conn, self.server_conn = MP_CONTEXT.Pipe(True) - self.lock = MP_CONTEXT.Lock() - self.server = MP_CONTEXT.Process( + ctx = _get_multiprocessing_context() + self.client_conn, self.server_conn = ctx.Pipe(True) + self.lock = ctx.Lock() + self.server = ctx.Process( target=dummy_server, args=(self.lock, self.server_conn) ) self.server.daemon = True diff --git a/python/grass/script/tests/utils_test.py b/python/grass/script/tests/utils_test.py index 7289161173b..649a4d21916 100644 --- a/python/grass/script/tests/utils_test.py +++ b/python/grass/script/tests/utils_test.py @@ -1,5 +1,8 @@ """Test functions in grass.script.utils""" +import multiprocessing +import sys + import pytest import grass.script as gs @@ -65,3 +68,12 @@ def test_resolve_nprocs(monkeypatch): assert gs.resolve_nprocs(-10) == 1 with pytest.raises(ValueError, match="invalid literal for int"): gs.resolve_nprocs("not-a-number") + + +def test_get_multiprocessing_context(): + """Fork is used where available and safe, the platform default elsewhere.""" + ctx = gutils._get_multiprocessing_context() + if sys.platform != "darwin" and "fork" in multiprocessing.get_all_start_methods(): + assert ctx.get_start_method() == "fork" + else: + assert ctx.get_start_method() == multiprocessing.get_start_method() diff --git a/python/grass/script/utils.py b/python/grass/script/utils.py index f2538ce7df9..c3e15ae2207 100644 --- a/python/grass/script/utils.py +++ b/python/grass/script/utils.py @@ -29,6 +29,7 @@ import uuid import random import string +import sys from pathlib import Path from typing import TYPE_CHECKING, AnyStr, TypeVar, cast, overload @@ -37,6 +38,7 @@ if TYPE_CHECKING: from _typeshed import FileDescriptorOrPath, StrOrBytesPath, StrPath from collections.abc import Callable + from multiprocessing.context import BaseContext # Type variables @@ -127,6 +129,29 @@ def resolve_nprocs(nprocs: int | str) -> int: return max(1, available + n) +def _get_multiprocessing_context() -> BaseContext: + """Return the multiprocessing context to use for GRASS worker processes. + + Python 3.14 changed the default multiprocessing start method from "fork" + to "forkserver" (on platforms where "fork" is available, except macOS). + GRASS worker processes rely on "fork" semantics: they inherit the + initialized C library state and the session environment from the parent. + Use "fork" wherever it is available, i.e. the default before 3.14, except + on macOS, which has defaulted to "spawn" since Python 3.8 and where + "fork" is unsafe. Everywhere else (Windows) keep the platform default. + + This is a workaround until GRASS supports the "spawn" and "forkserver" + start methods. + """ + # Imported here because grass.script is imported by every Python tool + # and only a few callers need multiprocessing. + import multiprocessing + + if sys.platform != "darwin" and "fork" in multiprocessing.get_all_start_methods(): + return multiprocessing.get_context("fork") + return multiprocessing.get_context() + + def diff_files( filename_a: FileDescriptorOrPath, filename_b: FileDescriptorOrPath ) -> list[str]: diff --git a/python/grass/temporal/c_libraries_interface.py b/python/grass/temporal/c_libraries_interface.py index fac6c64fc0c..a56dde244c2 100644 --- a/python/grass/temporal/c_libraries_interface.py +++ b/python/grass/temporal/c_libraries_interface.py @@ -26,10 +26,10 @@ import grass.lib.vector as libvector from grass.exceptions import FatalError from grass.pygrass.raster import RasterRow -from grass.pygrass.rpc.base import MP_CONTEXT, RPCServerBase +from grass.pygrass.rpc.base import RPCServerBase from grass.pygrass.utils import decode from grass.pygrass.vector import VectorTopo -from grass.script.utils import encode +from grass.script.utils import _get_multiprocessing_context, encode import grass.script as gs if TYPE_CHECKING: @@ -1470,9 +1470,10 @@ def __init__(self) -> None: RPCServerBase.__init__(self) def start_server(self) -> None: - self.client_conn, self.server_conn = MP_CONTEXT.Pipe(True) - self.lock = MP_CONTEXT.Lock() - self.server = MP_CONTEXT.Process( + ctx = _get_multiprocessing_context() + self.client_conn, self.server_conn = ctx.Pipe(True) + self.lock = ctx.Lock() + self.server = ctx.Process( target=c_library_server, args=(self.lock, self.server_conn) ) self.server.daemon = True diff --git a/python/grass/temporal/extract.py b/python/grass/temporal/extract.py index 0051a7e440d..f0d82e98161 100644 --- a/python/grass/temporal/extract.py +++ b/python/grass/temporal/extract.py @@ -12,10 +12,10 @@ import re import sys -from multiprocessing import Process import grass.script as gs from grass.exceptions import CalledModuleError +from grass.script.utils import _get_multiprocessing_context from .abstract_map_dataset import AbstractMapDataset @@ -209,6 +209,7 @@ def extract_dataset( # Run the mapcalc expression if expression: + ctx = _get_multiprocessing_context() proc_count = 0 proc_list = [] @@ -264,15 +265,15 @@ def extract_dataset( # Add process to the process list if type == "raster": msgr.verbose(_('Applying r.mapcalc expression: "%s"') % expr) - proc_list.append(Process(target=run_mapcalc2d, args=(expr,))) + proc_list.append(ctx.Process(target=run_mapcalc2d, args=(expr,))) elif type == "raster3d": msgr.verbose(_('Applying r3.mapcalc expression: "%s"') % expr) - proc_list.append(Process(target=run_mapcalc3d, args=(expr,))) + proc_list.append(ctx.Process(target=run_mapcalc3d, args=(expr,))) elif type == "vector": msgr.verbose(_('Applying v.extract where statement: "%s"') % expression) if row["layer"]: proc_list.append( - Process( + ctx.Process( target=run_vector_extraction, args=( row["name"] + "@" + row["mapset"], @@ -285,7 +286,7 @@ def extract_dataset( ) else: proc_list.append( - Process( + ctx.Process( target=run_vector_extraction, args=( row["name"] + "@" + row["mapset"], diff --git a/python/grass/temporal/mapcalc.py b/python/grass/temporal/mapcalc.py index b34d53f7330..8d0ad51a2cd 100644 --- a/python/grass/temporal/mapcalc.py +++ b/python/grass/temporal/mapcalc.py @@ -12,10 +12,10 @@ import copy import sys from datetime import datetime -from multiprocessing import Process import grass.script as gs from grass.exceptions import CalledModuleError +from grass.script.utils import _get_multiprocessing_context from .core import ( SQLDatabaseInterfaceConnection, @@ -236,6 +236,7 @@ def dataset_mapcalculator( num = len(map_matrix[0]) # Parallel processing + ctx = _get_multiprocessing_context() proc_list = [] proc_count = 0 @@ -311,9 +312,9 @@ def dataset_mapcalculator( # Start the parallel r.mapcalc computation if type == "raster": - proc_list.append(Process(target=_run_mapcalc2d, args=(expr,))) + proc_list.append(ctx.Process(target=_run_mapcalc2d, args=(expr,))) else: - proc_list.append(Process(target=_run_mapcalc3d, args=(expr,))) + proc_list.append(ctx.Process(target=_run_mapcalc3d, args=(expr,))) proc_list[proc_count].start() proc_count += 1 diff --git a/python/grass/temporal/tests/grass_temporal_c_libraries_interface_start_method_test.py b/python/grass/temporal/tests/grass_temporal_c_libraries_interface_start_method_test.py new file mode 100644 index 00000000000..c05c0b2a25a --- /dev/null +++ b/python/grass/temporal/tests/grass_temporal_c_libraries_interface_start_method_test.py @@ -0,0 +1,80 @@ +# Copyright (C) 2026 by the GRASS Development Team +# SPDX-License-Identifier: GPL-2.0-or-later + +"""Test that GRASS server processes start when the default start method is not fork. + +Python 3.14 made forkserver the default multiprocessing start method on Linux, +which broke the RPC servers in grass.pygrass and grass.temporal. The servers +now request a fork context explicitly. This test forces forkserver as the +default in a subprocess to reproduce the Python 3.14 situation on any version. +""" + +import multiprocessing +import subprocess +import sys + +import pytest + +SCRIPT = """ +import multiprocessing +import sys + + +def main(): + import grass.script as gs + import grass.temporal as tgis + from grass.pygrass.messages import Messenger + from grass.pygrass.rpc import DataProvider + from grass.pygrass.rpc.base import RPCServerBase + + project = sys.argv[1] + gs.create_project(project) + # Legacy init without env so that os.environ is set for grass.temporal. + session = gs.setup.init(project) + gs.run_command("g.region", n=10, s=0, e=10, w=0, res=1) + gs.run_command("r.mapcalc", expression="a = 1") + mapset = gs.gisenv()["MAPSET"] + + messenger = Messenger() + messenger.message("test") + messenger.stop() + + server = RPCServerBase() + server.stop() + + provider = DataProvider() + assert provider.get_raster_image_as_np("a").size > 0 + provider.stop() + + tgis.init() + ciface = tgis.CLibrariesInterface() + assert ciface.raster_map_exists("a", mapset) + ciface.stop() + + session.finish() + + +if __name__ == "__main__": + multiprocessing.set_start_method("forkserver") + main() +""" + + +@pytest.mark.skipif( + sys.platform == "darwin" or "fork" not in multiprocessing.get_all_start_methods(), + reason="Fork is only requested where it is available and not macOS", +) +def test_servers_start_with_forkserver_default(tmp_path): + """Servers work and exit cleanly when forkserver is the default start method.""" + script = tmp_path / "script.py" + script.write_text(SCRIPT) + result = subprocess.run( + [sys.executable, str(script), str(tmp_path / "project")], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + # A failing server check thread does not change the exit code. + assert "Traceback" not in result.stderr, result.stderr + assert "leaked semaphore" not in result.stderr, result.stderr