Skip to content

Commit 7b19a93

Browse files
committed
Retry profile reads after transient loader failures
1 parent 06317b8 commit 7b19a93

2 files changed

Lines changed: 103 additions & 6 deletions

File tree

DisplayCAL/profile_loader.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3803,6 +3803,7 @@ def _generate_gamma_ramp(
38033803
if (
38043804
not self._reset_gamma_ramps
38053805
and (self._manual_restore or profile_association_changed)
3806+
and profile is not None
38063807
and profile.tags.get("vcgt")
38073808
):
38083809
print(lang.getstr("calibration.loading_from_display_profile"))
@@ -3833,6 +3834,8 @@ def _generate_gamma_ramp(
38333834
vcgt_values = vcgt.get_values()[:3]
38343835
if self._reset_gamma_ramps:
38353836
print("Caching linear gamma ramps")
3837+
elif profile is None:
3838+
print("Using temporary linear gamma ramps for profile", desc)
38363839
else:
38373840
print("Caching implicit linear gamma ramps for profile", desc)
38383841
else:
@@ -3851,11 +3854,15 @@ def _generate_gamma_ramp(
38513854
if j == 0:
38523855
vcgt_value += 1
38533856
vcgt_ramp_hack[k][j] = vcgt_value
3854-
self.ramps[self._reset_gamma_ramps or key] = (
3855-
vcgt_ramp,
3856-
vcgt_ramp_hack,
3857-
vcgt_values,
3858-
)
3857+
# A failed read must not turn the linear fallback into a cached
3858+
# calibration. The file may become readable without its association
3859+
# or modification time changing (e.g. after a sharing violation).
3860+
if self._reset_gamma_ramps or profile is not None:
3861+
self.ramps[self._reset_gamma_ramps or key] = (
3862+
vcgt_ramp,
3863+
vcgt_ramp_hack,
3864+
vcgt_values,
3865+
)
38593866
recheck = True
38603867
return recheck, vcgt_ramp, vcgt_ramp_hack, vcgt_values
38613868

@@ -3895,7 +3902,8 @@ def _retrieve_profile_vcgt(
38953902
self.profiles[key].tags.get("vcgt")
38963903
except Exception as exception:
38973904
print(exception)
3898-
self.profiles[key] = ICCProfile()
3905+
self.profiles[key] = None
3906+
return vcgt_values, None
38993907
profile = self.profiles[key]
39003908
if isinstance(profile.tags.get("vcgt"), VideoCardGammaType):
39013909
# Get display profile vcgt

tests/test_profile_loader_retry.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Profile reads can fail while an installed ICC file is being replaced."""
2+
3+
import struct
4+
from unittest import mock
5+
6+
import pytest
7+
8+
from DisplayCAL import profile_loader
9+
from DisplayCAL.colormath import get_rgb_space
10+
from DisplayCAL.icc_profile import ICCProfile, VideoCardGammaFormulaType
11+
12+
13+
def make_loader():
14+
"""Use the ramp code without starting a tray icon or touching the display."""
15+
loader = profile_loader.ProfileLoader.__new__(profile_loader.ProfileLoader)
16+
loader.profiles = {}
17+
loader.ramps = {}
18+
loader._reset_gamma_ramps = False
19+
loader._manual_restore = False
20+
loader._quantize = 65535.0
21+
return loader
22+
23+
24+
def generate_ramp(loader, path, key="display-1"):
25+
return loader._generate_gamma_ramp(
26+
False, [], [], "Test display", key, str(path), False, "Test profile"
27+
)[1]
28+
29+
30+
def make_profile(path, calibrated=True):
31+
profile = ICCProfile.from_rgb_space(get_rgb_space("sRGB"), "Test profile")
32+
if calibrated:
33+
data = b"vcgt" + b"\0" * 4 + struct.pack(">I", 1)
34+
data += struct.pack(">III", 2 * 65536, 0, 65536) * 3
35+
profile.tags["vcgt"] = VideoCardGammaFormulaType(data, "vcgt")
36+
profile.write(str(path))
37+
38+
39+
@pytest.mark.parametrize("failures", [1, 2])
40+
@pytest.mark.parametrize("manual_restore", [False, True])
41+
def test_profile_read_is_retried_without_an_association_change(
42+
tmp_path, failures, manual_restore
43+
):
44+
path = tmp_path / "calibrated.icc"
45+
make_profile(path)
46+
loader = make_loader()
47+
loader._manual_restore = manual_restore
48+
reads = 0
49+
50+
def read_profile(filename=None):
51+
nonlocal reads
52+
if filename:
53+
reads += 1
54+
if reads <= failures:
55+
raise PermissionError("Profile is temporarily locked")
56+
return ICCProfile(filename)
57+
58+
with mock.patch.object(profile_loader, "ICCProfile", side_effect=read_profile):
59+
for _ in range(failures):
60+
fallback = generate_ramp(loader, path)
61+
assert fallback[0][128] == 32896
62+
recovered = generate_ramp(loader, path)
63+
# The file and association have not changed. Retry must replace the
64+
# temporary linear fallback with the profile's gamma 2.0 calibration.
65+
assert recovered[0][128] < 17000
66+
assert reads == failures + 1
67+
assert generate_ramp(loader, path) is recovered
68+
assert reads == failures + 1
69+
70+
71+
@pytest.mark.parametrize("calibrated", [False, True])
72+
def test_successful_profiles_are_still_cached(tmp_path, calibrated):
73+
path = tmp_path / "valid.icc"
74+
make_profile(path, calibrated)
75+
loader = make_loader()
76+
with mock.patch.object(profile_loader, "ICCProfile", wraps=ICCProfile) as read:
77+
ramp = generate_ramp(loader, path)
78+
assert generate_ramp(loader, path) is ramp
79+
read.assert_called_once_with(str(path))
80+
81+
82+
def test_explicit_reset_does_not_read_the_profile(tmp_path):
83+
loader = make_loader()
84+
loader._reset_gamma_ramps = True
85+
with mock.patch.object(profile_loader, "ICCProfile", wraps=ICCProfile) as read:
86+
ramp = generate_ramp(loader, tmp_path / "unused.icc")
87+
assert ramp[0][128] == 32896
88+
assert generate_ramp(loader, tmp_path / "unused.icc") is ramp
89+
read.assert_not_called()

0 commit comments

Comments
 (0)