Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion web/pgadmin/browser/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
37 changes: 35 additions & 2 deletions web/pgadmin/static/js/tree/tree_nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(), []);
Expand All @@ -192,24 +193,24 @@ 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,
connection_status: null,
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({
Expand All @@ -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);


Expand Down Expand Up @@ -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});
Expand All @@ -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: {
Expand Down
19 changes: 18 additions & 1 deletion web/pgadmin/utils/driver/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions web/pgadmin/utils/driver/psycopg3/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +1416 to +1439
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Base contract:"
rg -n -C2 'def ping\(|def connection_ping\(' web/pgadmin/utils/driver/abstract.py

echo
echo "Call sites:"
rg -n -C2 'connection_ping\(' web/pgadmin/browser/utils.py web/pgadmin/utils/driver

echo
echo "Implementations:"
rg -n -C2 'def connection_ping\(' web/pgadmin/utils/driver

Repository: pgadmin-org/pgadmin4

Length of output: 1836


🏁 Script executed:

fd -t f 'connection.py' web/pgadmin/utils/driver

Repository: pgadmin-org/pgadmin4

Length of output: 111


🏁 Script executed:

fd -type d web/pgadmin/utils/driver

Repository: pgadmin-org/pgadmin4

Length of output: 234


🏁 Script executed:

fd --type d --maxdepth 1 '' web/pgadmin/utils/driver

Repository: pgadmin-org/pgadmin4

Length of output: 98


🏁 Script executed:

ls -la web/pgadmin/utils/driver/

Repository: pgadmin-org/pgadmin4

Length of output: 416


🏁 Script executed:

head -50 web/pgadmin/utils/driver/psycopg3/connection.py | grep -E "^import|^from|class.*\("

Repository: pgadmin-org/pgadmin4

Length of output: 1035


🏁 Script executed:

rg -n "^class.*BaseConnection" web/pgadmin/utils/driver/psycopg3/connection.py

Repository: pgadmin-org/pgadmin4

Length of output: 100


🏁 Script executed:

rg -n "class.*BaseConnection" web/pgadmin/utils/driver/

Repository: pgadmin-org/pgadmin4

Length of output: 296


🏁 Script executed:

rg -n "def connection\(" web/pgadmin/browser/utils.py -A 5 | head -20

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

rg -n "manager.*=" web/pgadmin/browser/utils.py | head -10

Repository: pgadmin-org/pgadmin4

Length of output: 236


🏁 Script executed:

rg -n "PG_DEFAULT_DRIVER" web/pgadmin/browser/utils.py

Repository: pgadmin-org/pgadmin4

Length of output: 175


🏁 Script executed:

rg -n "PG_DEFAULT_DRIVER\s*=" config.py 2>/dev/null | head -5

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

fd config.py | head -5

Repository: pgadmin-org/pgadmin4

Length of output: 304


🏁 Script executed:

rg -n "PG_DEFAULT_DRIVER" web/config.py

Repository: pgadmin-org/pgadmin4

Length of output: 98


🏁 Script executed:

rg -n -B5 -A10 "connection_ping\(\)" web/pgadmin/browser/utils.py

Repository: pgadmin-org/pgadmin4

Length of output: 1042


🏁 Script executed:

rg -n "connection_ping" web/pgadmin --type py | grep -E "mock|Mock|patch|test"

Repository: pgadmin-org/pgadmin4

Length of output: 46


🏁 Script executed:

rg -n "connection_ping" web/pgadmin

Repository: pgadmin-org/pgadmin4

Length of output: 325


connection_ping() must be added to the BaseConnection abstract contract.

Currently, web/pgadmin/browser/utils.py:441 calls connection_ping() on the connection object without this method being defined in web/pgadmin/utils/driver/abstract.py. While only psycopg3 is currently deployed, this violates the driver abstraction contract and would cause AttributeError if any alternate driver or test double were introduced.

Add the abstract method to BaseConnection in abstract.py and ensure all driver implementations provide it.

🧰 Tools
🪛 Ruff (0.15.7)

[warning] 1433-1433: Do not catch blind exception: Exception

(BLE001)


[error] 1436-1437: try-except-pass detected, consider logging the exception

(S110)


[warning] 1436-1436: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/pgadmin/utils/driver/psycopg3/connection.py` around lines 1416 - 1439,
Add a new abstract method connection_ping(self) to the BaseConnection class in
abstract.py to formalize the contract (matching the psycopg3 implementation's
signature) so callers like the code that invokes connection_ping() on connection
objects won't get AttributeError; then update any driver implementations that
lack it to implement connection_ping (e.g., ensure the psycopg3 connection
class's connection_ping semantics—execute lightweight "SELECT 1", close on
failure and return bool—are mirrored or appropriately adapted in other
drivers/test doubles).


def _decrypt_password(self, manager):
"""
Decrypt password
Expand Down
16 changes: 16 additions & 0 deletions web/pgadmin/utils/driver/psycopg3/server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)