diff --git a/web/pgadmin/browser/utils.py b/web/pgadmin/browser/utils.py index 62ae1310677..f90915d7c2e 100644 --- a/web/pgadmin/browser/utils.py +++ b/web/pgadmin/browser/utils.py @@ -433,7 +433,12 @@ def children(self, **kwargs): try: conn = manager.connection(did=did) - if not conn.connected(): + # Use connection_ping() instead of connected() to detect + # stale / half-open TCP connections that were silently + # dropped while pgAdmin was idle. connected() only checks + # local state and would miss these, causing the subsequent + # SQL queries to hang indefinitely. + if not conn.connection_ping(): status, msg = conn.connect() if not status: return internal_server_error(errormsg=msg) diff --git a/web/pgadmin/static/js/tree/tree_nodes.ts b/web/pgadmin/static/js/tree/tree_nodes.ts index 31fc9f10087..14df045ae7e 100644 --- a/web/pgadmin/static/js/tree/tree_nodes.ts +++ b/web/pgadmin/static/js/tree/tree_nodes.ts @@ -121,12 +121,45 @@ export class ManageTreeNodes { let treeData = []; if (url) { try { - const res = await api.get(url); + const res = await api.get(url, {timeout: 30000}); treeData = res.data.data; } catch (error) { /* react-aspen does not handle reject case */ console.error(error); - pgAdmin.Browser.notifier.error(parseApiError(error)||'Node Load Error...'); + if (error.response?.status === 503 && + error.response?.data?.info === 'CONNECTION_LOST') { + // Connection dropped while idle. Walk up to the server node + // and mark it disconnected, then show a reconnect prompt so + // the user can re-establish instead of seeing a silent + // spinner. + let serverNode = node; + while (serverNode) { + const d = serverNode.metadata?.data ?? serverNode.data; + if (d?._type === 'server') break; + serverNode = serverNode.parentNode ?? null; + } + if (serverNode) { + const sData = serverNode.metadata?.data ?? serverNode.data; + if (sData) sData.connected = false; + pgAdmin.Browser.tree?.addIcon(serverNode, {icon: 'icon-server-not-connected'}); + pgAdmin.Browser.tree?.close(serverNode); + } + pgAdmin.Browser.notifier.confirm( + gettext('Connection lost'), + gettext('The connection to the server has been lost. Would you like to reconnect?'), + function() { + // Re-open (connect) the server node in the tree which + // will trigger the standard connect-to-server flow + // including any password prompts. + if (serverNode && pgAdmin.Browser.tree) { + pgAdmin.Browser.tree.toggle(serverNode); + } + }, + function() { /* cancelled */ } + ); + } else { + pgAdmin.Browser.notifier.error(parseApiError(error)||'Node Load Error...'); + } return []; } } diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index ff642a24357..75971c65f70 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -168,6 +168,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN setQtState((prev)=>({...prev,...evalFunc(null, state, prev)})); }; const isDirtyRef = useRef(false); // usefull when conn change. + const qtStateRef = useRef(qtState); const eventBus = useRef(eventBusObj || (new EventBus())); const docker = useRef(null); const api = useMemo(()=>getApiInstance(), []); @@ -192,14 +193,17 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN eventBus.current.fireEvent(QUERY_TOOL_EVENTS.CHANGE_EOL, lineSep); }, []); - useInterval(async ()=>{ + const refreshConnectionStatus = useCallback(async (transId) => { try { - let {data: respData} = await fetchConnectionStatus(api, qtState.params.trans_id); + let {data: respData} = await fetchConnectionStatus(api, transId); if(respData.data) { setQtStatePartial({ connected: true, connection_status: respData.data.status, }); + if(respData.data.notifies) { + eventBus.current.fireEvent(QUERY_TOOL_EVENTS.PUSH_NOTICE, respData.data.notifies); + } } else { setQtStatePartial({ connected: false, @@ -207,9 +211,6 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN connection_status_msg: gettext('An unexpected error occurred - ensure you are logged into the application.') }); } - if(respData.data.notifies) { - eventBus.current.fireEvent(QUERY_TOOL_EVENTS.PUSH_NOTICE, respData.data.notifies); - } } catch (error) { console.error(error); setQtStatePartial({ @@ -218,6 +219,10 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN connection_status_msg: parseApiError(error), }); } + }, [api]); + + useInterval(()=>{ + refreshConnectionStatus(qtState.params.trans_id); }, pollTime); @@ -453,13 +458,14 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN forceClose(); }); - qtPanelDocker.eventBus.registerListener(LAYOUT_EVENTS.CLOSING, (id)=>{ + const onLayoutClosing = (id)=>{ if(qtPanelId == id) { eventBus.current.fireEvent(QUERY_TOOL_EVENTS.WARN_SAVE_DATA_CLOSE); } - }); + }; + qtPanelDocker.eventBus.registerListener(LAYOUT_EVENTS.CLOSING, onLayoutClosing); - qtPanelDocker.eventBus.registerListener(LAYOUT_EVENTS.ACTIVE, _.debounce((currentTabId)=>{ + const onLayoutActive = _.debounce((currentTabId)=>{ /* Focus the appropriate panel on visible */ if(qtPanelId == currentTabId) { setQtStatePartial({is_visible: true}); @@ -474,18 +480,44 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN } else { setQtStatePartial({is_visible: false}); } - }, 100)); + }, 100); + qtPanelDocker.eventBus.registerListener(LAYOUT_EVENTS.ACTIVE, onLayoutActive); /* If the tab or window is not visible, applicable for open in new tab */ - document.addEventListener('visibilitychange', function() { + // Track whether this panel was active before the window was hidden, + // so only the active instance refreshes on return. + let wasActiveBeforeHide = false; + const onVisibilityChange = function() { if(document.hidden) { + wasActiveBeforeHide = qtStateRef.current.is_visible; setQtStatePartial({is_visible: false}); } else { + if(!wasActiveBeforeHide) return; setQtStatePartial({is_visible: true}); + // When the tab becomes visible again after being hidden (e.g. user + // switched away on Linux Desktop), immediately check the connection + // status. This ensures a dead connection is detected right away + // instead of waiting for the next poll interval, which was disabled + // while the tab was hidden. + const {params, connected_once} = qtStateRef.current; + if(params?.trans_id && connected_once) { + refreshConnectionStatus(params.trans_id); + } } - }); + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return ()=>{ + document.removeEventListener('visibilitychange', onVisibilityChange); + onLayoutActive.cancel(); + if(qtPanelDocker?.eventBus) { + qtPanelDocker.eventBus.deregisterListener(LAYOUT_EVENTS.CLOSING, onLayoutClosing); + qtPanelDocker.eventBus.deregisterListener(LAYOUT_EVENTS.ACTIVE, onLayoutActive); + } + }; }, []); + useEffect(() => { qtStateRef.current = qtState; }, [qtState]); + useEffect(() => usePreferences.subscribe( state => { setQtStatePartial({preferences: { diff --git a/web/pgadmin/utils/driver/abstract.py b/web/pgadmin/utils/driver/abstract.py index 20d5d871d4b..8329af67b88 100644 --- a/web/pgadmin/utils/driver/abstract.py +++ b/web/pgadmin/utils/driver/abstract.py @@ -114,7 +114,16 @@ class BaseConnection() * connected() - Implement this method to get the status of the connection. It should - return True for connected, otherwise False + return True for connected, otherwise False. This is a local check + only (e.g. inspecting driver-level state) and may not detect + server-side disconnects. Use connection_ping() when a network-level + check is required. + + * connection_ping() + - Implement this method to verify the connection is alive by sending a + lightweight query (e.g. SELECT 1) to the server. Returns True if the + server responds, False otherwise. Unlike connected(), this detects + stale or half-open TCP connections that were silently dropped. * reset() - Implement this method to reconnect the database server (if possible) @@ -207,6 +216,14 @@ def async_fetchmany_2darray(self, records=-1, def connected(self): pass + @abstractmethod + def connection_ping(self): + """ + Check if the connection is actually alive by sending a lightweight + query to the server. Returns True if alive, False otherwise. + """ + pass + @abstractmethod def reset(self): pass diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py index 47ab25b7660..53b5dac7fcc 100644 --- a/web/pgadmin/utils/driver/psycopg3/connection.py +++ b/web/pgadmin/utils/driver/psycopg3/connection.py @@ -1413,6 +1413,31 @@ def connected(self): self.conn = None return False + def connection_ping(self): + """ + Check if the connection is actually alive by executing a lightweight + query. Unlike connected(), which only inspects local state, this + sends traffic to the server and will detect stale / half-open TCP + connections that were silently dropped by firewalls or the OS while + pgAdmin was idle. + + Returns True if alive, False otherwise. + """ + if not self.connected(): + return False + try: + cur = self.conn.cursor() + cur.execute("SELECT 1") + cur.close() + return True + except Exception: + try: + self.conn.close() + except Exception: + pass + self.conn = None + return False + def _decrypt_password(self, manager): """ Decrypt password diff --git a/web/pgadmin/utils/driver/psycopg3/server_manager.py b/web/pgadmin/utils/driver/psycopg3/server_manager.py index 76cee8b8446..00738355ee4 100644 --- a/web/pgadmin/utils/driver/psycopg3/server_manager.py +++ b/web/pgadmin/utils/driver/psycopg3/server_manager.py @@ -699,6 +699,22 @@ def create_connection_string(self, database, user, password=None): display_dsn_args[key] = orig_value if with_complete_path else \ value + # Enable TCP keepalive so that stale/half-open connections are + # detected by the OS within a reasonable time instead of hanging + # for the full TCP retransmission timeout (which can be many + # minutes). These are libpq parameters passed through to + # setsockopt and only take effect if not already set by the user + # in connection_params. + keepalive_defaults = { + 'keepalives': 1, + 'keepalives_idle': 30, + 'keepalives_interval': 10, + 'keepalives_count': 3, + } + for k, v in keepalive_defaults.items(): + if k not in dsn_args: + dsn_args[k] = v + self.display_connection_string = make_conninfo(**display_dsn_args) return make_conninfo(**dsn_args)