Skip to content

Commit fbeb719

Browse files
committed
fix(keyring): make keyring unlock thread safe
When using `poetry install`, Poetry executor prefers to execute operations using a `concurrent.futures.ThreadPoolExecutor`. This can lead to multiple credential store unlock requests to be dispatched simultaneously. If the credential store is locked, and a user either intentionally or unintentionally dismisses the prompt to provide the store password; or the dbus messages fail to launch the prompter the Poetry user can experience, what can appear as a "hang". This in fact can be several threads competing with each other waiting for a dbus event indefinitely; this is a consequence of how Poetry uses keyring. This change introduces the following: - pre-flight check for installer commands that ensures keyring unlocks only once for the duration of the command - improved logging of keyring unlock event and/or failure - thread safe caching of `PasswordManager.<use_keyring|keyring>` This change does not address the following: - handling cases where dbus message fails or prompter is blocked - authentication of a locked keyring in a non-tty session
1 parent 80e3118 commit fbeb719

File tree

6 files changed

+105
-24
lines changed

6 files changed

+105
-24
lines changed

Diff for: src/poetry/console/commands/installer_command.py

+7
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
from poetry.console.commands.env_command import EnvCommand
66
from poetry.console.commands.group_command import GroupCommand
7+
from poetry.utils.password_manager import PoetryKeyring
78

89

910
if TYPE_CHECKING:
11+
from cleo.io.io import IO
12+
1013
from poetry.installation.installer import Installer
1114

1215

@@ -30,3 +33,7 @@ def installer(self) -> Installer:
3033

3134
def set_installer(self, installer: Installer) -> None:
3235
self._installer = installer
36+
37+
def execute(self, io: IO) -> int:
38+
PoetryKeyring.preflight_check(io)
39+
return super().execute(io)

Diff for: src/poetry/utils/password_manager.py

+56-11
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@
77
from contextlib import suppress
88
from typing import TYPE_CHECKING
99

10+
from poetry.config.config import Config
11+
from poetry.utils.threading import atomic_cached_property
12+
1013

1114
if TYPE_CHECKING:
12-
from keyring.backend import KeyringBackend
15+
import keyring.backend
16+
17+
from cleo.io.io import IO
1318

14-
from poetry.config.config import Config
1519

1620
logger = logging.getLogger(__name__)
1721

@@ -30,6 +34,35 @@ class PoetryKeyring:
3034
def __init__(self, namespace: str) -> None:
3135
self._namespace = namespace
3236

37+
@staticmethod
38+
def preflight_check(io: IO | None = None, config: Config | None = None) -> None:
39+
"""
40+
Performs a preflight check to determine the availability of the keyring service
41+
and logs the status if verbosity is enabled. This method is used to validate
42+
the configuration setup related to the keyring functionality.
43+
44+
:param io: An optional input/output handler used to log messages during the
45+
preflight check. If not provided, logging will be skipped.
46+
:param config: An optional configuration object. If not provided, a new
47+
configuration instance will be created using the default factory method.
48+
:return: None
49+
"""
50+
config = config or Config.create()
51+
52+
if config.get("keyring.enabled"):
53+
if io and io.is_verbose():
54+
io.write("Checking keyring availability: ")
55+
56+
message = "<fg=yellow;options=bold>Unavailable</>"
57+
58+
with suppress(RuntimeError, ValueError):
59+
if PoetryKeyring.is_available():
60+
message = "<fg=green;options=bold>Available</>"
61+
62+
if io and io.is_verbose():
63+
io.write(message)
64+
io.write_line("")
65+
3366
def get_credential(
3467
self, *names: str, username: str | None = None
3568
) -> HTTPAuthCredential:
@@ -62,9 +95,9 @@ def get_password(self, name: str, username: str) -> str | None:
6295

6396
try:
6497
return keyring.get_password(name, username)
65-
except (RuntimeError, keyring.errors.KeyringError):
98+
except (RuntimeError, keyring.errors.KeyringError) as e:
6699
raise PoetryKeyringError(
67-
f"Unable to retrieve the password for {name} from the key ring"
100+
f"Unable to retrieve the password for {name} from the key ring {e}"
68101
)
69102

70103
def set_password(self, name: str, username: str, password: str) -> None:
@@ -96,20 +129,22 @@ def get_entry_name(self, name: str) -> str:
96129
return f"{self._namespace}-{name}"
97130

98131
@classmethod
132+
@functools.cache
99133
def is_available(cls) -> bool:
100134
logger.debug("Checking if keyring is available")
101135
try:
102136
import keyring
103137
import keyring.backend
138+
import keyring.errors
104139
except ImportError as e:
105140
logger.debug("An error occurred while importing keyring: %s", e)
106141
return False
107142

