238 lines
8.8 KiB
Python
238 lines
8.8 KiB
Python
|
# -*- coding: utf-8 -*-
|
|||
|
"""
|
|||
|
Tests for greenlet.
|
|||
|
|
|||
|
"""
|
|||
|
from __future__ import absolute_import
|
|||
|
from __future__ import division
|
|||
|
from __future__ import print_function
|
|||
|
|
|||
|
import sys
|
|||
|
import unittest
|
|||
|
|
|||
|
from gc import collect
|
|||
|
from gc import get_objects
|
|||
|
from threading import active_count as active_thread_count
|
|||
|
from time import sleep
|
|||
|
from time import time
|
|||
|
|
|||
|
import psutil
|
|||
|
|
|||
|
from greenlet import greenlet as RawGreenlet
|
|||
|
from greenlet import getcurrent
|
|||
|
|
|||
|
from greenlet._greenlet import get_pending_cleanup_count
|
|||
|
from greenlet._greenlet import get_total_main_greenlets
|
|||
|
|
|||
|
from . import leakcheck
|
|||
|
|
|||
|
PY312 = sys.version_info[:2] >= (3, 12)
|
|||
|
WIN = sys.platform.startswith("win")
|
|||
|
|
|||
|
class TestCaseMetaClass(type):
|
|||
|
# wrap each test method with
|
|||
|
# a) leak checks
|
|||
|
def __new__(cls, classname, bases, classDict):
|
|||
|
# pylint and pep8 fight over what this should be called (mcs or cls).
|
|||
|
# pylint gets it right, but we can't scope disable pep8, so we go with
|
|||
|
# its convention.
|
|||
|
# pylint: disable=bad-mcs-classmethod-argument
|
|||
|
check_totalrefcount = True
|
|||
|
|
|||
|
# Python 3: must copy, we mutate the classDict. Interestingly enough,
|
|||
|
# it doesn't actually error out, but under 3.6 we wind up wrapping
|
|||
|
# and re-wrapping the same items over and over and over.
|
|||
|
for key, value in list(classDict.items()):
|
|||
|
if key.startswith('test') and callable(value):
|
|||
|
classDict.pop(key)
|
|||
|
if check_totalrefcount:
|
|||
|
value = leakcheck.wrap_refcount(value)
|
|||
|
classDict[key] = value
|
|||
|
return type.__new__(cls, classname, bases, classDict)
|
|||
|
|
|||
|
|
|||
|
class TestCase(TestCaseMetaClass(
|
|||
|
"NewBase",
|
|||
|
(unittest.TestCase,),
|
|||
|
{})):
|
|||
|
|
|||
|
cleanup_attempt_sleep_duration = 0.001
|
|||
|
cleanup_max_sleep_seconds = 1
|
|||
|
|
|||
|
def wait_for_pending_cleanups(self,
|
|||
|
initial_active_threads=None,
|
|||
|
initial_main_greenlets=None):
|
|||
|
initial_active_threads = initial_active_threads or self.threads_before_test
|
|||
|
initial_main_greenlets = initial_main_greenlets or self.main_greenlets_before_test
|
|||
|
sleep_time = self.cleanup_attempt_sleep_duration
|
|||
|
# NOTE: This is racy! A Python-level thread object may be dead
|
|||
|
# and gone, but the C thread may not yet have fired its
|
|||
|
# destructors and added to the queue. There's no particular
|
|||
|
# way to know that's about to happen. We try to watch the
|
|||
|
# Python threads to make sure they, at least, have gone away.
|
|||
|
# Counting the main greenlets, which we can easily do deterministically,
|
|||
|
# also helps.
|
|||
|
|
|||
|
# Always sleep at least once to let other threads run
|
|||
|
sleep(sleep_time)
|
|||
|
quit_after = time() + self.cleanup_max_sleep_seconds
|
|||
|
# TODO: We could add an API that calls us back when a particular main greenlet is deleted?
|
|||
|
# It would have to drop the GIL
|
|||
|
while (
|
|||
|
get_pending_cleanup_count()
|
|||
|
or active_thread_count() > initial_active_threads
|
|||
|
or (not self.expect_greenlet_leak
|
|||
|
and get_total_main_greenlets() > initial_main_greenlets)):
|
|||
|
sleep(sleep_time)
|
|||
|
if time() > quit_after:
|
|||
|
print("Time limit exceeded.")
|
|||
|
print("Threads: Waiting for only", initial_active_threads,
|
|||
|
"-->", active_thread_count())
|
|||
|
print("MGlets : Waiting for only", initial_main_greenlets,
|
|||
|
"-->", get_total_main_greenlets())
|
|||
|
break
|
|||
|
collect()
|
|||
|
|
|||
|
def count_objects(self, kind=list, exact_kind=True):
|
|||
|
# pylint:disable=unidiomatic-typecheck
|
|||
|
# Collect the garbage.
|
|||
|
for _ in range(3):
|
|||
|
collect()
|
|||
|
if exact_kind:
|
|||
|
return sum(
|
|||
|
1
|
|||
|
for x in get_objects()
|
|||
|
if type(x) is kind
|
|||
|
)
|
|||
|
# instances
|
|||
|
return sum(
|
|||
|
1
|
|||
|
for x in get_objects()
|
|||
|
if isinstance(x, kind)
|
|||
|
)
|
|||
|
|
|||
|
greenlets_before_test = 0
|
|||
|
threads_before_test = 0
|
|||
|
main_greenlets_before_test = 0
|
|||
|
expect_greenlet_leak = False
|
|||
|
|
|||
|
def count_greenlets(self):
|
|||
|
"""
|
|||
|
Find all the greenlets and subclasses tracked by the GC.
|
|||
|
"""
|
|||
|
return self.count_objects(RawGreenlet, False)
|
|||
|
|
|||
|
def setUp(self):
|
|||
|
# Ensure the main greenlet exists, otherwise the first test
|
|||
|
# gets a false positive leak
|
|||
|
super().setUp()
|
|||
|
getcurrent()
|
|||
|
self.threads_before_test = active_thread_count()
|
|||
|
self.main_greenlets_before_test = get_total_main_greenlets()
|
|||
|
self.wait_for_pending_cleanups(self.threads_before_test, self.main_greenlets_before_test)
|
|||
|
self.greenlets_before_test = self.count_greenlets()
|
|||
|
|
|||
|
def tearDown(self):
|
|||
|
if getattr(self, 'skipTearDown', False):
|
|||
|
return
|
|||
|
|
|||
|
self.wait_for_pending_cleanups(self.threads_before_test, self.main_greenlets_before_test)
|
|||
|
super().tearDown()
|
|||
|
|
|||
|
def get_expected_returncodes_for_aborted_process(self):
|
|||
|
import signal
|
|||
|
# The child should be aborted in an unusual way. On POSIX
|
|||
|
# platforms, this is done with abort() and signal.SIGABRT,
|
|||
|
# which is reflected in a negative return value; however, on
|
|||
|
# Windows, even though we observe the child print "Fatal
|
|||
|
# Python error: Aborted" and in older versions of the C
|
|||
|
# runtime "This application has requested the Runtime to
|
|||
|
# terminate it in an unusual way," it always has an exit code
|
|||
|
# of 3. This is interesting because 3 is the error code for
|
|||
|
# ERROR_PATH_NOT_FOUND; BUT: the C runtime abort() function
|
|||
|
# also uses this code.
|
|||
|
#
|
|||
|
# If we link to the static C library on Windows, the error
|
|||
|
# code changes to '0xc0000409' (hex(3221226505)), which
|
|||
|
# apparently is STATUS_STACK_BUFFER_OVERRUN; but "What this
|
|||
|
# means is that nowadays when you get a
|
|||
|
# STATUS_STACK_BUFFER_OVERRUN, it doesn’t actually mean that
|
|||
|
# there is a stack buffer overrun. It just means that the
|
|||
|
# application decided to terminate itself with great haste."
|
|||
|
#
|
|||
|
#
|
|||
|
# On windows, we've also seen '0xc0000005' (hex(3221225477)).
|
|||
|
# That's "Access Violation"
|
|||
|
#
|
|||
|
# See
|
|||
|
# https://devblogs.microsoft.com/oldnewthing/20110519-00/?p=10623
|
|||
|
# and
|
|||
|
# https://docs.microsoft.com/en-us/previous-versions/k089yyh0(v=vs.140)?redirectedfrom=MSDN
|
|||
|
# and
|
|||
|
# https://devblogs.microsoft.com/oldnewthing/20190108-00/?p=100655
|
|||
|
expected_exit = (
|
|||
|
-signal.SIGABRT,
|
|||
|
# But beginning on Python 3.11, the faulthandler
|
|||
|
# that prints the C backtraces sometimes segfaults after
|
|||
|
# reporting the exception but before printing the stack.
|
|||
|
# This has only been seen on linux/gcc.
|
|||
|
-signal.SIGSEGV,
|
|||
|
) if not WIN else (
|
|||
|
3,
|
|||
|
0xc0000409,
|
|||
|
0xc0000005,
|
|||
|
)
|
|||
|
return expected_exit
|
|||
|
|
|||
|
def get_process_uss(self):
|
|||
|
"""
|
|||
|
Return the current process's USS in bytes.
|
|||
|
|
|||
|
uss is available on Linux, macOS, Windows. Also known as
|
|||
|
"Unique Set Size", this is the memory which is unique to a
|
|||
|
process and which would be freed if the process was terminated
|
|||
|
right now.
|
|||
|
|
|||
|
If this is not supported by ``psutil``, this raises the
|
|||
|
:exc:`unittest.SkipTest` exception.
|
|||
|
"""
|
|||
|
try:
|
|||
|
return psutil.Process().memory_full_info().uss
|
|||
|
except AttributeError as e:
|
|||
|
raise unittest.SkipTest("uss not supported") from e
|
|||
|
|
|||
|
def run_script(self, script_name, show_output=True):
|
|||
|
import subprocess
|
|||
|
import os
|
|||
|
script = os.path.join(
|
|||
|
os.path.dirname(__file__),
|
|||
|
script_name,
|
|||
|
)
|
|||
|
|
|||
|
try:
|
|||
|
return subprocess.check_output([sys.executable, script],
|
|||
|
encoding='utf-8',
|
|||
|
stderr=subprocess.STDOUT)
|
|||
|
except subprocess.CalledProcessError as ex:
|
|||
|
if show_output:
|
|||
|
print('-----')
|
|||
|
print('Failed to run script', script)
|
|||
|
print('~~~~~')
|
|||
|
print(ex.output)
|
|||
|
print('------')
|
|||
|
raise
|
|||
|
|
|||
|
|
|||
|
def assertScriptRaises(self, script_name, exitcodes=None):
|
|||
|
import subprocess
|
|||
|
with self.assertRaises(subprocess.CalledProcessError) as exc:
|
|||
|
output = self.run_script(script_name, show_output=False)
|
|||
|
__traceback_info__ = output
|
|||
|
# We're going to fail the assertion if we get here, at least
|
|||
|
# preserve the output in the traceback.
|
|||
|
|
|||
|
if exitcodes is None:
|
|||
|
exitcodes = self.get_expected_returncodes_for_aborted_process()
|
|||
|
self.assertIn(exc.exception.returncode, exitcodes)
|
|||
|
return exc.exception
|