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
13 changes: 10 additions & 3 deletions web/pgadmin/browser/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -433,10 +433,17 @@ 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)
return service_unavailable(
msg, info="CONNECTION_LOST"
)
except (ConnectionLost, SSHTunnelConnectionLost, CryptKeyMissing):
raise
except Exception:
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
40 changes: 40 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,46 @@ 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
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)