1010and ``PCAPKIT_TEST_TIMEOUT_SCALE`` stretches it exactly as it stretches
1111:func:`tests._support.time_limit`.
1212
13+ The spin that stands in for a hang is bounded (#1889). Unbounded, a deadline
14+ whose raise was lost -- one raised inside a finaliser is reported and dropped --
15+ spun until pytest-timeout's backstop ended the whole xdist worker. Now it gives up
16+ after :data:`SPIN_LIMIT` and the test fails by name, and the generator repeats
17+ the raise until :func:`roundtrip` has it.
18+
1319This module is unit tier: it loads the generator by path and builds nothing, so
1420it imports no :mod:`pcapkit` and reads no capture.
1521
2127import os
2228import signal
2329import sys
30+ import threading
31+ import time
2432import types
2533import unittest
34+ import warnings
2635from typing import TYPE_CHECKING
2736from unittest import mock
2837
3544#: different name fails here rather than agreeing with itself.
3645SCALE_ENV = 'PCAPKIT_TEST_TIMEOUT_SCALE'
3746
47+ #: Seconds :class:`_Spin` runs before it gives up on being interrupted: ten times
48+ #: the one-second deadline the tests here arm, so a deadline that fires at all,
49+ #: repeats included, lands well inside it.
50+ SPIN_LIMIT = 10.0
51+
3852
3953def _load_generator () -> 'types.ModuleType' :
4054 """Load :file:`examples/generators/options.py` by path, under a name of its own.
@@ -58,13 +72,85 @@ def _load_generator() -> 'types.ModuleType':
5872 return module
5973
6074
75+ class _SpinGaveUp (Exception ):
76+ """Raised by :class:`_Spin` once its limit passes with no deadline raised in it."""
77+
78+
6179class _Spin : # pylint: disable=too-few-public-methods
62- """A family whose constructor never returns, standing in for a real hang."""
80+ """A family whose constructor spins until the deadline interrupts it.
81+
82+ It stands in for a real hang, but a bounded one: after ``limit`` seconds it
83+ sets :attr:`gave_up` and raises :exc:`_SpinGaveUp`, so a lost deadline fails
84+ the test in seconds instead of hanging the worker.
85+
86+ Args:
87+ limit: Seconds to spin before giving up.
88+ swallow: How many :exc:`TimeoutError` to drop before letting one
89+ through, standing in for a finaliser that drops the deadline's raise.
90+ clear: Cancel the pending alarm on entry, so no deadline ever fires.
91+
92+ """
93+
94+ def __init__ (self , limit : float = SPIN_LIMIT , swallow : int = 0 ,
95+ clear : bool = False ) -> None :
96+ self .limit = limit
97+ self .swallow = swallow
98+ self .clear = clear
99+ #: :exc:`TimeoutError` dropped so far.
100+ self .swallowed = 0
101+ #: Whether the limit passed before a deadline got through.
102+ self .gave_up = False
103+
104+ def build (self , code : 'Any' , kwargs : 'Any' ) -> bytes : # pylint: disable=unused-argument
105+ """Loop until the deadline interrupts it, or until :attr:`limit` passes."""
106+ if self .clear :
107+ signal .alarm (0 )
108+ end = time .monotonic () + self .limit
109+ while time .monotonic () < end :
110+ try :
111+ while time .monotonic () < end :
112+ pass
113+ except TimeoutError :
114+ if self .swallowed == self .swallow :
115+ raise
116+ self .swallowed += 1
117+ self .gave_up = True
118+ raise _SpinGaveUp (f'no deadline interrupted the spin within { self .limit :g} s' )
119+
120+
121+ class _Abort (BaseException ):
122+ """Leaves :func:`roundtrip` past every ``except Exception`` clause."""
123+
124+
125+ class _RaiseAt :
126+ """A family that raises ``exc`` at ``step``, or round-trips one octet if it is :data:`None`."""
127+
128+ def __init__ (self , step : 'str | None' , exc : 'BaseException | None' ) -> None :
129+ self .step = step
130+ self .exc = exc
131+
132+ def _at (self , step : str ) -> None :
133+ if step == self .step and self .exc is not None :
134+ raise self .exc
63135
64136 def build (self , code : 'Any' , kwargs : 'Any' ) -> bytes : # pylint: disable=unused-argument
65- """Loop until the deadline interrupts it."""
66- while True :
67- pass
137+ """One zero octet, unless this is the step to raise at."""
138+ self ._at ('build' )
139+ return b'\x00 '
140+
141+ def parse (self , octets : bytes ) -> bytes :
142+ """``octets`` as they are, unless this is the step to raise at."""
143+ self ._at ('parse' )
144+ return octets
145+
146+ def extract (self , parsed : bytes ) -> bytes :
147+ """``parsed`` as it is."""
148+ return parsed
149+
150+ def rebuild (self , parsed : bytes ) -> bytes :
151+ """``parsed`` as it is, unless this is the step to raise at."""
152+ self ._at ('rebuild' )
153+ return parsed
68154
69155
70156@unittest .skipUnless (hasattr (signal , 'SIGALRM' ), 'needs signal.SIGALRM' )
@@ -94,6 +180,19 @@ def armed(self, seconds: int) -> int:
94180 with self .options ._deadline (seconds ): # pylint: disable=protected-access
95181 return signal .alarm (0 )
96182
183+ def roundtrip (self , family : 'Any' ) -> 'Any' :
184+ """:func:`roundtrip` over ``family`` under a one-second deadline (scale 0.5 x 2s)."""
185+ os .environ [SCALE_ENV ] = '0.5'
186+ case = self .options .Case ('spin' , 0 , 'Spin' , {})
187+ with mock .patch .dict (self .options .FAMILY_MAP , {'spin' : family }):
188+ return self .options .roundtrip (case , deadline = 2 )
189+
190+ def assert_interrupted (self , spin : '_Spin' , outcome : 'Any' ) -> None :
191+ """Fail by name, rather than on a status mismatch, if the deadline never got through."""
192+ if spin .gave_up :
193+ self .fail (f'the deadline never interrupted the spin; roundtrip recorded '
194+ f'{ outcome .status } : { outcome .detail } ' )
195+
97196 def test_the_default_is_thirty_seconds_unscaled (self ) -> None :
98197 """Unset, the default applies as written -- and it is no longer 5."""
99198 self .assertEqual (self .options .DEADLINE , 30 )
@@ -142,13 +241,119 @@ def test_a_zero_deadline_stays_disarmed_under_any_scale(self) -> None:
142241
143242 def test_a_hang_is_still_recorded_as_timeout_naming_the_scaled_deadline (self ) -> None :
144243 """The ``'TIMEOUT'`` contract holds, and its detail names the seconds allowed."""
145- os .environ [SCALE_ENV ] = '0.5'
146- case = self .options .Case ('spin' , 0 , 'Spin' , {})
147- with mock .patch .dict (self .options .FAMILY_MAP , {'spin' : _Spin ()}):
148- outcome = self .options .roundtrip (case , deadline = 2 )
244+ spin = _Spin ()
245+ outcome = self .roundtrip (spin )
246+ self .assert_interrupted (spin , outcome )
247+ self .assertEqual (outcome .status , 'TIMEOUT' )
248+ self .assertEqual (outcome .detail , 'did not finish within 1s' )
249+ self .assertIs (signal .getsignal (signal .SIGALRM ), self .handler )
250+
251+ def test_a_raise_that_never_reaches_the_body_is_repeated (self ) -> None :
252+ """#1889: a dropped raise is followed by another, still recorded as ``'TIMEOUT'``."""
253+ spin = _Spin (swallow = 1 )
254+ outcome = self .roundtrip (spin )
255+ self .assert_interrupted (spin , outcome )
256+ self .assertEqual (spin .swallowed , 1 )
149257 self .assertEqual (outcome .status , 'TIMEOUT' )
150258 self .assertEqual (outcome .detail , 'did not finish within 1s' )
151259 self .assertIs (signal .getsignal (signal .SIGALRM ), self .handler )
260+ self .assertEqual (signal .alarm (0 ), 0 )
261+
262+ def test_each_raise_re_arms_until_the_body_settles_it (self ) -> None :
263+ """A raise re-arms one second; once :attr:`settled` is set, a firing does nothing."""
264+ with self .options ._deadline (5 ) as limit : # pylint: disable=protected-access
265+ expire = signal .getsignal (signal .SIGALRM )
266+ with self .assertRaisesRegex (TimeoutError , 'did not finish within 5s' ):
267+ expire (signal .SIGALRM , None )
268+ self .assertEqual (signal .alarm (5 ), 1 )
269+ limit .settled = True
270+ self .assertIsNone (expire (signal .SIGALRM , None ))
271+ self .assertIn (signal .alarm (0 ), (4 , 5 ))
272+ self .assertIs (signal .getsignal (signal .SIGALRM ), self .handler )
273+
274+ def test_a_lost_deadline_fails_in_seconds_rather_than_hanging (self ) -> None :
275+ """#1889: with the alarm cancelled under it, the spin gives up and the test says why."""
276+ spin = _Spin (limit = 0.5 , clear = True )
277+ outcome = self .roundtrip (spin )
278+ self .assertTrue (spin .gave_up )
279+ self .assertEqual (outcome .status , 'CONSTRUCT' )
280+ self .assertEqual (outcome .detail ,
281+ '_SpinGaveUp: no deadline interrupted the spin within 0.5s' )
282+ with self .assertRaisesRegex (AssertionError , 'the deadline never interrupted the spin' ):
283+ self .assert_interrupted (spin , outcome )
284+
285+ @unittest .skipUnless (hasattr (signal , 'pthread_kill' ), 'needs signal.pthread_kill' )
286+ def test_a_firing_on_the_way_out_leaves_nothing_behind (self ) -> None :
287+ """#1889: ``SIGALRM`` on the way out of :func:`roundtrip` changes nothing.
288+
289+ Fired while the outcome is built, just before :meth:`_deadline.__exit__`
290+ cancels the alarm, and from :meth:`warnings.catch_warnings.__exit__`, on
291+ every way out: each step's ``'TIMEOUT'`` and failure, a
292+ :exc:`BaseException`, and ``'OK'``. Each time the outcome or the exception
293+ comes back as it was, no alarm is left pending, and the handler is this
294+ test's again. ``reached`` is whether the firing found the deadline's
295+ handler still installed.
296+ """
297+ real_alarm = signal .alarm
298+ real_outcome = self .options .Outcome
299+ real_exit = warnings .catch_warnings .__exit__
300+ fired = [] # type: list[bool]
301+
302+ def fire () -> None :
303+ fired .append (signal .getsignal (signal .SIGALRM ) is not self .handler )
304+ # Delivered to this thread, and its handler run before this returns.
305+ signal .pthread_kill (threading .get_ident (), signal .SIGALRM )
306+
307+ def firing_outcome (* args : 'Any' ) -> 'Any' :
308+ if not fired :
309+ fire ()
310+ return real_outcome (* args )
311+
312+ def firing_alarm (seconds : int ) -> int :
313+ if not seconds and not fired :
314+ fire ()
315+ return real_alarm (seconds )
316+
317+ def firing_exit (manager : 'Any' , * exc_info : 'Any' ) -> 'Any' :
318+ fire ()
319+ return real_exit (manager , * exc_info )
320+
321+ where = {
322+ 'outcome' : lambda : mock .patch .object (self .options , 'Outcome' , firing_outcome ),
323+ 'cancel' : lambda : mock .patch .object (signal , 'alarm' , firing_alarm ),
324+ 'catch_warnings' : lambda : mock .patch .object (warnings .catch_warnings , '__exit__' ,
325+ firing_exit ),
326+ }
327+ ways_out = (
328+ ('build' , TimeoutError , 'TIMEOUT' ), ('build' , ValueError , 'CONSTRUCT' ),
329+ ('parse' , TimeoutError , 'TIMEOUT' ), ('parse' , ValueError , 'PARSE' ),
330+ ('rebuild' , TimeoutError , 'TIMEOUT' ), ('rebuild' , ValueError , 'RECONSTRUCT' ),
331+ ('build' , _Abort , '_Abort' ), (None , None , 'OK' ),
332+ )
333+ for point , patch in where .items ():
334+ for step , error , expected in ways_out :
335+ if point == 'outcome' and expected == '_Abort' :
336+ continue # no outcome is built on that way out
337+ # Only an ``'OK'`` outcome is built after the deadline is gone.
338+ reached = point == 'cancel' or (point == 'outcome' and expected != 'OK' )
339+ with self .subTest (fired = point , step = step , result = expected ):
340+ fired .clear ()
341+ try :
342+ with patch ():
343+ result = self .roundtrip (_RaiseAt (step , error and error ()))
344+ except (_Abort , TimeoutError ) as exc :
345+ result = exc
346+ finally :
347+ pending = real_alarm (0 )
348+ handler = signal .signal (signal .SIGALRM , self .handler )
349+ # One comparison, so a failure shows the leak and the result together.
350+ self .assertEqual (
351+ {'result' : (result .status if isinstance (result , real_outcome )
352+ else type (result ).__name__ ),
353+ 'reached' : fired , 'pending' : pending ,
354+ 'restored' : handler is self .handler },
355+ {'result' : expected , 'reached' : [reached ], 'pending' : 0 ,
356+ 'restored' : True })
152357
153358
154359if __name__ == '__main__' :
0 commit comments