108-
def backend_name(backend: KeyringBackend) -> str:
143+
def backend_name(backend: keyring.backend.KeyringBackend) -> str:
109144
name: str = backend.name
110145
return name.split(" ")[0]
111146

112-
def backend_is_valid(backend: KeyringBackend) -> bool:
147+
def backend_is_valid(backend: keyring.backend.KeyringBackend) -> bool:
113148
name = backend_name(backend)
114149
if name in ("chainer", "fail", "null"):
115150
logger.debug(f"Backend {backend.name!r} is not suitable")
@@ -130,20 +165,30 @@ def backend_is_valid(backend: KeyringBackend) -> bool:
130165
if valid_backend is None:
131166
logger.debug("No valid keyring backend was found")
132167
return False
133-
else:
134-
logger.debug(f"Using keyring backend {backend.name!r}")
135-
return True
168+
169+
logger.debug(f"Using keyring backend {backend.name!r}")
170+
171+
try:
172+
# unfortunately there is no clean of checking if keyring is unlocked
173+
keyring.get_password("python-poetry-check", "python-poetry")
174+
except (RuntimeError, keyring.errors.KeyringError):
175+
logger.debug(
176+
"Accessing keyring failed during availability check", exc_info=True
177+
)
178+
return False
179+
180+
return True
136181

137182

138183
class PasswordManager:
139184
def __init__(self, config: Config) -> None:
140185
self._config = config
141186

142-
@functools.cached_property
187+
@atomic_cached_property
143188
def use_keyring(self) -> bool:
144189
return self._config.get("keyring.enabled") and PoetryKeyring.is_available()
145190

146-
@functools.cached_property
191+
@atomic_cached_property
147192
def keyring(self) -> PoetryKeyring:
148193
if not self.use_keyring:
149194
raise PoetryKeyringError(

Diff for: tests/conftest.py

+13-7
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from tests.helpers import http_setup_redirect
4343
from tests.helpers import isolated_environment
4444
from tests.helpers import mock_clone
45+
from tests.helpers import set_keyring_backend
4546
from tests.helpers import switch_working_directory
4647
from tests.helpers import with_working_directory
4748

@@ -198,29 +199,29 @@ def dummy_keyring() -> DummyBackend:
198199

199200
@pytest.fixture()
200201
def with_simple_keyring(dummy_keyring: DummyBackend) -> None:
201-
keyring.set_keyring(dummy_keyring)
202+
set_keyring_backend(dummy_keyring)
202203

203204

204205
@pytest.fixture()
205206
def with_fail_keyring() -> None:
206-
keyring.set_keyring(FailKeyring()) # type: ignore[no-untyped-call]
207+
set_keyring_backend(FailKeyring()) # type: ignore[no-untyped-call]
207208

208209

209210
@pytest.fixture()
210211
def with_locked_keyring() -> None:
211-
keyring.set_keyring(LockedBackend()) # type: ignore[no-untyped-call]
212+
set_keyring_backend(LockedBackend()) # type: ignore[no-untyped-call]
212213

213214

214215
@pytest.fixture()
215216
def with_erroneous_keyring() -> None:
216-
keyring.set_keyring(ErroneousBackend()) # type: ignore[no-untyped-call]
217+
set_keyring_backend(ErroneousBackend()) # type: ignore[no-untyped-call]
217218

218219

219220
@pytest.fixture()
220221
def with_null_keyring() -> None:
221222
from keyring.backends.null import Keyring
222223

223-
keyring.set_keyring(Keyring()) # type: ignore[no-untyped-call]
224+
set_keyring_backend(Keyring()) # type: ignore[no-untyped-call]
224225

225226

226227
@pytest.fixture()
@@ -231,7 +232,7 @@ def with_chained_fail_keyring(mocker: MockerFixture) -> None:
231232
)
232233
from keyring.backends.chainer import ChainerBackend
233234

234-
keyring.set_keyring(ChainerBackend()) # type: ignore[no-untyped-call]
235+
set_keyring_backend(ChainerBackend()) # type: ignore[no-untyped-call]
235236

236237

237238
@pytest.fixture()
@@ -244,7 +245,7 @@ def with_chained_null_keyring(mocker: MockerFixture) -> None:
244245
)
245246
from keyring.backends.chainer import ChainerBackend
246247

247-
keyring.set_keyring(ChainerBackend()) # type: ignore[no-untyped-call]
248+
set_keyring_backend(ChainerBackend()) # type: ignore[no-untyped-call]
248249

249250

250251
@pytest.fixture
@@ -627,3 +628,8 @@ def handle(self) -> int:
627628
return MockCommand()
628629

629630
return _command_factory
631+
632+
633+
@pytest.fixture(autouse=True)
634+
def default_keyring(with_null_keyring: None) -> None:
635+
pass

