Skip to content

Commit 9b09e2b

Browse files
committed
Fixed infinite loading after idle connection loss on Linux Desktop. #6308
1 parent aa3dc38 commit 9b09e2b

5 files changed

Lines changed: 112 additions & 3 deletions

File tree

web/pgadmin/browser/utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,12 @@ def children(self, **kwargs):
433433

434434
try:
435435
conn = manager.connection(did=did)
436-
if not conn.connected():
436+
# Use connection_ping() instead of connected() to detect
437+
# stale / half-open TCP connections that were silently
438+
# dropped while pgAdmin was idle. connected() only checks
439+
# local state and would miss these, causing the subsequent
440+
# SQL queries to hang indefinitely.
441+
if not conn.connection_ping():
437442
status, msg = conn.connect()
438443
if not status:
439444
return internal_server_error(errormsg=msg)

web/pgadmin/static/js/tree/tree_nodes.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,45 @@ export class ManageTreeNodes {
121121
let treeData = [];
122122
if (url) {
123123
try {
124-
const res = await api.get(url);
124+
const res = await api.get(url, {timeout: 30000});
125125
treeData = res.data.data;
126126
} catch (error) {
127127
/* react-aspen does not handle reject case */
128128
console.error(error);
129-
pgAdmin.Browser.notifier.error(parseApiError(error)||'Node Load Error...');
129+
if (error.response?.status === 503 &&
130+
error.response?.data?.info === 'CONNECTION_LOST') {
131+
// Connection dropped while idle. Walk up to the server node
132+
// and mark it disconnected, then show a reconnect prompt so
133+
// the user can re-establish instead of seeing a silent
134+
// spinner.
135+
let serverNode = node;
136+
while (serverNode) {
137+
const d = serverNode.metadata?.data ?? serverNode.data;
138+
if (d?._type === 'server') break;
139+
serverNode = serverNode.parentNode ?? null;
140+
}
141+
if (serverNode) {
142+
const sData = serverNode.metadata?.data ?? serverNode.data;
143+
if (sData) sData.connected = false;
144+
pgAdmin.Browser.tree?.addIcon(serverNode, {icon: 'icon-server-not-connected'});
145+
pgAdmin.Browser.tree?.close(serverNode);
146+
}
147+
pgAdmin.Browser.notifier.confirm(
148+
gettext('Connection lost'),
149+
gettext('The connection to the server has been lost. Would you like to reconnect?'),
150+
function() {
151+
// Re-open (connect) the server node in the tree which
152+
// will trigger the standard connect-to-server flow
153+
// including any password prompts.
154+
if (serverNode && pgAdmin.Browser.tree) {
155+
pgAdmin.Browser.tree.toggle(serverNode);
156+
}
157+
},
158+
function() { /* cancelled */ }
159+
);
160+
} else {
161+
pgAdmin.Browser.notifier.error(parseApiError(error)||'Node Load Error...');
162+
}
130163
return [];
131164
}
132165
}

web/pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,36 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
482482
setQtStatePartial({is_visible: false});
483483
} else {
484484
setQtStatePartial({is_visible: true});
485+
// When the tab becomes visible again after being hidden (e.g. user
486+
// switched away on Linux Desktop), immediately check the connection
487+
// status. This ensures a dead connection is detected right away
488+
// instead of waiting for the next poll interval, which was disabled
489+
// while the tab was hidden.
490+
if(qtState.params?.trans_id && qtState.connected_once) {
491+
fetchConnectionStatus(api, qtState.params.trans_id)
492+
.then(({data: respData}) => {
493+
if(respData.data) {
494+
setQtStatePartial({
495+
connected: true,
496+
connection_status: respData.data.status,
497+
});
498+
} else {
499+
setQtStatePartial({
500+
connected: false,
501+
connection_status: null,
502+
connection_status_msg: gettext('An unexpected error occurred - ensure you are logged into the application.')
503+
});
504+
}
505+
})
506+
.catch((error) => {
507+
console.error(error);
508+
setQtStatePartial({
509+
connected: false,
510+
connection_status: null,
511+
connection_status_msg: parseApiError(error),
512+
});
513+
});
514+
}
485515
}
486516
});
487517
}, []);

web/pgadmin/utils/driver/psycopg3/connection.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,31 @@ def connected(self):
14131413
self.conn = None
14141414
return False
14151415

1416+
def connection_ping(self):
1417+
"""
1418+
Check if the connection is actually alive by executing a lightweight
1419+
query. Unlike connected(), which only inspects local state, this
1420+
sends traffic to the server and will detect stale / half-open TCP
1421+
connections that were silently dropped by firewalls or the OS while
1422+
pgAdmin was idle.
1423+
1424+
Returns True if alive, False otherwise.
1425+
"""
1426+
if not self.connected():
1427+
return False
1428+
try:
1429+
cur = self.conn.cursor()
1430+
cur.execute("SELECT 1")
1431+
cur.close()
1432+
return True
1433+
except Exception:
1434+
try:
1435+
self.conn.close()
1436+
except Exception:
1437+
pass
1438+
self.conn = None
1439+
return False
1440+
14161441
def _decrypt_password(self, manager):
14171442
"""
14181443
Decrypt password

web/pgadmin/utils/driver/psycopg3/server_manager.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,22 @@ def create_connection_string(self, database, user, password=None):
699699
display_dsn_args[key] = orig_value if with_complete_path else \
700700
value
701701

702+
# Enable TCP keepalive so that stale/half-open connections are
703+
# detected by the OS within a reasonable time instead of hanging
704+
# for the full TCP retransmission timeout (which can be many
705+
# minutes). These are libpq parameters passed through to
706+
# setsockopt and only take effect if not already set by the user
707+
# in connection_params.
708+
keepalive_defaults = {
709+
'keepalives': 1,
710+
'keepalives_idle': 30,
711+
'keepalives_interval': 10,
712+
'keepalives_count': 3,
713+
}
714+
for k, v in keepalive_defaults.items():
715+
if k not in dsn_args:
716+
dsn_args[k] = v
717+
702718
self.display_connection_string = make_conninfo(**display_dsn_args)
703719

704720
return make_conninfo(**dsn_args)

0 commit comments

Comments
 (0)