Skip to content

Commit b5e4c46

Browse files
authored
gh-146228: Better fork support in cached FastPath (#146231)
* Apply changes from importlib_metadata 8.9.0 * Suppress deprecation warning in fork.
1 parent 1eff27f commit b5e4c46

File tree

8 files changed

+124
-43
lines changed

8 files changed

+124
-43
lines changed

Lib/importlib/metadata/__init__.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
from . import _meta
3333
from ._collections import FreezableDefaultDict, Pair
34-
from ._functools import method_cache, pass_none
34+
from ._functools import method_cache, noop, pass_none, passthrough
3535
from ._itertools import always_iterable, bucket, unique_everseen
3636
from ._meta import PackageMetadata, SimplePath
3737
from ._typing import md_none
@@ -783,6 +783,20 @@ def find_distributions(self, context=Context()) -> Iterable[Distribution]:
783783
"""
784784

785785

786+
@passthrough
787+
def _clear_after_fork(cached):
788+
"""Ensure ``func`` clears cached state after ``fork`` when supported.
789+
790+
``FastPath`` caches zip-backed ``pathlib.Path`` objects that retain a
791+
reference to the parent's open ``ZipFile`` handle. Re-using a cached
792+
instance in a forked child can therefore resurrect invalid file pointers
793+
and trigger ``BadZipFile``/``OSError`` failures (python/importlib_metadata#520).
794+
Registering ``cache_clear`` with ``os.register_at_fork`` keeps each process
795+
on its own cache.
796+
"""
797+
getattr(os, 'register_at_fork', noop)(after_in_child=cached.cache_clear)
798+
799+
786800
class FastPath:
787801
"""
788802
Micro-optimized class for searching a root for children.
@@ -799,7 +813,8 @@ class FastPath:
799813
True
800814
"""
801815

802-
@functools.lru_cache() # type: ignore[misc]
816+
@_clear_after_fork # type: ignore[misc]
817+
@functools.lru_cache()
803818
def __new__(cls, root):
804819
return super().__new__(cls)
805820

@@ -925,10 +940,12 @@ def __init__(self, name: str | None):
925940
def normalize(name):
926941
"""
927942
PEP 503 normalization plus dashes as underscores.
943+
944+
Specifically avoids ``re.sub`` as prescribed for performance
945+
benefits (see python/cpython#143658).
928946
"""
929-
# Much faster than re.sub, and even faster than str.translate
930947
value = name.lower().replace("-", "_").replace(".", "_")
931-
# Condense repeats (faster than regex)
948+
# Condense repeats
932949
while "__" in value:
933950
value = value.replace("__", "_")
934951
return value

Lib/importlib/metadata/_adapters.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
class RawPolicy(email.policy.EmailPolicy):
1010
def fold(self, name, value):
1111
folded = self.linesep.join(
12-
textwrap.indent(value, prefix=' ' * 8, predicate=lambda line: True)
12+
textwrap
13+
.indent(value, prefix=' ' * 8, predicate=lambda line: True)
1314
.lstrip()
1415
.splitlines()
1516
)

Lib/importlib/metadata/_functools.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import functools
22
import types
3+
from collections.abc import Callable
4+
from typing import TypeVar
35

46

57
# from jaraco.functools 3.3
@@ -102,3 +104,33 @@ def wrapper(param, *args, **kwargs):
102104
return func(param, *args, **kwargs)
103105

104106
return wrapper
107+
108+
109+
# From jaraco.functools 4.4
110+
def noop(*args, **kwargs):
111+
"""
112+
A no-operation function that does nothing.
113+
114+
>>> noop(1, 2, three=3)
115+
"""
116+
117+
118+
_T = TypeVar('_T')
119+
120+
121+
# From jaraco.functools 4.4
122+
def passthrough(func: Callable[..., object]) -> Callable[[_T], _T]:
123+
"""
124+
Wrap the function to always return the first parameter.
125+
126+
>>> passthrough(print)('3')
127+
3
128+
'3'
129+
"""
130+
131+
@functools.wraps(func)
132+
def wrapper(first: _T, *args, **kwargs) -> _T:
133+
func(first, *args, **kwargs)
134+
return first
135+
136+
return wrapper # type: ignore[return-value]

Lib/test/test_importlib/metadata/fixtures.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import shutil
77
import sys
88
import textwrap
9+
from importlib import resources
910

1011
from test.support import import_helper
1112
from test.support import os_helper
@@ -14,11 +15,6 @@
1415
from . import _path
1516
from ._path import FilesSpec
1617

17-
if sys.version_info >= (3, 9):
18-
from importlib import resources
19-
else:
20-
import importlib_resources as resources
21-
2218

2319
@contextlib.contextmanager
2420
def tmp_path():
@@ -374,8 +370,6 @@ def setUp(self):
374370
# Add self.zip_name to the front of sys.path.
375371
self.resources = contextlib.ExitStack()
376372
self.addCleanup(self.resources.close)
377-
# workaround for #138313
378-
self.addCleanup(lambda: None)
379373

380374

381375
def parameterize(*args_set):

Lib/test/test_importlib/metadata/test_api.py

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -317,33 +317,31 @@ def test_invalidate_cache(self):
317317

318318

319319
class PreparedTests(unittest.TestCase):
320-
def test_normalize(self):
321-
tests = [
322-
# Simple
323-
("sample", "sample"),
324-
# Mixed case
325-
("Sample", "sample"),
326-
("SAMPLE", "sample"),
327-
("SaMpLe", "sample"),
328-
# Separator conversions
329-
("sample-pkg", "sample_pkg"),
330-
("sample.pkg", "sample_pkg"),
331-
("sample_pkg", "sample_pkg"),
332-
# Multiple separators
333-
("sample---pkg", "sample_pkg"),
334-
("sample___pkg", "sample_pkg"),
335-
("sample...pkg", "sample_pkg"),
336-
# Mixed separators
337-
("sample-._pkg", "sample_pkg"),
338-
("sample_.-pkg", "sample_pkg"),
339-
# Complex
340-
("Sample__Pkg-name.foo", "sample_pkg_name_foo"),
341-
("Sample__Pkg.name__foo", "sample_pkg_name_foo"),
342-
# Uppercase with separators
343-
("SAMPLE-PKG", "sample_pkg"),
344-
("Sample.Pkg", "sample_pkg"),
345-
("SAMPLE_PKG", "sample_pkg"),
346-
]
347-
for name, expected in tests:
348-
with self.subTest(name=name):
349-
self.assertEqual(Prepared.normalize(name), expected)
320+
@fixtures.parameterize(
321+
# Simple
322+
dict(input='sample', expected='sample'),
323+
# Mixed case
324+
dict(input='Sample', expected='sample'),
325+
dict(input='SAMPLE', expected='sample'),
326+
dict(input='SaMpLe', expected='sample'),
327+
# Separator conversions
328+
dict(input='sample-pkg', expected='sample_pkg'),
329+
dict(input='sample.pkg', expected='sample_pkg'),
330+
dict(input='sample_pkg', expected='sample_pkg'),
331+
# Multiple separators
332+
dict(input='sample---pkg', expected='sample_pkg'),
333+
dict(input='sample___pkg', expected='sample_pkg'),
334+
dict(input='sample...pkg', expected='sample_pkg'),
335+
# Mixed separators
336+
dict(input='sample-._pkg', expected='sample_pkg'),
337+
dict(input='sample_.-pkg', expected='sample_pkg'),
338+
# Complex
339+
dict(input='Sample__Pkg-name.foo', expected='sample_pkg_name_foo'),
340+
dict(input='Sample__Pkg.name__foo', expected='sample_pkg_name_foo'),
341+
# Uppercase with separators
342+
dict(input='SAMPLE-PKG', expected='sample_pkg'),
343+
dict(input='Sample.Pkg', expected='sample_pkg'),
344+
dict(input='SAMPLE_PKG', expected='sample_pkg'),
345+
)
346+
def test_normalize(self, input, expected):
347+
self.assertEqual(Prepared.normalize(input), expected)

Lib/test/test_importlib/metadata/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
import pickle
33
import re
44
import unittest
5-
from test.support import os_helper
65

76
try:
87
import pyfakefs.fake_filesystem_unittest as ffs
98
except ImportError:
109
from .stubs import fake_filesystem_unittest as ffs
10+
from test.support import os_helper
1111

1212
from importlib.metadata import (
1313
Distribution,

Lib/test/test_importlib/metadata/test_zip.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import multiprocessing
2+
import os
13
import sys
24
import unittest
35

6+
from test.support import warnings_helper
7+
48
from importlib.metadata import (
9+
FastPath,
510
PackageNotFoundError,
611
distribution,
712
distributions,
@@ -47,6 +52,38 @@ def test_one_distribution(self):
4752
dists = list(distributions(path=sys.path[:1]))
4853
assert len(dists) == 1
4954

55+
@warnings_helper.ignore_fork_in_thread_deprecation_warnings()
56+
@unittest.skipUnless(
57+
hasattr(os, 'register_at_fork')
58+
and 'fork' in multiprocessing.get_all_start_methods(),
59+
'requires fork-based multiprocessing support',
60+
)
61+
def test_fastpath_cache_cleared_in_forked_child(self):
62+
zip_path = sys.path[0]
63+
64+
FastPath(zip_path)
65+
assert FastPath.__new__.cache_info().currsize >= 1
66+
67+
ctx = multiprocessing.get_context('fork')
68+
parent_conn, child_conn = ctx.Pipe()
69+
70+
def child(conn, root):
71+
try:
72+
before = FastPath.__new__.cache_info().currsize
73+
FastPath(root)
74+
after = FastPath.__new__.cache_info().currsize
75+
conn.send((before, after))
76+
finally:
77+
conn.close()
78+
79+
proc = ctx.Process(target=child, args=(child_conn, zip_path))
80+
proc.start()
81+
child_conn.close()
82+
cache_sizes = parent_conn.recv()
83+
proc.join()
84+
85+
self.assertEqual(cache_sizes, (0, 1))
86+
5087

5188
class TestEgg(TestZip):
5289
def setUp(self):
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Cached FastPath objects in importlib.metadata are now cleared on fork,
2+
avoiding broken references to zip files during fork.

0 commit comments

Comments
 (0)