|  | 
| 25 | 25 | 
 | 
| 26 | 26 | from time import time as walltime | 
| 27 | 27 | from os import sysconf, times | 
|  | 28 | +from contextlib import contextmanager | 
|  | 29 | +from cysignals.alarm import alarm, cancel_alarm, AlarmInterrupt | 
| 28 | 30 | 
 | 
| 29 | 31 | 
 | 
| 30 | 32 | def count_noun(number, noun, plural=None, pad_number=False, pad_noun=False): | 
| @@ -749,3 +751,152 @@ def __ne__(self, other): | 
| 749 | 751 |             True | 
| 750 | 752 |         """ | 
| 751 | 753 |         return not (self == other) | 
|  | 754 | + | 
|  | 755 | + | 
|  | 756 | +@contextmanager | 
|  | 757 | +def ensure_interruptible_after(seconds: float, max_wait_after_interrupt: float = 0.2, inaccuracy_tolerance: float = 0.1): | 
|  | 758 | +    """ | 
|  | 759 | +    Helper function for doctesting to ensure that the code is interruptible after a certain amount of time. | 
|  | 760 | +    This should only be used for internal doctesting purposes. | 
|  | 761 | +
 | 
|  | 762 | +    EXAMPLES:: | 
|  | 763 | +
 | 
|  | 764 | +        sage: from sage.doctest.util import ensure_interruptible_after | 
|  | 765 | +        sage: with ensure_interruptible_after(1) as data: sleep(3) | 
|  | 766 | +
 | 
|  | 767 | +    ``as data`` is optional, but if it is used, it will contain a few useful values:: | 
|  | 768 | +
 | 
|  | 769 | +        sage: data  # abs tol 1 | 
|  | 770 | +        {'alarm_raised': True, 'elapsed': 1.0} | 
|  | 771 | +
 | 
|  | 772 | +    ``max_wait_after_interrupt`` can be passed if the function may take longer than usual to be interrupted:: | 
|  | 773 | +
 | 
|  | 774 | +        sage: # needs sage.misc.cython | 
|  | 775 | +        sage: cython(r''' | 
|  | 776 | +        ....: from posix.time cimport clock_gettime, CLOCK_REALTIME, timespec, time_t | 
|  | 777 | +        ....: from cysignals.signals cimport sig_check | 
|  | 778 | +        ....: | 
|  | 779 | +        ....: cpdef void uninterruptible_sleep(double seconds): | 
|  | 780 | +        ....:     cdef timespec start_time, target_time | 
|  | 781 | +        ....:     clock_gettime(CLOCK_REALTIME, &start_time) | 
|  | 782 | +        ....: | 
|  | 783 | +        ....:     cdef time_t floor_seconds = <time_t>seconds | 
|  | 784 | +        ....:     target_time.tv_sec = start_time.tv_sec + floor_seconds | 
|  | 785 | +        ....:     target_time.tv_nsec = start_time.tv_nsec + <long>((seconds - floor_seconds) * 1e9) | 
|  | 786 | +        ....:     if target_time.tv_nsec >= 1000000000: | 
|  | 787 | +        ....:         target_time.tv_nsec -= 1000000000 | 
|  | 788 | +        ....:         target_time.tv_sec += 1 | 
|  | 789 | +        ....: | 
|  | 790 | +        ....:     while True: | 
|  | 791 | +        ....:         clock_gettime(CLOCK_REALTIME, &start_time) | 
|  | 792 | +        ....:         if start_time.tv_sec > target_time.tv_sec or (start_time.tv_sec == target_time.tv_sec and start_time.tv_nsec >= target_time.tv_nsec): | 
|  | 793 | +        ....:             break | 
|  | 794 | +        ....: | 
|  | 795 | +        ....: cpdef void check_interrupt_only_occasionally(): | 
|  | 796 | +        ....:     for i in range(10): | 
|  | 797 | +        ....:         uninterruptible_sleep(0.8) | 
|  | 798 | +        ....:         sig_check() | 
|  | 799 | +        ....: ''') | 
|  | 800 | +        sage: with ensure_interruptible_after(1):  # not passing max_wait_after_interrupt will raise an error | 
|  | 801 | +        ....:     check_interrupt_only_occasionally() | 
|  | 802 | +        Traceback (most recent call last): | 
|  | 803 | +        ... | 
|  | 804 | +        RuntimeError: Function is not interruptible within 1.0000 seconds, only after 1.60... seconds | 
|  | 805 | +        sage: with ensure_interruptible_after(1, max_wait_after_interrupt=0.9): | 
|  | 806 | +        ....:     check_interrupt_only_occasionally() | 
|  | 807 | +
 | 
|  | 808 | +    TESTS:: | 
|  | 809 | +
 | 
|  | 810 | +        sage: with ensure_interruptible_after(2) as data: sleep(1) | 
|  | 811 | +        Traceback (most recent call last): | 
|  | 812 | +        ... | 
|  | 813 | +        RuntimeError: Function terminates early after 1... < 2.0000 seconds | 
|  | 814 | +        sage: data  # abs tol 1 | 
|  | 815 | +        {'alarm_raised': False, 'elapsed': 1.0} | 
|  | 816 | +
 | 
|  | 817 | +    The test above requires a large tolerance, because both ``time.sleep`` and | 
|  | 818 | +    ``from posix.unistd cimport usleep`` may have slowdown on the order of 0.1s on Mac, | 
|  | 819 | +    likely because the system is idle and GitHub CI switches the program out, | 
|  | 820 | +    and context switch back takes time. Besides, there is an issue with ``Integer`` | 
|  | 821 | +    destructor, see `<https://github.com/sagemath/cysignals/issues/215>`_ | 
|  | 822 | +    So we use busy wait and Python integers:: | 
|  | 823 | +
 | 
