Skip to content

Commit e5fb473

Browse files
committed
fix: make _clear_cookies safe against concurrent jar mutation
- skip None entries yielded by py<=3.10 http.cookiejar.deepvalues - tolerate KeyError when a peer removed the cookie before clear() - retarget the concurrency test at _clear_cookies, not stdlib jar reads - add deterministic tests covering both guards
1 parent aa0d359 commit e5fb473

2 files changed

Lines changed: 118 additions & 30 deletions

File tree

‎src/osw/wtsite.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -498,10 +498,21 @@ def _clear_cookies(self):
498498
# hold the session lock so a peer thread cannot clear/re-login concurrently.
499499
with self._get_session_lock():
500500
for cookie in list(self._site.connection.cookies):
501+
# The session lock does not cover requests' own jar writes:
502+
# extract_cookies() drops any cookie whose Set-Cookie expiry is in
503+
# the past, on every response. On py<=3.10 http.cookiejar iterates
504+
# via a lazy map(dict.get, sorted(keys)), so such a delete makes the
505+
# snapshot yield None instead of a cookie.
506+
if cookie is None:
507+
continue
501508
if "PostEditRevision" in cookie.name:
502-
self._site.connection.cookies.clear(
503-
cookie.domain, cookie.path, cookie.name
504-
)
509+
try:
510+
self._site.connection.cookies.clear(
511+
cookie.domain, cookie.path, cookie.name
512+
)
513+
except KeyError:
514+
# already removed between snapshot and delete
515+
pass
505516

506517
class SearchParam(wt.SearchParam):
507518
pass

‎tests/test_wtsite_session_lock.py‎

Lines changed: 104 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,19 @@
1414
from osw.wtsite import WtPage, WtSite
1515

1616

17-
def _make_wtsite_with_jar():
18-
"""Build a minimal WtSite carrying a real cookie jar, bypassing __init__."""
19-
jar = rc.RequestsCookieJar()
17+
def _make_wtsite_for(jar):
18+
"""Wrap an arbitrary cookie jar in a minimal WtSite, bypassing __init__."""
2019
fake_site = types.SimpleNamespace(connection=types.SimpleNamespace(cookies=jar))
2120
ws = WtSite.__new__(WtSite)
2221
ws._session_lock = threading.RLock()
2322
ws._site = fake_site
24-
return ws, jar
23+
return ws
24+
25+
26+
def _make_wtsite_with_jar():
27+
"""Build a minimal WtSite carrying a real cookie jar, bypassing __init__."""
28+
jar = rc.RequestsCookieJar()
29+
return _make_wtsite_for(jar), jar
2530

2631

2732
def _populate(jar, n=25):
@@ -40,51 +45,123 @@ def test_clear_cookies_removes_only_post_edit_revision():
4045
assert {c.name for c in jar} == {"sessionToken"}
4146

4247