Diff for: tests/helpers.py

+10
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from pathlib import Path
99
from typing import TYPE_CHECKING
1010

11+
import keyring
12+
1113
from poetry.core.packages.package import Package
1214
from poetry.core.packages.utils.link import Link
1315
from poetry.core.vcs.git import ParsedUrl
@@ -20,6 +22,7 @@
2022
from poetry.repositories import Repository
2123
from poetry.repositories.exceptions import PackageNotFoundError
2224
from poetry.utils._compat import metadata
25+
from poetry.utils.password_manager import PoetryKeyring
2326

2427

2528
if TYPE_CHECKING:
@@ -30,6 +33,7 @@
3033
import httpretty
3134

3235
from httpretty.core import HTTPrettyRequest
36+
from keyring.backend import KeyringBackend
3337
from poetry.core.constraints.version import Version
3438
from poetry.core.packages.dependency import Dependency
3539
from pytest_mock import MockerFixture
@@ -356,3 +360,9 @@ def with_working_directory(source: Path, target: Path | None = None) -> Iterator
356360

357361
with switch_working_directory(target or source, remove=use_copy) as path:
358362
yield path
363+
364+
365+
def set_keyring_backend(backend: KeyringBackend) -> None:
366+
"""Clears availability cache and sets the specified keyring backend."""
367+
PoetryKeyring.is_available.cache_clear()
368+
keyring.set_keyring(backend)

Diff for: tests/utils/test_authenticator.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from poetry.utils.authenticator import Authenticator
2020
from poetry.utils.authenticator import RepositoryCertificateConfig
21+
from poetry.utils.password_manager import PoetryKeyring
2122

2223

2324
if TYPE_CHECKING:
@@ -95,29 +96,39 @@ def test_authenticator_ignores_locked_keyring(
9596
http: type[httpretty.httpretty],
9697
with_locked_keyring: None,
9798
caplog: LogCaptureFixture,
99+
mocker: MockerFixture,
98100
) -> None:
99101
caplog.set_level(logging.DEBUG, logger="poetry.utils.password_manager")
102+
spy_get_credential = mocker.spy(PoetryKeyring, "get_credential")
103+
spy_get_password = mocker.spy(PoetryKeyring, "get_password")
100104
authenticator = Authenticator()
101105
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")
102106

103107
request = http.last_request()
104108
assert request.headers["Authorization"] is None
105-
assert "Keyring foo.bar is locked" in caplog.messages
109+
assert "Using keyring backend 'conftest LockedBackend'" in caplog.messages
110+
assert spy_get_credential.call_count == spy_get_password.call_count == 0
106111

107112

108113
def test_authenticator_ignores_failing_keyring(
109114
mock_remote: None,
110115
http: type[httpretty.httpretty],
111116
with_erroneous_keyring: None,
112117
caplog: LogCaptureFixture,
118+
mocker: MockerFixture,
113119
) -> None:
114120
caplog.set_level(logging.DEBUG, logger="poetry.utils.password_manager")
121+
spy_get_credential = mocker.spy(PoetryKeyring, "get_credential")
122+
spy_get_password = mocker.spy(PoetryKeyring, "get_password")
115123
authenticator = Authenticator()
116124
authenticator.request("get", "https://foo.bar/files/foo-0.1.0.tar.gz")
117125

118126
request = http.last_request()
119127
assert request.headers["Authorization"] is None
120-
assert "Accessing keyring foo.bar failed" in caplog.messages
128+
129+
assert "Using keyring backend 'conftest ErroneousBackend'" in caplog.messages
130+
assert "Accessing keyring failed during availability check" in caplog.messages
131+
assert spy_get_credential.call_count == spy_get_password.call_count == 0
121132

122133

123134
def test_authenticator_uses_password_only_credentials(

Diff for: tests/utils/test_password_manager.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -286,16 +286,18 @@ def test_fail_keyring_should_be_unavailable(
286286
assert not key_ring.is_available()
287287

288288

289-
def test_locked_keyring_should_be_available(with_locked_keyring: None) -> None:
289+
def test_locked_keyring_should_not_be_available(with_locked_keyring: None) -> None:
290290
key_ring = PoetryKeyring("poetry")
291291

292-
assert key_ring.is_available()
292+
assert not key_ring.is_available()
293293

294294

295-
def test_erroneous_keyring_should_be_available(with_erroneous_keyring: None) -> None:
295+
def test_erroneous_keyring_should_not_be_available(
296+
with_erroneous_keyring: None,
297+
) -> None:
296298
key_ring = PoetryKeyring("poetry")
297299

298-
assert key_ring.is_available()
300+
assert not key_ring.is_available()
299301

300302

301303
def test_get_http_auth_from_environment_variables(

0 commit comments

Comments
 (0)