From 9b09e2b619345898edad10f97e23393142d8fe08 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 12:58:08 +0530 Subject: [PATCH 1/5] Fixed infinite loading after idle connection loss on Linux Desktop. #6308 --- web/pgadmin/browser/utils.py | 7 +++- web/pgadmin/static/js/tree/tree_nodes.ts | 37 ++++++++++++++++++- .../js/components/QueryToolComponent.jsx | 30 +++++++++++++++ .../utils/driver/psycopg3/connection.py | 25 +++++++++++++ .../utils/driver/psycopg3/server_manager.py | 16 ++++++++ 5 files changed, 112 insertions(+), 3 deletions(-) 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..772eafd57d6 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -482,6 +482,36 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN setQtStatePartial({is_visible: false}); } else { 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. + if(qtState.params?.trans_id && qtState.connected_once) { + fetchConnectionStatus(api, qtState.params.trans_id) + .then(({data: respData}) => { + if(respData.data) { + setQtStatePartial({ + connected: true, + connection_status: respData.data.status, + }); + } else { + setQtStatePartial({ + connected: false, + connection_status: null, + connection_status_msg: gettext('An unexpected error occurred - ensure you are logged into the application.') + }); + } + }) + .catch((error) => { + console.error(error); + setQtStatePartial({ + connected: false, + connection_status: null, + connection_status_msg: parseApiError(error), + }); + }); + } } }); }, []); 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) From 39f1c3bd4bac7d3bd9a25f0664decd0037c5d033 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 14:13:37 +0530 Subject: [PATCH 2/5] Address review: fix stale closure, add cleanup, add abstract method --- .../static/js/components/QueryToolComponent.jsx | 17 +++++++++++++---- web/pgadmin/utils/driver/abstract.py | 8 ++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index 772eafd57d6..9cdd475a4f4 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(), []); @@ -477,7 +478,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN }, 100)); /* If the tab or window is not visible, applicable for open in new tab */ - document.addEventListener('visibilitychange', function() { + const onVisibilityChange = function() { if(document.hidden) { setQtStatePartial({is_visible: false}); } else { @@ -487,14 +488,18 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN // 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. - if(qtState.params?.trans_id && qtState.connected_once) { - fetchConnectionStatus(api, qtState.params.trans_id) + const {params, connected_once} = qtStateRef.current; + if(params?.trans_id && connected_once) { + fetchConnectionStatus(api, params.trans_id) .then(({data: respData}) => { 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, @@ -513,9 +518,13 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN }); } } - }); + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return ()=>document.removeEventListener('visibilitychange', onVisibilityChange); }, []); + 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..9f5e179c819 100644 --- a/web/pgadmin/utils/driver/abstract.py +++ b/web/pgadmin/utils/driver/abstract.py @@ -207,6 +207,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 From d08eac6133f7a49b8ac27ac551e4982831812a60 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 15:38:01 +0530 Subject: [PATCH 3/5] Clarify connection_ping() vs ping() contract in abstract base class docs --- web/pgadmin/utils/driver/abstract.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/pgadmin/utils/driver/abstract.py b/web/pgadmin/utils/driver/abstract.py index 9f5e179c819..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) From c66fc4015e66b206a541bdbc48e574a8ccd119f8 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 15:40:06 +0530 Subject: [PATCH 4/5] Extract shared refreshConnectionStatus helper and add layout listener cleanup --- .../js/components/QueryToolComponent.jsx | 59 ++++++++----------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index 9cdd475a4f4..7803cabcd77 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -193,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, @@ -208,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({ @@ -219,6 +219,10 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN connection_status_msg: parseApiError(error), }); } + }, [api]); + + useInterval(()=>{ + refreshConnectionStatus(qtState.params.trans_id); }, pollTime); @@ -454,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}); @@ -475,7 +480,8 @@ 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 */ const onVisibilityChange = function() { @@ -490,37 +496,18 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN // while the tab was hidden. const {params, connected_once} = qtStateRef.current; if(params?.trans_id && connected_once) { - fetchConnectionStatus(api, params.trans_id) - .then(({data: respData}) => { - 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, - connection_status: null, - connection_status_msg: gettext('An unexpected error occurred - ensure you are logged into the application.') - }); - } - }) - .catch((error) => { - console.error(error); - setQtStatePartial({ - connected: false, - connection_status: null, - connection_status_msg: parseApiError(error), - }); - }); + refreshConnectionStatus(params.trans_id); } } }; document.addEventListener('visibilitychange', onVisibilityChange); - return ()=>document.removeEventListener('visibilitychange', onVisibilityChange); + return ()=>{ + document.removeEventListener('visibilitychange', onVisibilityChange); + if(qtPanelDocker?.eventBus) { + qtPanelDocker.eventBus.deregisterListener(LAYOUT_EVENTS.CLOSING, onLayoutClosing); + qtPanelDocker.eventBus.deregisterListener(LAYOUT_EVENTS.ACTIVE, onLayoutActive); + } + }; }, []); useEffect(() => { qtStateRef.current = qtState; }, [qtState]); From e373d40d076f5c90e465b393aebf85e9e842d442 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 18:14:46 +0530 Subject: [PATCH 5/5] Cancel debounced handler on cleanup, gate visibility check by active panel --- .../sqleditor/static/js/components/QueryToolComponent.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx index 7803cabcd77..75971c65f70 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx @@ -484,10 +484,15 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN qtPanelDocker.eventBus.registerListener(LAYOUT_EVENTS.ACTIVE, onLayoutActive); /* If the tab or window is not visible, applicable for open in new tab */ + // 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 @@ -503,6 +508,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN 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);