Skip to content

Commit 23bbc46

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 the application 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 c4e909b commit 23bbc46

File tree

6 files changed

+89
-23
lines changed

6 files changed

+89
-23
lines changed

src/poetry/console/application.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
from cleo.formatters.style import Style
1818

1919
from poetry.__version__ import __version__
20+
from poetry.config.config import Config
2021
from poetry.console.command_loader import CommandLoader
2122
from poetry.console.commands.command import Command
2223
from poetry.utils.helpers import directory
2324
from poetry.utils.helpers import ensure_path
25+
from poetry.utils.password_manager import PoetryKeyring
2426

2527

2628
if TYPE_CHECKING:
@@ -218,6 +220,26 @@ def create_io(
218220

219221
return io
220222

223+
@staticmethod
224+
def preflight(io: IO) -> None:
225+
config = Config.create()
226+
if config.get("keyring.enabled"):
227+
if io.is_verbose():
228+
io.write("Checking keyring availability ... ")
229+
230+
keyring_available = PoetryKeyring.is_available()
231+
if io.is_verbose():
232+
if keyring_available:
233+
io.write(
234+
"<fg=green;options=bold>Available</>",
235+
)
236+
else:
237+
io.write(
238+
"<fg=yellow;options=bold>Unavailable</>",
239+
)
240+
241+
io.write_line("")
242+
221243
def _run(self, io: IO) -> int:
222244
# we do this here and not inside the _configure_io implementation in order
223245
# to ensure the users are not exposed to a stack trace for providing invalid values to
@@ -228,6 +250,7 @@ def _run(self, io: IO) -> int:
228250
self._load_plugins(io)
229251

230252
with directory(self._working_directory):
253+
self.preflight(io)
231254
exit_code: int = super()._run(io)
232255

233256
return exit_code

src/poetry/utils/password_manager.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
from contextlib import suppress
88
from typing import TYPE_CHECKING
99

10+
from poetry.utils.threading import atomic_cached_property
11+
1012

1113
if TYPE_CHECKING:
12-
from keyring.backend import KeyringBackend
14+
import keyring.backend
1315

1416
from poetry.config.config import Config
1517

@@ -62,9 +64,9 @@ def get_password(self, name: str, username: str) -> str | None:
6264

6365
try:
6466
return keyring.get_password(name, username)
65-
except (RuntimeError, keyring.errors.KeyringError):
67+
except (RuntimeError, keyring.errors.KeyringError) as e:
6668
raise PoetryKeyringError(
67-
f"Unable to retrieve the password for {name} from the key ring"
69+
f"Unable to retrieve the password for {name} from the key ring {e}"
6870
)
6971

7072
def set_password(self, name: str, username: str, password: str) -> None:
@@ -96,20 +98,22 @@ def get_entry_name(self, name: str) -> str:
9698
return f"{self._namespace}-{name}"
9799

98100
@classmethod
101+
@functools.cache
99102
def is_available(cls) -> bool:
100103
logger.debug("Checking if keyring is available")
101104
try:
102105
import keyring
103106
import keyring.backend
107+
import keyring.errors
104108
except ImportError as e:
105109
logger.debug("An error occurred while importing keyring: %s", e)
106110
return False
107111

108-
def backend_name(backend: KeyringBackend) -> str:
112+
def backend_name(backend: keyring.backend.KeyringBackend) -> str:
109113
name: str = backend.name
110114
return name.split(" ")[0]
111115

112-
def backend_is_valid(backend: KeyringBackend) -> bool:
116+
def backend_is_valid(backend: keyring.backend.KeyringBackend) -> bool:
113117
name = backend_name(backend)
114118
if name in ("chainer", "fail", "null"):
115119
logger.debug(f"Backend {backend.name!r} is not suitable")
@@ -130,20 +134,30 @@ def backend_is_valid(backend: KeyringBackend) -> bool:
130134
if valid_backend is None:
131135
logger.debug("No valid keyring backend was found")
132136
return False
133-
else:
134-
logger.debug(f"Using keyring backend {backend.name!r}")
135-
return True
137+
138+
logger.debug(f"Using keyring backend {backend.name!r}")
139+
140+
try:
141+
# unfortunately there is no clean of checking if keyring is unlocked
142+
keyring.get_password("python-poetry-check", "python-poetry")
143+
except (RuntimeError, keyring.errors.KeyringError):
144+
logger.debug(
145+
"Accessing keyring failed during availability check", exc_info=True
146+
)
147+
return False
148+
149+
return True
136150

137151

138152
class PasswordManager:
139153
def __init__(self, config: Config) -> None:
140154
self._config = config
141155

142-
@functools.cached_property
156+
@atomic_cached_property
143157
def use_keyring(self) -> bool:
144158
return self._config.get("keyring.enabled") and PoetryKeyring.is_available()
145159

146-
@functools.cached_property
160+
@atomic_cached_property
147161
def keyring(self) -> PoetryKeyring:
148162
if not self.use_keyring:
149163
raise PoetryKeyringError(

tests/conftest.py

Lines changed: 13 additions & 7 deletions
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

@@ -193,29 +194,29 @@ def dummy_keyring() -> DummyBackend:
193194

194195
@pytest.fixture()
195196
def with_simple_keyring(dummy_keyring: DummyBackend) -> None:
196-
keyring.set_keyring(dummy_keyring)
197+
set_keyring_backend(dummy_keyring)
197198

198199

199200
@pytest.fixture()
200201
def with_fail_keyring() -> None:
201-
keyring.set_keyring(FailKeyring()) # type: ignore[no-untyped-call]
202+
set_keyring_backend(FailKeyring()) # type: ignore[no-untyped-call]
202203

203204

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

208209

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

213214

214215
@pytest.fixture()
215216
def with_null_keyring() -> None:
216217
from keyring.backends.null import Keyring
217218

218-
keyring.set_keyring(Keyring()) # type: ignore[no-untyped-call]
219+
set_keyring_backend(Keyring()) # type: ignore[no-untyped-call]
219220

220221

221222
@pytest.fixture()
@@ -226,7 +227,7 @@ def with_chained_fail_keyring(mocker: MockerFixture) -> None:
226227
)
227228
from keyring.backends.chainer import ChainerBackend
228229

229-
keyring.set_keyring(ChainerBackend()) # type: ignore[no-untyped-call]
230+
set_keyring_backend(ChainerBackend()) # type: ignore[no-untyped-call]
230231

231232

232233
@pytest.fixture()
@@ -239,7 +240,7 @@ def with_chained_null_keyring(mocker: MockerFixture) -> None:
239240
)
240241
from keyring.backends.chainer import ChainerBackend
241242

242-
keyring.set_keyring(ChainerBackend()) # type: ignore[no-untyped-call]
243+
set_keyring_backend(ChainerBackend()) # type: ignore[no-untyped-call]
243244

244245

245246
@pytest.fixture
@@ -582,3 +583,8 @@ def project_context(project: str | Path, in_place: bool = False) -> Iterator[Pat
582583
yield path
583584

584585
return project_context
586+
587+
588+
@pytest.fixture(autouse=True)
589+
def default_keyring(with_null_keyring: None) -> None:
590+
pass

tests/helpers.py

Lines changed: 10 additions & 0 deletions
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)

tests/utils/test_authenticator.py

Lines changed: 13 additions & 2 deletions
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(

tests/utils/test_password_manager.py

Lines changed: 6 additions & 4 deletions
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)