48+
def _fake_cookie(name):
49+
return types.SimpleNamespace(name=name, domain="example.org", path="/")
50+
51+
52+
class _FakeJar:
53+
"""Stands in for a jar being mutated by requests while we iterate it."""
54+
55+
def __init__(self, entries):
56+
self._entries = entries
57+
self.cleared = []
58+
59+
def __iter__(self):
60+
return iter(self._entries)
61+
62+
def clear(self, domain, path, name):
63+
self.cleared.append((domain, path, name))
64+
65+
66+
def test_clear_cookies_skips_none_entries_from_the_jar():
67+
"""py<=3.10 http.cookiejar yields None for a cookie deleted mid-iteration.
68+
69+
Its deepvalues() walks a lazy map(dict.get, sorted(keys)), so a concurrent
70+
delete turns into a None rather than a missing entry. Without the guard this
71+
raises AttributeError: 'NoneType' object has no attribute 'name'.
72+
"""
73+
jar = _FakeJar([None, _fake_cookie("PostEditRevision1"), None])
74+
ws = _make_wtsite_for(jar)
75+
76+
ws._clear_cookies()
77+
78+
assert jar.cleared == [("example.org", "/", "PostEditRevision1")]
79+
80+
81+
def test_clear_cookies_tolerates_a_cookie_already_removed():
82+
"""CookieJar.clear raises KeyError if a peer deleted the cookie first.
83+
84+
_clear_cookies works off a snapshot, so any entry may be gone by the time it
85+
calls clear(). It must swallow that and keep going, not abort the sweep.
86+
"""
87+
88+
class _RacingJar(_FakeJar):
89+
def clear(self, domain, path, name):
90+
super().clear(domain, path, name)
91+
raise KeyError(name)
92+
93+
jar = _RacingJar([
94+
_fake_cookie("PostEditRevision1"),
95+
_fake_cookie("PostEditRevision2"),
96+
])
97+
ws = _make_wtsite_for(jar)
98+
99+
ws._clear_cookies()
100+
101+
# the KeyError on the first cookie must not stop the second from being tried
102+
assert len(jar.cleared) == 2
103+
104+
43105
def test_clear_cookies_under_concurrency_is_safe():
44-
"""Concurrent clears + reads must not raise and must preserve unrelated cookies."""
106+
"""_clear_cookies must survive jar writes the session lock does not cover.
107+
108+
requests' own extract_cookies() runs on every response and drops any cookie
109+
whose Set-Cookie expiry is in the past, taking only the jar's internal lock.
110+
So it races _clear_cookies two ways: the delete can land between the
111+
snapshot and the follow-up clear() (KeyError, any version), and on py<=3.10
112+
http.cookiejar iterates via a lazy map(dict.get, sorted(keys)), so the
113+
snapshot yields None instead of the deleted cookie.
114+
115+
Only _clear_cookies is asserted on. Unsynchronized iteration of the jar is
116+
not something WtSite can make safe, since requests never takes the session
117+
lock, so the reader here is a contention generator and nothing more.
118+
"""
45119
ws, jar = _make_wtsite_with_jar()
46120
_populate(jar)
47121

48-
errors = []
122+
clear_errors = []
49123
stop = threading.Event()
50124

125+
def peer_writer():
126+
"""Stand in for requests.extract_cookies: set + delete, no session lock."""
127+
while not stop.is_set():
128+
for i in range(25):
129+
jar.set(f"PostEditRevision{i}", f"v{i}", domain="example.org", path="/")
130+
for i in range(25):
131+
try:
132+
jar.clear("example.org", "/", f"PostEditRevision{i}")
133+
except KeyError:
134+
pass
135+
51136
def clearer():
52137
try:
53138
for _ in range(200):
54-
# re-add under the lock so there is always something to clear,
55-
# creating real contention with the concurrent _clear_cookies calls
56-
with ws._session_lock:
57-
for i in range(25):
58-
jar.set(
59-
f"PostEditRevision{i}",
60-
f"v{i}",
61-
domain="example.org",
62-
path="/",
63-
)
64139
ws._clear_cookies()
65140
except Exception as exc:
66-
errors.append(exc)
141+
clear_errors.append(exc)
67142

68143
def reader():
69-
try:
70-
while not stop.is_set():
71-
_ = [c.name for c in list(jar)]
72-
except Exception as exc:
73-
errors.append(exc)
74-
144+
while not stop.is_set():
145+
try:
146+
_ = [c.name for c in list(jar) if c is not None]
147+
except RuntimeError:
148+
# dict resized mid-iteration; the stdlib jar is not thread-safe
149+
pass
150+
151+
peers = [threading.Thread(target=peer_writer, daemon=True) for _ in range(2)]
152+
readers = [threading.Thread(target=reader, daemon=True) for _ in range(2)]
75153
clearers = [threading.Thread(target=clearer) for _ in range(4)]
76-
readers = [threading.Thread(target=reader) for _ in range(2)]
77-
for t in readers:
154+
for t in peers + readers:
78155
t.start()
79156
for t in clearers:
80157
t.start()
81158
for t in clearers:
82159
t.join()
83160
stop.set()
84-
for t in readers:
161+
for t in peers + readers:
85162
t.join()
86163

87-
assert errors == []
164+
assert clear_errors == []
88165
# the non-PostEditRevision cookie must survive every clear
89166
assert "sessionToken" in {c.name for c in jar}
90167

0 commit comments

Comments
 (0)