diff --git a/taipy/gui/config.py b/taipy/gui/config.py index 6e52012df6..869a36ffd5 100644 --- a/taipy/gui/config.py +++ b/taipy/gui/config.py @@ -255,11 +255,12 @@ def _build_config(self, root_dir, env_filename, kwargs): # pragma: no cover elif key == "port" and str(value).strip() == "auto": config["port"] = "auto" else: - config[key] = value if config.get(key) is None else type(config.get(key))(value) # type: ignore[reportCallIssue] + config[key] = value if config.get(key) is None else type(config.get(key))( + value) # type: ignore[reportCallIssue] except Exception as e: _warn( - f"Invalid keyword arguments value in Gui.run(): {key} - {value}. Unable to parse value to the correct type", # noqa: E501 - e, + f"Invalid keyword arguments value in Gui.run(): {key} - {value}. " + f"Unable to parse value to the correct type: {e}", ) # Load config from env file if os.path.isfile(env_file_abs_path): @@ -273,11 +274,12 @@ def _build_config(self, root_dir, env_filename, kwargs): # pragma: no cover if isinstance(config[key], bool): config[key] = _is_true(value) else: - config[key] = value if config[key] is None else type(config[key])(value) # type: ignore[reportCallIssue] + config[key] = value if config[key] is None else type(config[key])( + value) # type: ignore[reportCallIssue] except Exception as e: _warn( - f"Invalid env value in Gui.run(): {key} - {value}. Unable to parse value to the correct type", # noqa: E501 - e, + f"Invalid env value in Gui.run(): {key} - {value}. " + f"Unable to parse value to the correct type: {e}", ) # Taipy-config diff --git a/taipy/gui/servers/flask/server.py b/taipy/gui/servers/flask/server.py index 8b953d9bd8..663dc89b68 100644 --- a/taipy/gui/servers/flask/server.py +++ b/taipy/gui/servers/flask/server.py @@ -11,7 +11,6 @@ from __future__ import annotations -import contextlib import logging import os import pathlib @@ -142,6 +141,7 @@ def _get_default_handler( base_url: str, ) -> Blueprint: taipy_bp = Blueprint("Taipy", __name__, static_folder=static_folder, template_folder=template_folder) + # Serve static react build @taipy_bp.route("/", defaults={"path": ""}) @@ -171,11 +171,13 @@ def my_index(path): ) except Exception: raise RuntimeError( - "Something is wrong with the taipy-gui front-end installation. Check that the js bundle has been properly built (is Node.js installed?)." # noqa: E501 + "Something is wrong with the taipy-gui front-end installation. " + "Check that the js bundle has been properly built (is Node.js installed?)." ) from None if path == "taipy.status.json": - return self.direct_render_json(self._gui._serve_status(pathlib.Path(template_folder) / path)) # type: ignore[attr-defined] + return self.direct_render_json( + self._gui._serve_status(pathlib.Path(template_folder) / path)) # type: ignore[attr-defined] if (file_path := str(os.path.normpath((base_path := static_folder + os.path.sep) + path))).startswith( base_path ) and os.path.isfile(file_path): @@ -185,25 +187,26 @@ def my_index(path): if ( path.startswith(f"{k}/") and ( - file_path := str(os.path.normpath((base_path := v + os.path.sep) + path[len(k) + 1 :])) - ).startswith(base_path) + file_path := str(os.path.normpath((base_path := v + os.path.sep) + path[len(k) + 1:])) + ).startswith(base_path) and os.path.isfile(file_path) ): - return send_from_directory(base_path, path[len(k) + 1 :]) + return send_from_directory(base_path, path[len(k) + 1:]) if ( hasattr(__main__, "__file__") and ( - file_path := str( - os.path.normpath((base_path := os.path.dirname(__main__.__file__) + os.path.sep) + path) - ) - ).startswith(base_path) + file_path := str( + os.path.normpath((base_path := os.path.dirname(__main__.__file__) + os.path.sep) + path) + ) + ).startswith(base_path) and os.path.isfile(file_path) and not self._is_ignored(file_path) ): return send_from_directory(base_path, path) if ( ( - file_path := str(os.path.normpath((base_path := self._gui._root_dir + os.path.sep) + path)) # type: ignore[attr-defined] + file_path := str(os.path.normpath((base_path := self._gui._root_dir + os.path.sep) + path)) + # type: ignore[attr-defined] ).startswith(base_path) and os.path.isfile(file_path) and not self._is_ignored(file_path) @@ -237,7 +240,15 @@ def test_request_context(self, path, data=None): def _run_notebook(self): self._is_running = True - self._ws.run(self._server, host=self._host, port=self._port, debug=False, use_reloader=False) + self._ws.run( + self._server, + host=self._host, + port=self._port, + debug=False, + use_reloader=False, + allow_unsafe_werkzeug=True, + log_output=True + ) def _get_async_mode(self) -> str: return self._ws.async_mode # type: ignore[attr-defined] @@ -380,9 +391,14 @@ def run( if not self.is_running_from_reloader() and self._gui._get_config("run_browser", False): # type: ignore[attr-defined] webbrowser.open(client_url or server_url, new=2) if _is_in_notebook() or run_in_thread: - self._thread = KThread(target=self._run_notebook) + self._thread = KThread( + target=self._run_notebook, + daemon=True, + name=f"TaipyGUI-{port}" + ) self._thread.start() return + self._is_running = True run_config = { "app": self._server, @@ -407,17 +423,44 @@ def is_running(self): def stop_thread(self): if hasattr(self, "_thread") and self._thread.is_alive() and self._is_running: self._is_running = False - with contextlib.suppress(Exception): + + try: if self._get_async_mode() == "gevent": - if self._ws.wsgi_server is not None: # type: ignore[attr-defined] - self._ws.wsgi_server.stop() # type: ignore[attr-defined] + if hasattr(self._ws, 'wsgi_server') and self._ws.wsgi_server is not None: + self._ws.wsgi_server.stop() else: self._thread.kill() else: self._thread.kill() + except Exception as e: + _TaipyLogger._get_logger().warning(f"Error stopping thread: {e}") + + timeout_start = time.time() + timeout_duration = 5.0 # 5 seconds timeout + while _is_port_open(self._host, self._port): + if time.time() - timeout_start > timeout_duration: + _TaipyLogger._get_logger().warning( + f"Port {self._port} still occupied after {timeout_duration}s timeout" + ) + break time.sleep(0.1) + def __del__(self): + try: + if hasattr(self, '_thread') and self._thread and self._thread.is_alive(): + self.stop_thread() + if hasattr(self, '_proxy'): + self.stop_proxy() + except Exception: + pass + def stop_proxy(self): if hasattr(self, "_proxy"): - self._proxy.stop() + try: + self._proxy.stop() + except Exception as e: + _TaipyLogger._get_logger().warning(f"Error stopping proxy: {e}") + finally: + if hasattr(self, "_proxy"): + delattr(self, "_proxy") diff --git a/taipy/gui/utils/proxy.py b/taipy/gui/utils/proxy.py index b9edf2271f..2a3ad5c237 100644 --- a/taipy/gui/utils/proxy.py +++ b/taipy/gui/utils/proxy.py @@ -10,9 +10,10 @@ # specific language governing permissions and limitations under the License. import contextlib +import threading import typing as t import warnings -from threading import Thread +from threading import Event, Thread from urllib.parse import quote as urlquote from urllib.parse import urlparse @@ -21,6 +22,7 @@ from twisted.web.resource import Resource from twisted.web.server import NOT_DONE_YET, Site +from .._warnings import _warn from .is_port_open import _is_port_open # flake8: noqa: E402 @@ -29,10 +31,10 @@ warnings.filterwarnings( "ignore", category=UserWarning, - message="You do not have a working installation of the service_identity module: 'No module named 'service_identity''.*", # noqa: E501 + message="You don't have a working installation of the service_identity module: " + "'No module named 'service_identity''.*", ) - if t.TYPE_CHECKING: from ..gui import Gui @@ -93,23 +95,80 @@ def __init__(self, gui: "Gui", listening_port: int) -> None: self._listening_port = listening_port self._gui = gui self._is_running = False + self._thread: t.Optional[Thread] = None + self._stop_event = Event() + self._reactor_thread_id: t.Optional[int] = None def run(self): - if self._is_running: + if self._is_running and self._thread and self._thread.is_alive(): return + host = self._gui._get_config("host", "127.0.0.1") port = self._listening_port + if _is_port_open(host, port): raise ConnectionError( - f"Port {port} is already opened on {host}. You have another server application running on the same port." # noqa: E501 + f"Port {port} is already opened on {host}. " + f"You have another server application running on the same port." ) - site = Site(_TaipyReverseProxyResource(host, b"", self._gui)) - reactor.listenTCP(port, site) - Thread(target=reactor.run, args=(False,)).start() + + self._thread = Thread( + target=self._run_reactor, + args=(host, port), + daemon=True, + name=f"TaipyNotebookProxy-{port}" + ) + + self._stop_event.clear() + self._thread.start() self._is_running = True + import time + time.sleep(0.1) + + def _run_reactor(self, host: str, port: int): + try: + ident = threading.current_thread().ident + self._reactor_thread_id = ident if ident is not None else 0 + site = Site(_TaipyReverseProxyResource(host, b"", self._gui)) + reactor.listenTCP(port, site) + + reactor.run(installSignalHandlers=False) + + except Exception as e: + _warn(f"Reactor error: {e}") + finally: + self._is_running = False + self._reactor_thread_id = None + def stop(self): if not self._is_running: return + + self._stop_event.set() self._is_running = False - reactor.stop() + + if (self._reactor_thread_id and + threading.current_thread().ident == self._reactor_thread_id): + reactor.stop() + else: + + reactor.callFromThread(reactor.stop) + + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2.0) + + if self._thread.is_alive(): + _warn(f"Warning: Proxy thread {self._thread.name} did not terminate cleanly") + + self._thread = None + self._reactor_thread_id = None + + def is_alive(self) -> bool: + return (self._is_running and + self._thread is not None and + self._thread.is_alive()) + + def __del__(self): + with contextlib.suppress(Exception): + self.stop()