From 227aa3902cc624a82122f90b509968061e58985c Mon Sep 17 00:00:00 2001 From: Wojciech Szlachta Date: Wed, 30 Apr 2025 13:40:48 +0100 Subject: [PATCH 1/7] fixes 559 -- replace select.select() with select.poll() on posix On glibc based Linux systems select() can monitor only file descriptor numbers that are less than FD_SETSIZE (1024). This is an unreasonably low limit for many modern applications. --- py4j-python/src/py4j/java_gateway.py | 80 ++++++++++++++++++---------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/py4j-python/src/py4j/java_gateway.py b/py4j-python/src/py4j/java_gateway.py index 1607c00f..08b6fb4b 100644 --- a/py4j-python/src/py4j/java_gateway.py +++ b/py4j-python/src/py4j/java_gateway.py @@ -2319,34 +2319,60 @@ def run(self): self, server=self) read_list = [self.server_socket] - while not self.is_shutdown: - readable, writable, errored = select.select( - read_list, [], [], - self.callback_server_parameters.accept_timeout) - - if self.is_shutdown: - break + poller = None + try: + if os.name == "posix": + # On posix systems use poll to avoid problems with file + # descriptor numbers above 1024. + poller = select.poll() + for r in read_list: + poller.register(r.fileno(), select.POLLIN) + + while not self.is_shutdown: + if poller is not None: + readable_fds = { + fd + for fd, event in poller.poll( + self.callback_server_parameters.accept_timeout + ) + if event & select.POLLIN + } + readable = [ + r for r in read_list if r.fileno() in readable_fds + ] + else: + # If poll is not available, use select. + readable, writable, errored = select.select( + read_list, [], [], + self.callback_server_parameters.accept_timeout) + + if self.is_shutdown: + break - for s in readable: - socket_instance, _ = self.server_socket.accept() - if self.callback_server_parameters.read_timeout: - socket_instance.settimeout( - self.callback_server_parameters.read_timeout) - if self.ssl_context: - socket_instance = self.ssl_context.wrap_socket( - socket_instance, server_side=True) - input = socket_instance.makefile("rb") - connection = self._create_connection( - socket_instance, input) - with self.lock: - if not self.is_shutdown: - self.connections.add(connection) - connection.start() - server_connection_started.send( - self, connection=connection) - else: - quiet_shutdown(connection.socket) - quiet_close(connection.socket) + for s in readable: + socket_instance, _ = self.server_socket.accept() + if self.callback_server_parameters.read_timeout: + socket_instance.settimeout( + self.callback_server_parameters.read_timeout) + if self.ssl_context: + socket_instance = self.ssl_context.wrap_socket( + socket_instance, server_side=True) + input = socket_instance.makefile("rb") + connection = self._create_connection( + socket_instance, input) + with self.lock: + if not self.is_shutdown: + self.connections.add(connection) + connection.start() + server_connection_started.send( + self, connection=connection) + else: + quiet_shutdown(connection.socket) + quiet_close(connection.socket) + finally: + if poller is not None: + for r in read_list: + poller.unregister(r.fileno()) except Exception as e: if self.is_shutdown: logger.info("Error while waiting for a connection.") From 95cc5c1ece1aad6e4420223b2782f2d77f2c549b Mon Sep 17 00:00:00 2001 From: Wojciech Szlachta Date: Thu, 4 Dec 2025 14:50:37 +0000 Subject: [PATCH 2/7] Add env var PY4J_FORCE_SELECT to fallback to select.select() --- py4j-python/src/py4j/java_gateway.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/py4j-python/src/py4j/java_gateway.py b/py4j-python/src/py4j/java_gateway.py index 08b6fb4b..1765f776 100644 --- a/py4j-python/src/py4j/java_gateway.py +++ b/py4j-python/src/py4j/java_gateway.py @@ -58,6 +58,7 @@ def emit(self, record): DEFAULT_CALLBACK_SERVER_ACCEPT_TIMEOUT = 5 PY4J_SKIP_COLLECTIONS = "PY4J_SKIP_COLLECTIONS" PY4J_TRUE = {"yes", "y", "t", "true"} +PY4J_FORCE_SELECT = "PY4J_FORCE_SELECT" server_connection_stopped = Signal() @@ -2321,12 +2322,17 @@ def run(self): read_list = [self.server_socket] poller = None try: - if os.name == "posix": + if ( + os.name == "posix" + and os.getenv(PY4J_FORCE_SELECT, "").lower() + not in PY4J_TRUE + ): # On posix systems use poll to avoid problems with file - # descriptor numbers above 1024. + # descriptor numbers above 1024 (unless we force select by + # setting the PY4J_FORCE_SELECT environment variable). poller = select.poll() for r in read_list: - poller.register(r.fileno(), select.POLLIN) + poller.register(r, select.POLLIN) while not self.is_shutdown: if poller is not None: From a1eab05df92a11f5bd5cc48469917ac3a232aaa2 Mon Sep 17 00:00:00 2001 From: Wojciech Szlachta Date: Fri, 5 Dec 2025 10:01:37 +0000 Subject: [PATCH 3/7] Remove line break inside if condition for better readability --- py4j-python/src/py4j/java_gateway.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/py4j-python/src/py4j/java_gateway.py b/py4j-python/src/py4j/java_gateway.py index 1765f776..97418a30 100644 --- a/py4j-python/src/py4j/java_gateway.py +++ b/py4j-python/src/py4j/java_gateway.py @@ -2324,8 +2324,7 @@ def run(self): try: if ( os.name == "posix" - and os.getenv(PY4J_FORCE_SELECT, "").lower() - not in PY4J_TRUE + and os.getenv(PY4J_FORCE_SELECT, "").lower() not in PY4J_TRUE ): # On posix systems use poll to avoid problems with file # descriptor numbers above 1024 (unless we force select by From f02e4e164032a4ebcf9e2ccbfabe6207eb1888fb Mon Sep 17 00:00:00 2001 From: Wojciech Szlachta Date: Tue, 9 Dec 2025 20:03:20 +0000 Subject: [PATCH 4/7] Fix timeout in poll() from seconds to millis --- py4j-python/src/py4j/java_gateway.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/py4j-python/src/py4j/java_gateway.py b/py4j-python/src/py4j/java_gateway.py index 97418a30..00819a96 100644 --- a/py4j-python/src/py4j/java_gateway.py +++ b/py4j-python/src/py4j/java_gateway.py @@ -2335,10 +2335,12 @@ def run(self): while not self.is_shutdown: if poller is not None: + # Unlike select, poll timeout is in millis (hence multiply + # by 1000). Rule out error events. readable_fds = { fd for fd, event in poller.poll( - self.callback_server_parameters.accept_timeout + 1000 * self.callback_server_parameters.accept_timeout ) if event & select.POLLIN } From 760c2d8483b511a326d6f49e30f729cff6d91b94 Mon Sep 17 00:00:00 2001 From: Wojciech Szlachta Date: Tue, 16 Dec 2025 11:30:36 +0000 Subject: [PATCH 5/7] Handle POLLHUP, POLLERR and POLLNVAL when using poll() --- py4j-python/src/py4j/java_gateway.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/py4j-python/src/py4j/java_gateway.py b/py4j-python/src/py4j/java_gateway.py index 00819a96..911cab8c 100644 --- a/py4j-python/src/py4j/java_gateway.py +++ b/py4j-python/src/py4j/java_gateway.py @@ -2336,14 +2336,21 @@ def run(self): while not self.is_shutdown: if poller is not None: # Unlike select, poll timeout is in millis (hence multiply - # by 1000). Rule out error events. - readable_fds = { - fd - for fd, event in poller.poll( - 1000 * self.callback_server_parameters.accept_timeout - ) - if event & select.POLLIN - } + # by 1000). + readable_fds = [] + for fd, event in poller.poll( + 1000 * self.callback_server_parameters.accept_timeout + ): + if event & (select.POLLIN | select.POLLHUP): + # Data can be read (for POLLHUP peer hang up, so + # reads will return 0 bytes, in which case we want + # to break out - this is consistent with how select + # behaves). + readable_fds.append(fd) + else: + # Could be POLLERR or POLLNVAL (select would raise + # in this case). + raise Py4JError(f"Polling error - event {event} on fd {fd}") readable = [ r for r in read_list if r.fileno() in readable_fds ] @@ -2379,7 +2386,7 @@ def run(self): finally: if poller is not None: for r in read_list: - poller.unregister(r.fileno()) + poller.unregister(r) except Exception as e: if self.is_shutdown: logger.info("Error while waiting for a connection.") From 9504766251c79bb8655bc836d2997b5eddd01938 Mon Sep 17 00:00:00 2001 From: Ruslan Dautkhanov Date: Fri, 4 Sep 2026 00:32:44 -0600 Subject: [PATCH 6/7] test: cover CallbackServer poll/select accept-loop back-ends (#559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CallbackServerPollSelectTest to java_callback_test.py: - testAcceptLoopUsesPollOnPosix: white-box, patches select.poll (wraps=) to assert the accept loop actually goes through poll on posix — the fix for #559's FD_SETSIZE=1024 ceiling. - testForceSelectSkipsPoll: PY4J_FORCE_SELECT forces the select fallback on posix (poll not called) and callbacks still work through it. - testCleanShutdownDrainsPool: shutdown drains the proxy pool without the poll-path finally (poller.unregister) raising. The poll path was previously only covered implicitly by existing callback tests on posix CI; the select fallback and env-var hatch had no direct coverage. Both poll-specific tests skip off posix (select.poll is Unix-only). Co-authored-by: Isaac --- .../src/py4j/tests/java_callback_test.py | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/py4j-python/src/py4j/tests/java_callback_test.py b/py4j-python/src/py4j/tests/java_callback_test.py index a2f7b179..75a72f03 100644 --- a/py4j-python/src/py4j/tests/java_callback_test.py +++ b/py4j-python/src/py4j/tests/java_callback_test.py @@ -5,14 +5,17 @@ """ from contextlib import contextmanager from multiprocessing import Process +import os +import select import subprocess from threading import Thread from traceback import print_exc import unittest +from unittest.mock import patch from py4j.java_gateway import ( JavaGateway, PythonProxyPool, CallbackServerParameters, - set_default_callback_accept_timeout, is_instance_of) + set_default_callback_accept_timeout, is_instance_of, PY4J_FORCE_SELECT) from py4j.protocol import Py4JJavaError from py4j.tests.java_gateway_test import ( PY4J_JAVA_PATH, safe_join, safe_shutdown, sleep, check_connection, @@ -593,5 +596,75 @@ def testByteString(self): self.fail() +class CallbackServerPollSelectTest(unittest.TestCase): + """Covers the CallbackServer accept loop under both back-ends: + + - poll (the posix default; issue #559 -- avoids select's + ``FD_SETSIZE=1024`` ceiling so callback sockets with high fd + numbers still work), and + - select (Windows, or forced on posix via ``PY4J_FORCE_SELECT``). + + The poll path is already exercised implicitly by every callback test + on posix CI; these tests pin it explicitly and add the otherwise + uncovered select fallback and env-var escape hatch. + """ + + def setUp(self): + self.p = start_example_app_process() + self.gateway = None + sleep() + + def tearDown(self): + if self.gateway is not None: + safe_shutdown(self) + self.p.join() + os.environ.pop(PY4J_FORCE_SELECT, None) + sleep() + + def _assert_callback_works(self): + # A Java->Python callback forces the accept loop to accept a + # callback connection, so it must have gone through the active + # back-end (poll or select) to succeed. + example = self.gateway.entry_point.getNewExample() + self.assertEqual("This is Hello!", example.callHello(IHelloImpl())) + + @unittest.skipIf(os.name != "posix", "poll is only used on posix") + def testAcceptLoopUsesPollOnPosix(self): + # White-box: the accept loop must go through select.poll on posix + # (issue #559). wraps= lets the real poll run while recording it. + with patch("py4j.java_gateway.select.poll", + wraps=select.poll) as mock_poll: + self.gateway = JavaGateway( + callback_server_parameters=CallbackServerParameters()) + sleep() + self._assert_callback_works() + self.assertTrue( + mock_poll.called, + "poll() should back the accept loop on posix") + + @unittest.skipIf(os.name != "posix", "select is the only path off posix") + def testForceSelectSkipsPoll(self): + # PY4J_FORCE_SELECT must make the accept loop use select even on + # posix, and callbacks must still work through it. + os.environ[PY4J_FORCE_SELECT] = "true" + with patch("py4j.java_gateway.select.poll", + wraps=select.poll) as mock_poll: + self.gateway = JavaGateway( + callback_server_parameters=CallbackServerParameters()) + sleep() + self._assert_callback_works() + mock_poll.assert_not_called() + + def testCleanShutdownDrainsPool(self): + # A clean shutdown must drain the proxy pool without the poll-path + # teardown (poller.unregister in the finally) raising. + self.gateway = JavaGateway( + callback_server_parameters=CallbackServerParameters()) + sleep() + self._assert_callback_works() + self.gateway.shutdown() + self.assertEqual(0, len(self.gateway.gateway_property.pool)) + + if __name__ == "__main__": unittest.main() From 041ca2112894749cbd6cfb393fe751ff5515c2f8 Mon Sep 17 00:00:00 2001 From: Ruslan Dautkhanov Date: Fri, 4 Sep 2026 08:17:42 -0600 Subject: [PATCH 7/7] Harden poll accept loop + tests + docs (from 20-agent review) Addresses the findings from a completeness + adversarial review of the select->poll change. MUST-FIX (correctness): - Don't raise Py4JError on POLLERR/POLLHUP/POLLNVAL. The old select path passed an empty exceptional set (it ignored these and kept waiting); raising killed the callback server on a transient listening-socket error -- a regression for the long-lived servers issue #559 targets. Now we log-and-continue when not shutting down, matching select's tolerance. - Accept only on POLLIN. Treating POLLHUP as readable could block accept() forever on a listening socket with no pending connection. - Feature-detect select.poll (hasattr) in the posix guard: some posix builds (Emscripten/WASM) ship select without poll, which would have raised AttributeError at callback-server startup. Tests: - testAcceptLoopUsesPollOnPosix now asserts poll() is actually CALLED (via a wrapping poller), not merely that a poller was constructed. - testForceSelectSkipsPoll also asserts select.select() backs the loop. - New testTransientPollErrorDoesNotKillServer: injects a synthetic POLLERR and verifies a callback still succeeds (regression test for the fix above). Docs: - Changelog entry (closes #559) documenting the poll switch + PY4J_FORCE_SELECT. - CallbackServerParameters docstring notes the poll/select back-ends. - FAQ entry for the 'filedescriptor out of range in select()' symptom. Co-authored-by: Isaac --- py4j-python/src/py4j/java_gateway.py | 46 ++++++---- .../src/py4j/tests/java_callback_test.py | 84 ++++++++++++++++--- py4j-web/changelog.rst | 10 +++ py4j-web/faq.rst | 16 ++++ 4 files changed, 130 insertions(+), 26 deletions(-) diff --git a/py4j-python/src/py4j/java_gateway.py b/py4j-python/src/py4j/java_gateway.py index fc4cd1e4..a3f99bae 100644 --- a/py4j-python/src/py4j/java_gateway.py +++ b/py4j-python/src/py4j/java_gateway.py @@ -952,7 +952,12 @@ def __init__( class CallbackServerParameters(object): """Wrapper class that contains all parameters that can be passed to - configure a `CallbackServer` + configure a `CallbackServer`. + + On POSIX systems the callback server's accept loop uses ``select.poll()``, + which (unlike ``select.select()``) is not bounded by ``FD_SETSIZE``; set + the ``PY4J_FORCE_SELECT`` environment variable to force the legacy + ``select()`` path. Windows always uses ``select()``. """ def __init__( @@ -2622,12 +2627,15 @@ def run(self): try: if ( os.name == "posix" + and hasattr(select, "poll") and os.getenv( PY4J_FORCE_SELECT, "").lower() not in PY4J_TRUE ): # On posix systems use poll to avoid problems with file - # descriptor numbers above 1024 (unless we force select by - # setting the PY4J_FORCE_SELECT environment variable). + # descriptor numbers above 1024 (select is bounded by + # FD_SETSIZE). Guarded by hasattr because a few posix + # builds (e.g. Emscripten/WASM) ship select without poll. + # Set PY4J_FORCE_SELECT to force the select path. poller = select.poll() for r in read_list: poller.register(r, select.POLLIN) @@ -2645,20 +2653,26 @@ def run(self): else 1000 * accept_timeout) readable_fds = [] for fd, event in poller.poll(poll_timeout): - if event & (select.POLLIN | select.POLLHUP): - # POLLIN: a connection is waiting to be - # accepted. POLLHUP: the peer hung up, so the - # subsequent accept() returns/raises and is - # handled below -- treating it as readable - # matches how select surfaced this condition. + if event & select.POLLIN: + # A connection is waiting to be accepted. readable_fds.append(fd) - else: - # Pure POLLERR / POLLNVAL (select raised here - # too). On shutdown the socket was closed under - # us; the outer handler logs it quietly. - raise Py4JError( - "Polling error: event {0} on fd {1}" - .format(event, fd)) + elif not self.is_shutdown: + # POLLERR / POLLHUP / POLLNVAL without POLLIN + # on the listening socket. The select-based + # path passed an empty exceptional set -- it + # ignored these conditions and kept waiting -- + # so mirror that here rather than tearing the + # callback server down on a transient socket + # error (a regression for long-lived servers, + # the very case #559 targets). During shutdown + # the socket was closed under us, so stay + # silent. We deliberately do NOT accept() on a + # POLLHUP-only event: accept() on a listening + # socket with no pending connection blocks. + logger.warning( + "Ignoring unexpected poll event %s on the " + "callback server listening socket (fd %s)", + event, fd) readable = [ r for r in read_list if r.fileno() in readable_fds ] diff --git a/py4j-python/src/py4j/tests/java_callback_test.py b/py4j-python/src/py4j/tests/java_callback_test.py index 75a72f03..85a6508c 100644 --- a/py4j-python/src/py4j/tests/java_callback_test.py +++ b/py4j-python/src/py4j/tests/java_callback_test.py @@ -596,6 +596,31 @@ def testByteString(self): self.fail() +class _WrappingPoller(object): + """Wraps a real ``select.poll`` object so a test can confirm the accept + loop actually CALLS ``poll()`` (not merely that a poller was created), + and optionally inject a synthetic event on the first ``poll()`` call to + exercise the error path. register/unregister delegate to the real poller. + """ + + def __init__(self, real, inject_first=None): + self._real = real + self._inject_first = inject_first + self.poll_calls = 0 + + def register(self, *args, **kwargs): + return self._real.register(*args, **kwargs) + + def unregister(self, *args, **kwargs): + return self._real.unregister(*args, **kwargs) + + def poll(self, *args, **kwargs): + self.poll_calls += 1 + if self._inject_first is not None and self.poll_calls == 1: + return self._inject_first + return self._real.poll(*args, **kwargs) + + class CallbackServerPollSelectTest(unittest.TestCase): """Covers the CallbackServer accept loop under both back-ends: @@ -630,30 +655,69 @@ def _assert_callback_works(self): @unittest.skipIf(os.name != "posix", "poll is only used on posix") def testAcceptLoopUsesPollOnPosix(self): - # White-box: the accept loop must go through select.poll on posix - # (issue #559). wraps= lets the real poll run while recording it. - with patch("py4j.java_gateway.select.poll", - wraps=select.poll) as mock_poll: + # White-box: the accept loop must actually CALL poll() (not merely + # construct a poller) on posix -- the fix for #559's FD_SETSIZE + # ceiling. A wrapping poller records poll() invocations. Because a + # successful callback requires the accept loop to have accepted the + # connection, poll_calls > 0 is guaranteed once the callback returns. + pollers = [] + real_poll = select.poll + + def make_poller(): + wrapper = _WrappingPoller(real_poll()) + pollers.append(wrapper) + return wrapper + + with patch("py4j.java_gateway.select.poll", side_effect=make_poller): self.gateway = JavaGateway( callback_server_parameters=CallbackServerParameters()) sleep() self._assert_callback_works() - self.assertTrue( - mock_poll.called, - "poll() should back the accept loop on posix") + self.assertTrue(pollers, "a poll object should have been created") + self.assertGreater( + pollers[0].poll_calls, 0, + "poller.poll() should drive the accept loop on posix") @unittest.skipIf(os.name != "posix", "select is the only path off posix") def testForceSelectSkipsPoll(self): - # PY4J_FORCE_SELECT must make the accept loop use select even on - # posix, and callbacks must still work through it. + # PY4J_FORCE_SELECT must make the accept loop use select (not poll) + # even on posix; poll must never be constructed and select.select + # must actually back the loop (proven by the callback succeeding). os.environ[PY4J_FORCE_SELECT] = "true" with patch("py4j.java_gateway.select.poll", - wraps=select.poll) as mock_poll: + wraps=select.poll) as mock_poll, \ + patch("py4j.java_gateway.select.select", + wraps=select.select) as mock_select: self.gateway = JavaGateway( callback_server_parameters=CallbackServerParameters()) sleep() self._assert_callback_works() mock_poll.assert_not_called() + self.assertTrue( + mock_select.called, + "select.select() should back the accept loop when forced") + + @unittest.skipIf(os.name != "posix", "poll is only used on posix") + def testTransientPollErrorDoesNotKillServer(self): + # A transient POLLERR on the listening socket must be logged and + # ignored (the old select path passed an empty exceptional set, i.e. + # ignored these), NOT raised -- otherwise the callback server would + # die on a transient error, a regression for the long-lived servers + # #559 targets. Inject a synthetic POLLERR on the first poll(), then + # confirm a callback still succeeds (the loop survived). + real_poll = select.poll + + def make_poller(): + return _WrappingPoller( + real_poll(), inject_first=[(999, select.POLLERR)]) + + with patch("py4j.java_gateway.select.poll", side_effect=make_poller): + self.gateway = JavaGateway( + callback_server_parameters=CallbackServerParameters()) + sleep() + # Had the POLLERR raised, the accept loop would be dead and this + # callback would fail/hang instead of returning. + self._assert_callback_works() def testCleanShutdownDrainsPool(self): # A clean shutdown must drain the proxy pool without the poll-path diff --git a/py4j-web/changelog.rst b/py4j-web/changelog.rst index 1f9e39f2..7ea55bc9 100644 --- a/py4j-web/changelog.rst +++ b/py4j-web/changelog.rst @@ -7,6 +7,16 @@ releases. Unreleased ---------- +- Python side: The callback server's accept loop now uses ``select.poll()`` + instead of ``select.select()`` on POSIX systems. ``select()`` is bounded by + ``FD_SETSIZE`` (typically 1024), so a callback-server socket assigned a file + descriptor >= 1024 -- common in long-lived processes such as PySpark drivers + that have opened many descriptors -- raised ``ValueError: filedescriptor out + of range in select()``. ``poll()`` has no such limit. Windows, and any POSIX + build without ``select.poll``, continue to use ``select()``. Set the + ``PY4J_FORCE_SELECT`` environment variable (to ``yes``/``y``/``t``/``true``) + to force the legacy ``select()`` path on POSIX. Closes issue #559. + - Python side: Memoize attribute lookups on ``JVMView``, ``JavaPackage``, and ``JavaClass`` via a per-instance bounded LRU cache (default 1024 entries). Repeated walks along chains like diff --git a/py4j-web/faq.rst b/py4j-web/faq.rst index f866c6ab..c48354a5 100644 --- a/py4j-web/faq.rst +++ b/py4j-web/faq.rst @@ -214,6 +214,22 @@ default on most systems. To secure Py4J applications, refer to the :ref:`TLS And Authentication ` documentation. +Why do I get "filedescriptor out of range in select()" with callbacks? +---------------------------------------------------------------------- + +This happens in long-lived processes (for example PySpark drivers) that have +opened many file descriptors: the callback server's socket is assigned a file +descriptor number greater than or equal to ``FD_SETSIZE`` (typically 1024), +which the underlying ``select()`` call cannot represent. Since Py4J 0.11 the +callback server uses ``select.poll()`` on POSIX systems, which has no such +limit, so this should no longer occur. + +If you need the legacy behavior, set the ``PY4J_FORCE_SELECT`` environment +variable before starting your application. This reintroduces the limit and is +intended only as an escape hatch:: + + export PY4J_FORCE_SELECT=yes + I found a bug, how do I report it? ----------------------------------