|  | 824 | +        sage: # needs sage.misc.cython | 
|  | 825 | +        sage: cython(r''' | 
|  | 826 | +        ....: from posix.time cimport clock_gettime, CLOCK_REALTIME, timespec, time_t | 
|  | 827 | +        ....: from cysignals.signals cimport sig_check | 
|  | 828 | +        ....: | 
|  | 829 | +        ....: cpdef void interruptible_sleep(double seconds): | 
|  | 830 | +        ....:     cdef timespec start_time, target_time | 
|  | 831 | +        ....:     clock_gettime(CLOCK_REALTIME, &start_time) | 
|  | 832 | +        ....: | 
|  | 833 | +        ....:     cdef time_t floor_seconds = <time_t>seconds | 
|  | 834 | +        ....:     target_time.tv_sec = start_time.tv_sec + floor_seconds | 
|  | 835 | +        ....:     target_time.tv_nsec = start_time.tv_nsec + <long>((seconds - floor_seconds) * 1e9) | 
|  | 836 | +        ....:     if target_time.tv_nsec >= 1000000000: | 
|  | 837 | +        ....:         target_time.tv_nsec -= 1000000000 | 
|  | 838 | +        ....:         target_time.tv_sec += 1 | 
|  | 839 | +        ....: | 
|  | 840 | +        ....:     while True: | 
|  | 841 | +        ....:         sig_check() | 
|  | 842 | +        ....:         clock_gettime(CLOCK_REALTIME, &start_time) | 
|  | 843 | +        ....:         if start_time.tv_sec > target_time.tv_sec or (start_time.tv_sec == target_time.tv_sec and start_time.tv_nsec >= target_time.tv_nsec): | 
|  | 844 | +        ....:             break | 
|  | 845 | +        ....: ''') | 
|  | 846 | +        sage: with ensure_interruptible_after(2) as data: interruptible_sleep(1r) | 
|  | 847 | +        Traceback (most recent call last): | 
|  | 848 | +        ... | 
|  | 849 | +        RuntimeError: Function terminates early after 1.00... < 2.0000 seconds | 
|  | 850 | +        sage: with ensure_interruptible_after(1) as data: uninterruptible_sleep(2r) | 
|  | 851 | +        Traceback (most recent call last): | 
|  | 852 | +        ... | 
|  | 853 | +        RuntimeError: Function is not interruptible within 1.0000 seconds, only after 2.00... seconds | 
|  | 854 | +        sage: data  # abs tol 0.01 | 
|  | 855 | +        {'alarm_raised': True, 'elapsed': 2.0} | 
|  | 856 | +        sage: with ensure_interruptible_after(1): uninterruptible_sleep(2r); raise RuntimeError | 
|  | 857 | +        Traceback (most recent call last): | 
|  | 858 | +        ... | 
|  | 859 | +        RuntimeError: Function is not interruptible within 1.0000 seconds, only after 2.00... seconds | 
|  | 860 | +        sage: data  # abs tol 0.01 | 
|  | 861 | +        {'alarm_raised': True, 'elapsed': 2.0} | 
|  | 862 | +
 | 
|  | 863 | +    :: | 
|  | 864 | +
 | 
|  | 865 | +        sage: with ensure_interruptible_after(1) as data: raise ValueError | 
|  | 866 | +        Traceback (most recent call last): | 
|  | 867 | +        ... | 
|  | 868 | +        ValueError | 
|  | 869 | +        sage: data  # abs tol 0.01 | 
|  | 870 | +        {'alarm_raised': False, 'elapsed': 0.0} | 
|  | 871 | +    """ | 
|  | 872 | +    seconds = float(seconds) | 
|  | 873 | +    max_wait_after_interrupt = float(max_wait_after_interrupt) | 
|  | 874 | +    inaccuracy_tolerance = float(inaccuracy_tolerance) | 
|  | 875 | +    # use Python float to avoid slowdown with Sage Integer (see https://github.com/sagemath/cysignals/issues/215) | 
|  | 876 | +    data = {} | 
|  | 877 | +    start_time = walltime() | 
|  | 878 | +    alarm(seconds) | 
|  | 879 | +    alarm_raised = False | 
|  | 880 | + | 
|  | 881 | +    try: | 
|  | 882 | +        yield data | 
|  | 883 | +    except AlarmInterrupt as e: | 
|  | 884 | +        e.__traceback__ = None  # workaround for https://github.com/python/cpython/pull/129276 | 
|  | 885 | +        alarm_raised = True | 
|  | 886 | +    finally: | 
|  | 887 | +        before_cancel_alarm_elapsed = walltime() - start_time | 
|  | 888 | +        cancel_alarm() | 
|  | 889 | +        elapsed = walltime() - start_time | 
|  | 890 | +        data["elapsed"] = elapsed | 
|  | 891 | +        data["alarm_raised"] = alarm_raised | 
|  | 892 | + | 
|  | 893 | +    if elapsed > seconds + max_wait_after_interrupt: | 
|  | 894 | +        raise RuntimeError( | 
|  | 895 | +                f"Function is not interruptible within {seconds:.4f} seconds, only after {elapsed:.4f} seconds" | 
|  | 896 | +                + ("" if alarm_raised else " (__exit__ called before interrupt check)")) | 
|  | 897 | + | 
|  | 898 | +    if alarm_raised: | 
|  | 899 | +        if elapsed < seconds - inaccuracy_tolerance: | 
|  | 900 | +            raise RuntimeError(f"Interrupted too early: {elapsed:.4f} < {seconds:.4f}, this should not happen") | 
|  | 901 | +    else: | 
|  | 902 | +        raise RuntimeError(f"Function terminates early after {elapsed:.4f} < {seconds:.4f} seconds") | 
0 commit comments