Skip to content

Commit a19dea7

Browse files
authored
Merge pull request #1095 from python-wheel-build/fix/immutable-resolution-cache
fix(resolver): make resolution cache values immutable to prevent corr…
2 parents 478701b + ddd1ea1 commit a19dea7

3 files changed

Lines changed: 96 additions & 7 deletions

File tree

src/fromager/bootstrap_requirement_resolver.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ def __init__(
5151
self.prev_graph = prev_graph
5252
# Session-level resolution cache to avoid re-resolving same requirements
5353
# Key: (requirement_string, pre_built) to distinguish source vs prebuilt
54-
# Value: list of (url, version) tuples sorted by version (highest first)
54+
# Value: tuple of (url, version) tuples sorted by version (highest first)
55+
# Values are stored as immutable tuples to prevent accidental corruption
56+
# when callers modify the returned reference.
5557
self._resolved_requirements: dict[
56-
tuple[str, bool], list[tuple[str, Version]]
58+
tuple[str, bool], tuple[tuple[str, Version], ...]
5759
] = {}
5860

5961
def resolve(
@@ -106,7 +108,7 @@ def resolve(
106108
cached_result = self.get_cached_resolution(req, pre_built)
107109
if cached_result is not None:
108110
logger.debug(f"resolved {req} from cache")
109-
return cached_result if return_all_versions else [cached_result[0]]
111+
return list(cached_result) if return_all_versions else [cached_result[0]]
110112

111113
# Resolve using strategies
112114
results = self._resolve(req, req_type, parent_req, pre_built)
@@ -182,15 +184,17 @@ def get_cached_resolution(
182184
self,
183185
req: Requirement,
184186
pre_built: bool,
185-
) -> list[tuple[str, Version]] | None:
187+
) -> tuple[tuple[str, Version], ...] | None:
186188
"""Get a cached resolution result if it exists.
187189
190+
Returns an immutable tuple to prevent accidental cache corruption.
191+
188192
Args:
189193
req: Package requirement to look up in cache
190194
pre_built: Whether looking for prebuilt or source resolution
191195
192196
Returns:
193-
List of (url, version) tuples if cached, None otherwise
197+
Tuple of (url, version) tuples if cached, None otherwise
194198
"""
195199
cache_key = (str(req), pre_built)
196200
return self._resolved_requirements.get(cache_key)
@@ -203,6 +207,9 @@ def cache_resolution(
203207
) -> None:
204208
"""Cache a resolution result.
205209
210+
The result is stored as an immutable tuple to prevent accidental
211+
corruption when callers modify the original list.
212+
206213
Used by Bootstrapper to cache git URL resolutions that are
207214
handled externally (outside this resolver).
208215
@@ -212,7 +219,7 @@ def cache_resolution(
212219
result: List of (url, version) tuples
213220
"""
214221
cache_key = (str(req), pre_built)
215-
self._resolved_requirements[cache_key] = result
222+
self._resolved_requirements[cache_key] = tuple(result)
216223

217224
def _resolve_from_graph(
218225
self,

src/fromager/bootstrapper.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,9 @@ def resolve_versions(
224224
cached_result = self._resolver.get_cached_resolution(req, pre_built=False)
225225
if cached_result is not None:
226226
logger.debug(f"resolved {req} from cache")
227-
return cached_result if return_all_versions else [cached_result[0]]
227+
return (
228+
list(cached_result) if return_all_versions else [cached_result[0]]
229+
)
228230

229231
logger.info("resolving source via URL, ignoring any plugins")
230232
source_url, resolved_version = self._resolve_version_from_git_url(req=req)

tests/test_bootstrap_requirement_resolver.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,86 @@ def test_resolve_auto_routes_to_source(
562562
assert version == Version("2.0")
563563

564564

565+
def test_cache_resolution_stores_immutable_tuple(tmp_context: WorkContext) -> None:
566+
"""cache_resolution() stores an immutable tuple, not the original list."""
567+
resolver = BootstrapRequirementResolver(tmp_context)
568+
req = Requirement("mypkg>=1.0")
569+
original = [("https://example.com/mypkg-1.0.tar.gz", Version("1.0"))]
570+
571+
resolver.cache_resolution(req, pre_built=False, result=original)
572+
cached = resolver.get_cached_resolution(req, pre_built=False)
573+
574+
# Cached value should be a tuple
575+
assert isinstance(cached, tuple)
576+
577+
# Mutating the original list must not affect the cache
578+
original.append(("https://example.com/mypkg-2.0.tar.gz", Version("2.0")))
579+
cached_after = resolver.get_cached_resolution(req, pre_built=False)
580+
assert cached_after is not None
581+
assert len(cached_after) == 1
582+
583+
584+
def test_get_cached_resolution_returns_immutable(tmp_context: WorkContext) -> None:
585+
"""get_cached_resolution() returns a tuple that cannot be mutated."""
586+
resolver = BootstrapRequirementResolver(tmp_context)
587+
req = Requirement("mypkg>=1.0")
588+
589+
resolver.cache_resolution(
590+
req,
591+
pre_built=False,
592+
result=[("https://example.com/mypkg-1.0.tar.gz", Version("1.0"))],
593+
)
594+
cached = resolver.get_cached_resolution(req, pre_built=False)
595+
assert cached is not None
596+
597+
with pytest.raises(AttributeError):
598+
cached.append(("https://example.com/bad.tar.gz", Version("2.0"))) # type: ignore[attr-defined, union-attr]
599+
600+
with pytest.raises(TypeError):
601+
cached[0] = ("https://example.com/bad.tar.gz", Version("2.0")) # type: ignore[index]
602+
603+
604+
@patch("fromager.resolver.find_all_matching_from_provider")
605+
def test_resolve_cache_returns_independent_lists(
606+
mock_resolve: MagicMock,
607+
tmp_context: WorkContext,
608+
) -> None:
609+
"""resolve() returns independent list copies from the cache, not shared references."""
610+
req = Requirement("mypkg>=1.0")
611+
mock_resolve.return_value = [
612+
("https://example.com/mypkg-2.0.tar.gz", Version("2.0")),
613+
("https://example.com/mypkg-1.5.tar.gz", Version("1.5")),
614+
]
615+
616+
resolver = BootstrapRequirementResolver(tmp_context)
617+
618+
# First call populates cache
619+
results1 = resolver.resolve(
620+
req=req,
621+
req_type=RequirementType.INSTALL,
622+
parent_req=None,
623+
pre_built=False,
624+
return_all_versions=True,
625+
)
626+
627+
# Mutate the returned list
628+
results1.append(("https://example.com/injected.tar.gz", Version("9.9")))
629+
630+
# Second call should return clean cached data, unaffected by mutation
631+
results2 = resolver.resolve(
632+
req=req,
633+
req_type=RequirementType.INSTALL,
634+
parent_req=None,
635+
pre_built=False,
636+
return_all_versions=True,
637+
)
638+
639+
assert len(results2) == 2
640+
assert results1 is not results2
641+
# Only called once — second call used cache
642+
mock_resolve.assert_called_once()
643+
644+
565645
@patch("fromager.resolver.find_all_matching_from_provider")
566646
def test_resolve_prebuilt_after_source_uses_separate_cache(
567647
mock_resolve: MagicMock,

0 commit comments

Comments
 (0)