From a3d37964e34c20c300c5d9f54681b44a8cf73406 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 12:58:08 +0530 Subject: [PATCH 1/7] 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 d61a0eaa4a901f82b7099c62d566ae3f3eeb08aa Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 14:13:37 +0530 Subject: [PATCH 2/7] 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 6b24db147538db9b92efc6bed8d1b4b71a446ba6 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 15:38:01 +0530 Subject: [PATCH 3/7] 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 253d68fd14cf831c56096d6aa878f2f4fb1532e1 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 15:40:06 +0530 Subject: [PATCH 4/7] 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 034fa3ecd31b906d7b949e4fbe33cd05330fc485 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Sun, 29 Mar 2026 18:14:46 +0530 Subject: [PATCH 5/7] 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); From cef5b02506e6e7d68880cb6cb7efe8d23a51c105 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Mon, 6 Apr 2026 11:39:59 +0530 Subject: [PATCH 6/7] Fix connection_ping() to check transaction_status before SELECT 1 and return 503 CONNECTION_LOST - Check transaction_status before executing ping query to avoid disrupting long-running queries or active transactions (addresses review feedback). Moved the check inside try-except to handle race conditions where the connection drops between connected() and transaction_status access. - Return 503 service_unavailable with CONNECTION_LOST info instead of 500 internal_server_error when reconnect fails in children() endpoint, so the tree_nodes.ts reconnect dialog is properly triggered. --- web/pgadmin/browser/utils.py | 6 ++++-- web/pgadmin/utils/driver/psycopg3/connection.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/web/pgadmin/browser/utils.py b/web/pgadmin/browser/utils.py index f90915d7c2e..ba701637bf3 100644 --- a/web/pgadmin/browser/utils.py +++ b/web/pgadmin/browser/utils.py @@ -18,7 +18,7 @@ from config import PG_DEFAULT_DRIVER from pgadmin.utils.ajax import make_json_response, precondition_required,\ - internal_server_error + internal_server_error, service_unavailable from pgadmin.utils.exception import ConnectionLost, SSHTunnelConnectionLost,\ CryptKeyMissing from pgadmin.utils.constants import DATABASE_LAST_SYSTEM_OID @@ -441,7 +441,9 @@ def children(self, **kwargs): if not conn.connection_ping(): status, msg = conn.connect() if not status: - return internal_server_error(errormsg=msg) + return service_unavailable( + msg, info="CONNECTION_LOST" + ) except (ConnectionLost, SSHTunnelConnectionLost, CryptKeyMissing): raise except Exception: diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py index 53b5dac7fcc..b7b31b5bb02 100644 --- a/web/pgadmin/utils/driver/psycopg3/connection.py +++ b/web/pgadmin/utils/driver/psycopg3/connection.py @@ -1425,7 +1425,22 @@ def connection_ping(self): """ if not self.connected(): return False + try: + # Check the transaction status before executing the ping + # query. If a query is already in progress (ACTIVE) or we + # are inside a transaction block (INTRANS / INERROR), running + # SELECT 1 would fail or disrupt the ongoing operation. In + # those states the connection is evidently alive, so just + # return True. + txn_status = self.conn.info.transaction_status + if txn_status != 0: + # 0 = IDLE — safe to send a query + # 1 = ACTIVE — command in progress, connection is alive + # 2 = INTRANS — in transaction block, connection is alive + # 3 = INERROR — in failed transaction, connection is alive + return True + cur = self.conn.cursor() cur.execute("SELECT 1") cur.close() From a9ad295d23eb919779ab2bbcd72fe062843e4464 Mon Sep 17 00:00:00 2001 From: ZoroXL Date: Tue, 28 Apr 2026 19:22:34 +0530 Subject: [PATCH 7/7] Address review: consolidate ping(), handle timeout, dedup reconnect dialogs - Merge connection_ping() into the existing ping(): adds the transaction_status guard and close-on-failure recovery, returns bool. Removes the redundant abstract method and updates callers. - Treat axios ECONNABORTED (client-side timeout, no error.response) as a connection-lost signal so the reconnect dialog still triggers when the server hangs on a recently-dead socket. - Add a per-server _reconnectPending guard so concurrent failed child-load requests do not stack multiple reconnect dialogs; the flag is cleared on both OK and Cancel. --- web/pgadmin/browser/utils.py | 12 +-- web/pgadmin/static/js/tree/tree_nodes.ts | 31 ++++++-- web/pgadmin/utils/driver/abstract.py | 32 ++++---- .../utils/driver/psycopg3/connection.py | 78 +++++++++---------- 4 files changed, 82 insertions(+), 71 deletions(-) diff --git a/web/pgadmin/browser/utils.py b/web/pgadmin/browser/utils.py index ba701637bf3..7acb0afde80 100644 --- a/web/pgadmin/browser/utils.py +++ b/web/pgadmin/browser/utils.py @@ -433,12 +433,12 @@ def children(self, **kwargs): try: conn = manager.connection(did=did) - # 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(): + # Use 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.ping(): status, msg = conn.connect() if not status: return service_unavailable( diff --git a/web/pgadmin/static/js/tree/tree_nodes.ts b/web/pgadmin/static/js/tree/tree_nodes.ts index 14df045ae7e..3ed0b859ec9 100644 --- a/web/pgadmin/static/js/tree/tree_nodes.ts +++ b/web/pgadmin/static/js/tree/tree_nodes.ts @@ -126,8 +126,15 @@ export class ManageTreeNodes { } catch (error) { /* react-aspen does not handle reject case */ console.error(error); - if (error.response?.status === 503 && - error.response?.data?.info === 'CONNECTION_LOST') { + const isConnectionLost = + (error.response?.status === 503 && + error.response?.data?.info === 'CONNECTION_LOST') || + // Axios client-side timeout has no error.response — treat it + // as a likely connection loss so the reconnect dialog still + // triggers when the server hangs on a recently-dead socket + // (before TCP keepalives detect it). + error.code === 'ECONNABORTED'; + if (isConnectionLost) { // 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 @@ -138,16 +145,30 @@ export class ManageTreeNodes { if (d?._type === 'server') break; serverNode = serverNode.parentNode ?? null; } + const sData = serverNode + ? (serverNode.metadata?.data ?? serverNode.data) + : null; + // When a server has multiple expanded children, every in-flight + // child-load request will fail independently. Guard with a + // per-server flag so we only show one reconnect dialog at a + // time instead of stacking them. + if (sData?._reconnectPending) return []; if (serverNode) { - const sData = serverNode.metadata?.data ?? serverNode.data; - if (sData) sData.connected = false; + if (sData) { + sData.connected = false; + sData._reconnectPending = true; + } pgAdmin.Browser.tree?.addIcon(serverNode, {icon: 'icon-server-not-connected'}); pgAdmin.Browser.tree?.close(serverNode); } + const clearPending = () => { + if (sData) sData._reconnectPending = false; + }; pgAdmin.Browser.notifier.confirm( gettext('Connection lost'), gettext('The connection to the server has been lost. Would you like to reconnect?'), function() { + clearPending(); // Re-open (connect) the server node in the tree which // will trigger the standard connect-to-server flow // including any password prompts. @@ -155,7 +176,7 @@ export class ManageTreeNodes { pgAdmin.Browser.tree.toggle(serverNode); } }, - function() { /* cancelled */ } + clearPending, ); } else { pgAdmin.Browser.notifier.error(parseApiError(error)||'Node Load Error...'); diff --git a/web/pgadmin/utils/driver/abstract.py b/web/pgadmin/utils/driver/abstract.py index 8329af67b88..9af0f693a68 100644 --- a/web/pgadmin/utils/driver/abstract.py +++ b/web/pgadmin/utils/driver/abstract.py @@ -116,14 +116,8 @@ class BaseConnection() - Implement this method to get the status of the connection. It should 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. + server-side disconnects. Use ping() when a network-level check is + required. * reset() - Implement this method to reconnect the database server (if possible) @@ -133,9 +127,13 @@ class BaseConnection() connection. Range of return values different for each driver type. * ping() - - Implement this method to ping the server. There are times, a connection - has been lost, but - the connection driver does not know about it. This - can be helpful to figure out the actual reason for query failure. + - 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. When + a query is already in progress or the connection is inside a + transaction block, the probe is skipped and True is returned (the + connection is evidently alive). * _release() - Implement this method to release the connection object. This should not @@ -216,14 +214,6 @@ 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 @@ -234,6 +224,10 @@ def transaction_status(self): @abstractmethod def 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 diff --git a/web/pgadmin/utils/driver/psycopg3/connection.py b/web/pgadmin/utils/driver/psycopg3/connection.py index b7b31b5bb02..b7c97468a69 100644 --- a/web/pgadmin/utils/driver/psycopg3/connection.py +++ b/web/pgadmin/utils/driver/psycopg3/connection.py @@ -1413,46 +1413,6 @@ 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: - # Check the transaction status before executing the ping - # query. If a query is already in progress (ACTIVE) or we - # are inside a transaction block (INTRANS / INERROR), running - # SELECT 1 would fail or disrupt the ongoing operation. In - # those states the connection is evidently alive, so just - # return True. - txn_status = self.conn.info.transaction_status - if txn_status != 0: - # 0 = IDLE — safe to send a query - # 1 = ACTIVE — command in progress, connection is alive - # 2 = INTRANS — in transaction block, connection is alive - # 3 = INERROR — in failed transaction, connection is alive - return True - - 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 @@ -1526,7 +1486,43 @@ def async_query_error(self): return self.__async_query_error def ping(self): - return self.execute_scalar('SELECT 1') + """ + 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: + # Check the transaction status before executing the ping + # query. If a query is already in progress (ACTIVE) or we + # are inside a transaction block (INTRANS / INERROR), running + # SELECT 1 would fail or disrupt the ongoing operation. In + # those states the connection is evidently alive, so just + # return True. + # 0 = IDLE — safe to send a query + # 1 = ACTIVE — command in progress, connection is alive + # 2 = INTRANS — in transaction block, connection is alive + # 3 = INERROR — in failed transaction, connection is alive + if self.conn.info.transaction_status != 0: + return True + + 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 _release(self): if self.wasConnected: