fix: unbound keyring probe at config-import time can hang desktop startup

evaluate_and_patch_config() called keyring.get_password() synchronously
at `import config` time to detect a selected-but-unusable OS keyring
backend. On Debian 13 (and similar headless/RDP sessions with no live
D-Bus/GNOME-Keyring session), that call - and even the keyring import
itself - can block forever, freezing the whole desktop app before it
ever starts.

Revert evaluate_config.py to the pre-9.17 synchronous check (backend
name only, no get_password call). Move the usability probe into
pgadmin.utils.keyring_probe, run from create_app() in a background
daemon thread so it never delays startup, isolated in its own process
via subprocess.Popen(sys.executable, '-c', ...) so a hang can actually
be killed - a thread-only timeout can't do this, it would leave
CPython's per-module import lock held forever and wedge any later
`import keyring` in the parent process too.

subprocess.Popen (plain fork+exec), not multiprocessing.Process, is
required here: create_app() runs at the top level of pgAdmin4.py while
it's still being imported, and multiprocessing's spawn start method
refuses to start a child before the current process finishes
bootstrapping its __main__ module. An earlier version of this fix used
multiprocessing and crashed the probe thread with RuntimeError on every
single test run, which corrupted the SQLAlchemy/sqlite session for the
rest of app init and surfaced as an unrelated-looking "attempt to write
a readonly database" error on module_preference inserts.

config.USE_OS_SECRET_STORAGE is only ever read from request handlers
requiring an authenticated session, never at import time or inside
create_app() itself, so the async resolution race is safe in practice.

Tests mock subprocess.Popen for the timeout/kill/config-fallback
orchestration (deterministic, no real 3s wait or backend dependency),
plus one test class that runs the real probe script in a real
subprocess against a fake keyring module injected via PYTHONPATH, so
the script body itself has real coverage.

Verified with the full regression suite (--exclude feature_tests) in
both desktop mode (2134 passed, 0 failed) and server mode (2251
passed, 0 failed); remaining skips are pre-existing pgAgent-dependent
job tests.
This commit is contained in:
Ashesh Vashi
2026-07-30 00:00:33 +05:30
parent 6e029ab6e5
commit 3f99454199
4 changed files with 352 additions and 21 deletions
+6
View File
@@ -210,6 +210,12 @@ def create_app(app_name=None):
# we don't want it to redirect to main page after password
# change operation so we will open the same password change page again.
config.SECURITY_POST_CHANGE_VIEW = 'browser.change_password'
else:
# Desktop mode: re-check the keyring backend picked at config-load
# time is actually usable, without blocking startup on a possible
# hang (see pgadmin/keyring_probe.py for why).
from pgadmin.utils.keyring_probe import start_async_probe
start_async_probe(config.__dict__)
"""Create the Flask application, startup logging and dynamically load
additional modules (blueprints) that are found in this directory."""
+13 -21
View File
@@ -9,7 +9,6 @@
import os
import sys
import keyring
import importlib.util
from pgadmin.utils.db_utils import normalize_database_uri
@@ -137,27 +136,20 @@ def evaluate_and_patch_config(config: dict) -> dict:
config.setdefault('USE_OS_SECRET_STORAGE', False)
config.setdefault('KEYRING_NAME', '')
else:
k_name = keyring.get_keyring().name
# A keyring backend may be selected (e.g. SecretService on Linux)
# yet be completely unusable at runtime - for example in a headless
# or RDP session where there is no running D-Bus / GNOME Keyring.
# In that case every keyring call raises and the crypt key is never
# set, surfacing as a bare CryptKeyMissing on every operation.
# Probe the backend with a harmless read of an entry that never
# exists; if the backend is healthy this returns None without
# prompting, and if it is unusable it raises. When it is not usable,
# disable OS secret storage so pgAdmin falls back to the
# master-password / in-app crypt key mechanism.
keyring_usable = k_name != 'fail Keyring'
if keyring_usable:
try:
keyring.get_password(
'pgAdmin4', 'entry_to_check_keyring_access')
except Exception:
keyring_usable = False
# NOTE: whether the selected keyring backend is actually *usable*
# (e.g. SecretService with no live D-Bus/GNOME-Keyring session) is
# probed separately, asynchronously, from create_app() via
# keyring_probe.start_async_probe() - see that module for why this
# can't be done safely here at config-import time.
config.setdefault('USE_OS_SECRET_STORAGE', True)
k_name = ''
try:
import keyring
k_name = keyring.get_keyring().name
except Exception:
k_name = 'fail Keyring'
if not keyring_usable:
# Setup USE_OS_SECRET_STORAGE false as no usable keyring backend
if k_name == 'fail Keyring':
config['USE_OS_SECRET_STORAGE'] = False
config['KEYRING_NAME'] = ''
else:
+99
View File
@@ -0,0 +1,99 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""Background, hang-safe check of whether the selected OS keyring backend
is actually usable (desktop mode only).
A keyring backend may be *selected* (e.g. SecretService on Linux) yet be
completely unusable at runtime - for example in a headless or RDP session
where there is no live D-Bus / GNOME Keyring session. In that case
`import keyring`, `keyring.get_keyring()`, and any keyring call can block
forever on a D-Bus call, instead of raising.
The probe therefore runs in its own OS process (subprocess.Popen, plain
fork+exec) so a hang can be killed outright from the parent - a
thread-based timeout can only stop *waiting*, it can't stop the
import/call, and would leave CPython's per-module import lock held
forever, wedging every later `import keyring` in the parent too.
NOTE: this must be `subprocess.Popen`, not `multiprocessing.Process`.
multiprocessing's 'spawn' start method refuses to start a child while the
current process hasn't finished bootstrapping its __main__ module yet
(`_check_not_importing_main`) - and that's exactly the situation here,
since this runs from create_app(), which itself executes at the top
level of pgAdmin4.py while it's still being imported. Popen has no such
restriction; it's an independent process from the start.
It's started from create_app() in a background daemon thread so it never
delays application startup; config.USE_OS_SECRET_STORAGE / KEYRING_NAME
are updated in place once the probe resolves (or times out).
"""
import subprocess
import sys
import threading
KEYRING_PROBE_TIMEOUT = 3
# Kept minimal and dependency-free: runs in a fresh interpreter, so it
# only has stdlib + keyring available, and reports its result over
# stdout rather than needing to be pickled back to the parent.
_PROBE_SCRIPT = (
"try:\n"
" import keyring\n"
" name = keyring.get_keyring().name\n"
" if name == 'fail Keyring':\n"
" print('0')\n"
" else:\n"
" keyring.get_password(\n"
" 'pgAdmin4', 'entry_to_check_keyring_access')\n"
" print('1')\n"
" print(name)\n"
"except Exception:\n"
" print('0')\n"
)
def _run_probe(config):
try:
proc = subprocess.Popen(
[sys.executable, '-c', _PROBE_SCRIPT],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
except Exception:
# Can't probe - leave config as evaluate_config.py set it.
return
try:
stdout, _ = proc.communicate(timeout=KEYRING_PROBE_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
stdout = ''
lines = stdout.splitlines()
keyring_usable = bool(lines) and lines[0] == '1'
if not keyring_usable:
config['USE_OS_SECRET_STORAGE'] = False
config['KEYRING_NAME'] = ''
else:
config['KEYRING_NAME'] = lines[1] if len(lines) > 1 else ''
def start_async_probe(config):
"""Kick off the keyring usability probe in the background.
Desktop mode only. Non-blocking - returns immediately; config is
updated in place whenever the probe resolves.
"""
if config.get('SERVER_MODE'):
return
threading.Thread(
target=_run_probe, args=(config,), daemon=True).start()
@@ -0,0 +1,234 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2026, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""Regression tests for the keyring-hang fix (Debian 13 / RDP D-Bus hang).
9.17 added a synchronous, unbounded keyring.get_password() probe at
config-import time. When the selected backend (e.g. SecretService) has no
live D-Bus/GNOME-Keyring session, that call - and even `import keyring`
itself - can block forever, freezing application startup outright.
The fix moves the probe into pgadmin.utils.keyring_probe, run from
create_app() in a background thread, isolated in a killable subprocess
(subprocess.Popen, not multiprocessing - see that module's docstring for
why) so a hang can be terminated instead of wedging the parent process.
Most tests below mock subprocess.Popen to verify _run_probe()'s
orchestration (timeout -> kill(), stdout parsing, config fallback)
quickly and deterministically - a real hang would have to actually wait
out the timeout, and a real keyring backend's behaviour differs per
machine/CI runner. TestProbeScriptRunsForReal is the exception: it
executes the real _PROBE_SCRIPT in a real subprocess (via
subprocess.Popen, unmocked) against a fake `keyring` module injected
over PYTHONPATH, so the script's actual body - never exercised by the
mocked tests - gets real coverage without depending on any real OS
keyring backend."""
import os
import sys
import tempfile
from unittest.mock import patch, MagicMock
from pgadmin.utils.route import BaseTestGenerator
from pgadmin.utils import keyring_probe
class _FakeProcess:
"""Stand-in for subprocess.Popen. `communicate_result` is either a
(stdout, stderr) tuple, or a subprocess.TimeoutExpired instance to be
raised by the first communicate() call (mimicking a hung process)."""
def __init__(self, communicate_result):
self._communicate_result = communicate_result
self._communicate_calls = 0
self.killed = False
def communicate(self, timeout=None):
self._communicate_calls += 1
if self._communicate_calls == 1 and isinstance(
self._communicate_result, BaseException):
raise self._communicate_result
return ('', '')
def kill(self):
self.killed = True
class TestKeyringProbeRunsInKillableSubprocess(BaseTestGenerator):
"""_run_probe() must resolve USE_OS_SECRET_STORAGE / KEYRING_NAME from
whatever the isolated subprocess reports on stdout, and must kill()
it rather than wait forever when it hangs past the timeout."""
scenarios = [
('healthy backend keeps OS secret storage enabled', dict(
stdout='1\nSecretService Keyring\n',
expect_use_os_secret_storage=True,
expect_keyring_name='SecretService Keyring',
expect_killed=False,
)),
('backend raises disables OS secret storage', dict(
stdout='0\n',
expect_use_os_secret_storage=False,
expect_keyring_name='',
expect_killed=False,
)),
]
def runTest(self):
fake_process = _FakeProcess((self.stdout, ''))
fake_process.communicate = MagicMock(
return_value=(self.stdout, ''))
config = {'USE_OS_SECRET_STORAGE': True}
with patch.object(keyring_probe.subprocess, 'Popen',
return_value=fake_process):
keyring_probe._run_probe(config)
self.assertEqual(config['USE_OS_SECRET_STORAGE'],
self.expect_use_os_secret_storage)
self.assertEqual(config.get('KEYRING_NAME'),
self.expect_keyring_name)
self.assertEqual(fake_process.killed, self.expect_killed)
class TestKeyringProbeTimeoutIsKilled(BaseTestGenerator):
"""When the subprocess hangs past the timeout, _run_probe() must
kill() it and fall back to disabling OS secret storage, rather than
waiting on communicate() forever."""
def runTest(self):
import subprocess as subprocess_module
fake_process = _FakeProcess(
subprocess_module.TimeoutExpired(cmd='probe', timeout=3))
config = {'USE_OS_SECRET_STORAGE': True}
with patch.object(keyring_probe.subprocess, 'Popen',
return_value=fake_process):
keyring_probe._run_probe(config)
self.assertTrue(fake_process.killed)
self.assertFalse(config['USE_OS_SECRET_STORAGE'])
self.assertEqual(config.get('KEYRING_NAME'), '')
class TestStartAsyncProbeIsNonBlockingAndServerModeAware(BaseTestGenerator):
"""start_async_probe() must never run the probe inline (it would
reintroduce the startup hang), and must be a no-op in server mode,
where OS secret storage doesn't apply."""
scenarios = [
('server mode skips the probe entirely', dict(
server_mode=True, expect_thread_started=False)),
('desktop mode backgrounds the probe', dict(
server_mode=False, expect_thread_started=True)),
]
def runTest(self):
config = {'SERVER_MODE': self.server_mode}
with patch.object(keyring_probe, 'threading') as mock_threading:
mock_thread = MagicMock()
mock_threading.Thread.return_value = mock_thread
keyring_probe.start_async_probe(config)
if self.expect_thread_started:
mock_threading.Thread.assert_called_once()
_, kwargs = mock_threading.Thread.call_args
self.assertEqual(kwargs.get('target'),
keyring_probe._run_probe)
self.assertEqual(kwargs.get('args'), (config,))
self.assertTrue(kwargs.get('daemon'))
mock_thread.start.assert_called_once()
else:
mock_threading.Thread.assert_not_called()
class TestProbeScriptRunsForReal(BaseTestGenerator):
"""Runs the real _PROBE_SCRIPT in a real subprocess - the only test
here that isn't mocked - against a fake `keyring` module so the
script's actual body (never executed by the mocked tests above) is
verified for real, without depending on this machine's OS keyring
backend."""
scenarios = [
('healthy backend', dict(
fake_keyring="""
class _KR:
name = 'FakeKeyring'
def get_keyring():
return _KR()
def get_password(service, key):
return None
""",
expect_stdout='1\nFakeKeyring\n')),
('fail Keyring name disables storage', dict(
fake_keyring="""
class _KR:
name = 'fail Keyring'
def get_keyring():
return _KR()
def get_password(service, key):
return None
""",
expect_stdout='0\n')),
('get_password raising disables storage', dict(
fake_keyring="""
class _KR:
name = 'FakeKeyring'
def get_keyring():
return _KR()
def get_password(service, key):
raise RuntimeError('backend unusable')
""",
expect_stdout='0\n')),
('get_keyring raising disables storage', dict(
fake_keyring="""
def get_keyring():
raise RuntimeError('no backend available')
def get_password(service, key):
return None
""",
expect_stdout='0\n')),
]
def runTest(self):
with tempfile.TemporaryDirectory() as fake_site:
with open(os.path.join(fake_site, 'keyring.py'), 'w') as f:
f.write(self.fake_keyring)
env = dict(os.environ)
env['PYTHONPATH'] = fake_site + os.pathsep + \
env.get('PYTHONPATH', '')
proc = keyring_probe.subprocess.Popen(
[sys.executable, '-c', keyring_probe._PROBE_SCRIPT],
stdout=keyring_probe.subprocess.PIPE,
stderr=keyring_probe.subprocess.PIPE, text=True, env=env)
stdout, stderr = proc.communicate(
timeout=keyring_probe.KEYRING_PROBE_TIMEOUT)
self.assertEqual(stdout, self.expect_stdout, stderr)