1414from 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
2732def _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+
43105def 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