diff --git a/mathics/builtin/base.py b/mathics/builtin/base.py index 664b7d413..24d0b36f8 100644 --- a/mathics/builtin/base.py +++ b/mathics/builtin/base.py @@ -9,6 +9,7 @@ import typing from typing import Any, Iterable, cast +from datetime import datetime, timedelta from mathics.builtin.exceptions import ( BoxConstructError, @@ -17,6 +18,7 @@ from mathics.core.attributes import no_attributes from mathics.core.convert import from_sympy from mathics.core.definitions import Definition +from mathics.core.interrupt import TimeoutInterrupt from mathics.core.list import ListExpression from mathics.core.parser.util import SystemDefinitions, PyMathicsDefinitions from mathics.core.rules import Rule, BuiltinRule, Pattern @@ -42,6 +44,9 @@ from mathics.core.attributes import protected, read_protected +from multiprocessing import Process, Queue + + def get_option(options, name, evaluation, pop=False, evaluate=True): # we do not care whether an option X is given as System`X, # Global`X, or with any prefix from $ContextPath for that @@ -658,6 +663,27 @@ def apply(self, expr, evaluation) -> Symbol: return +def call_sympy_function(queue, sympy_fn, *sympy_args): + queue.put(from_sympy(sympy_fn(*sympy_args))) + + +def call_with_timeout(evaluation, external_function, *args): + t, start_time = evaluation.timeout_queue[-1] + curr_time = datetime.now().timestamp() + remaining = t + start_time - curr_time + if remaining < 0: + raise TimeoutInterrupt + queue = Queue() + process = Process(target=external_function, args=(queue, *args)) + process.start() + process.join(remaining) + if process.is_alive(): + process.terminate() + evaluation.timeout + raise TimeoutInterrupt + return queue.get() + + class SympyFunction(SympyObject): def apply(self, z, evaluation): # @@ -669,7 +695,14 @@ def apply(self, z, evaluation): args = z.numerify(evaluation).get_sequence() sympy_args = [a.to_sympy() for a in args] sympy_fn = getattr(sympy, self.sympy_name) - return from_sympy(sympy_fn(*sympy_args)) + + if evaluation.timeout_queue: + result = call_with_timeout( + evaluation, call_sympy_function, sympy_fn, *sympy_args + ) + else: + result = from_sympy(sympy_fn(*sympy_args)) + return result def get_constant(self, precision, evaluation, have_mpmath=False): try: diff --git a/mathics/builtin/datentime.py b/mathics/builtin/datentime.py index 4892ac118..391e63b15 100644 --- a/mathics/builtin/datentime.py +++ b/mathics/builtin/datentime.py @@ -18,7 +18,7 @@ from mathics.builtin.base import Builtin, Predefined from mathics.core.atoms import Integer, Real, String, from_python from mathics.core.attributes import hold_all, no_attributes, protected, read_protected -from mathics.core.evaluation import TimeoutInterrupt, run_with_timeout_and_stack +from mathics.core.interrupt import TimeoutInterrupt from mathics.core.element import ImmutableValueMixin from mathics.core.expression import Expression, to_expression from mathics.core.list import ListExpression, to_mathics_list @@ -1046,8 +1046,16 @@ def apply(self, n, evaluation): "Pause", "numnm", Expression(SymbolPause, from_python(n)) ) return - - time.sleep(sleeptime) + if evaluation.timeout_queue: + while sleeptime > 0 and not evaluation.timeout: + # Fixme: look for a criteria to set the + # granularity + time.sleep(0.01) + sleeptime = sleeptime - 0.01 + if evaluation.timeout: + raise TimeoutInterrupt + else: + time.sleep(sleeptime) return SymbolNull @@ -1088,72 +1096,68 @@ def evaluate(self, evaluation): return Expression(SymbolDateObject.evaluate(evaluation)) -if sys.platform != "win32" and not hasattr(sys, "pyston_version_info"): - - class TimeConstrained(Builtin): - r""" -
-
'TimeConstrained[$expr$, $t$]' -
'evaluates $expr$, stopping after $t$ seconds.' - -
'TimeConstrained[$expr$, $t$, $failexpr$]' -
'returns $failexpr$ if the time constraint is not met.' -
- - Possible issues: for certain time-consuming functions (like simplify) - which are based on sympy or other libraries, it is possible that - the evaluation continues after the timeout. However, at the end of the evaluation, the function will return '$Aborted' and the results will not affect - the state of the \Mathics kernel. +class TimeConstrained(Builtin): + r""" +
+
'TimeConstrained[$expr$, $t$]' +
'evaluates $expr$, stopping after $t$ seconds.' - """ +
'TimeConstrained[$expr$, $t$, $failexpr$]' +
'returns $failexpr$ if the time constraint is not met.' +
- # FIXME: these tests sometimes cause SEGVs which probably means - # that TimeConstraint has bugs. + Possible issues: for certain time-consuming functions (like simplify) + which are based on sympy or other libraries, it is possible that + the evaluation continues after the timeout. However, at the end of the evaluation, the function will return '$Aborted' and the results will not affect + the state of the \Mathics kernel. - # Consider testing via unit tests. - # >> TimeConstrained[Integrate[Sin[x]^1000000,x],1] - # = $Aborted + >> TimeConstrained[Pause[1]; x^2 , .1] + = $Aborted - # >> TimeConstrained[Integrate[Sin[x]^1000000,x], 1, Integrate[Cos[x],x]] - # = Sin[x] + >> TimeConstrained[Pause[1]; x^2, .1, sqx] + = sqx - # >> s=TimeConstrained[Integrate[Sin[x] ^ 3, x], a] - # : Number of seconds a is not a positive machine-sized number or Infinity. - # = TimeConstrained[Integrate[Sin[x] ^ 3, x], a] + >> s = TimeConstrained[Integrate[Sin[x] ^ 3, x], a] + : Number of seconds a is not a positive machine-sized number or Infinity. + = TimeConstrained[Integrate[Sin[x] ^ 3, x], a] - # >> a=1; s - # = Cos[x] (-5 + Cos[2 x]) / 6 + >> a=10.; s + = Cos[x] (-3 + Cos[x] ^ 2) / 3 + """ - attributes = hold_all | protected - messages = { - "timc": "Number of seconds `1` is not a positive machine-sized number or Infinity.", - } + attributes = hold_all | protected + messages = { + "timc": "Number of seconds `1` is not a positive machine-sized number or Infinity.", + } - summary_text = "run a command for at most a specified time" + summary_text = "run a command for at most a specified time" - def apply_2(self, expr, t, evaluation): - "TimeConstrained[expr_, t_]" - return self.apply_3(expr, t, SymbolAborted, evaluation) + def apply_2(self, expr, t, evaluation): + "TimeConstrained[expr_, t_]" + return self.apply_3(expr, t, SymbolAborted, evaluation) - def apply_3(self, expr, t, failexpr, evaluation): - "TimeConstrained[expr_, t_, failexpr_]" - t = t.evaluate(evaluation) - if not t.is_numeric(evaluation): - evaluation.message("TimeConstrained", "timc", t) - return - try: - t = float(t.to_python()) - evaluation.timeout_queue.append((t, datetime.now().timestamp())) - request = lambda: expr.evaluate(evaluation) - res = run_with_timeout_and_stack(request, t, evaluation) - except TimeoutInterrupt: - evaluation.timeout_queue.pop() + def apply_3(self, expr, t, failexpr, evaluation): + "TimeConstrained[expr_, t_, failexpr_]" + t = t.evaluate(evaluation) + if not t.is_numeric(evaluation): + evaluation.message("TimeConstrained", "timc", t) + return + try: + t = float(t.to_python()) + evaluation.timeout_queue.append((t, datetime.now().timestamp())) + res = expr.evaluate(evaluation) + except TimeoutInterrupt: + last = evaluation.timeout_queue.pop() + if last == True: return failexpr.evaluate(evaluation) - except: - evaluation.timeout_queue.pop() - raise + # The timeout was not set here. Reraise the + # TimeoutInterrupt exception. + raise TimeoutInterrupt + except Exception as e: evaluation.timeout_queue.pop() - return res + raise + evaluation.timeout_queue.pop() + return res class TimeZone(Predefined): diff --git a/mathics/builtin/numbers/calculus.py b/mathics/builtin/numbers/calculus.py index 5a07f56ad..35f5cb190 100644 --- a/mathics/builtin/numbers/calculus.py +++ b/mathics/builtin/numbers/calculus.py @@ -16,7 +16,12 @@ series_times_series, series_derivative, ) -from mathics.builtin.base import Builtin, PostfixOperator, SympyFunction +from mathics.builtin.base import ( + Builtin, + PostfixOperator, + SympyFunction, + call_with_timeout, +) from mathics.builtin.scoping import dynamic_scoping from mathics.core.atoms import ( @@ -42,6 +47,7 @@ from mathics.core.convert import sympy_symbol_prefix, SympyExpression, from_sympy from mathics.core.evaluation import Evaluation from mathics.core.evaluators import apply_N +from mathics.core.interrupt import TimeoutInterrupt from mathics.core.expression import Expression, to_expression from mathics.core.list import ListExpression, to_mathics_list @@ -496,6 +502,10 @@ def to_sympy(self, expr, **kwargs): return +def call_sympy_integrate(queue, f_sympy, *vars): + queue.put(sympy.integrate(f_sympy, *vars)) + + class Integrate(SympyFunction): r"""
@@ -669,8 +679,15 @@ def apply(self, f, xs, evaluation, options): else: vars.append((x, a, b)) try: - sympy_result = sympy.integrate(f_sympy, *vars) + if evaluation.timeout_queue: + sympy_result = call_with_timeout( + evaluation, call_sympy_integrate, f_sympy, *vars + ) + else: + sympy_result = sympy.integrate(f_sympy, *vars) pass + except TimeoutInterrupt: + raise except sympy.PolynomialError: return except ValueError: diff --git a/mathics/builtin/procedural.py b/mathics/builtin/procedural.py index 6c3a14aab..6633e869f 100644 --- a/mathics/builtin/procedural.py +++ b/mathics/builtin/procedural.py @@ -195,6 +195,7 @@ def apply(self, expr, evaluation): for expr in items: prev_result = result + result = expr.evaluate(evaluation) # `expr1; expr2;` returns `Null` but assigns `expr2` to `Out[n]`. diff --git a/mathics/core/evaluation.py b/mathics/core/evaluation.py index 1db73a457..6f3422d7d 100644 --- a/mathics/core/evaluation.py +++ b/mathics/core/evaluation.py @@ -7,6 +7,7 @@ import os import sys from threading import Thread, stack_size as set_thread_stack_size +from datetime import datetime, timedelta from typing import Tuple @@ -95,53 +96,6 @@ def set_python_recursion_limit(n) -> None: raise OverflowError -def run_with_timeout_and_stack(request, timeout, evaluation): - """ - interrupts evaluation after a given time period. Provides a suitable stack environment. - """ - - # only use set_thread_stack_size if max recursion depth was changed via the environment variable - # MATHICS_MAX_RECURSION_DEPTH. if it is set, we always use a thread, even if timeout is None, in - # order to be able to set the thread stack size. - - if MAX_RECURSION_DEPTH > settings.DEFAULT_MAX_RECURSION_DEPTH: - set_thread_stack_size(python_stack_size(MAX_RECURSION_DEPTH)) - elif timeout is None: - return request() - - queue = Queue(maxsize=1) # stores the result or exception - thread = Thread(target=_thread_target, args=(request, queue)) - thread.start() - - # Thead join(timeout) can leave zombie threads (we are the parent) - # when a time out occurs, but the thread hasn't terminated. See - # https://docs.python.org/3/library/multiprocessing.shared_memory.html - # for a detailed discussion of this. - # - # To reduce this problem, we make use of specific properties of - # the Mathics evaluator: if we set "evaluation.timeout", the - # next call to "Expression.evaluate" in the thread will finish it - # immediately. - # - # However this still will not terminate long-running processes - # in Sympy or or libraries called by Mathics that might hang or run - # for a long time. - thread.join(timeout) - if thread.is_alive(): - evaluation.timeout = True - while thread.is_alive(): - pass - evaluation.timeout = False - evaluation.stopped = False - raise TimeoutInterrupt() - - success, result = queue.get() - if success: - return result - else: - raise result[0].with_traceback(result[1], result[2]) - - class Out(KeyComparable): def __init__(self) -> None: self.is_message = False @@ -234,7 +188,6 @@ def __init__( definitions = Definitions() self.definitions = definitions self.recursion_depth = 0 - self.timeout = False self.timeout_queue = [] self.stopped = False self.out = [] @@ -299,7 +252,6 @@ def evaluate(self, query, timeout=None, format=None): self.start_time = time.time() self.recursion_depth = 0 - self.timeout = False self.stopped = False self.exc_result = self.SymbolNull self.last_eval = None @@ -309,7 +261,6 @@ def evaluate(self, query, timeout=None, format=None): line_no = self.definitions.get_line_no() line_no += 1 self.definitions.set_line_no(line_no) - history_length = self.definitions.get_history_length() result = None @@ -350,9 +301,11 @@ def evaluate(): self.exec_result = self.SymbolNull return None + if timeout: + self.timeout_queue.append((timeout, datetime.now().timestamp())) try: try: - result = run_with_timeout_and_stack(evaluate, timeout, self) + result = evaluate() except KeyboardInterrupt: if self.catch_interrupt: self.exc_result = SymbolAborted @@ -388,7 +341,6 @@ def evaluate(): self.exc_result = Expression(SymbolHold, Expression(SymbolContinue)) except TimeoutInterrupt: self.stopped = False - self.timeout = True self.message("General", "timeout") self.exc_result = SymbolAborted except AbortInterrupt: # , error: @@ -406,6 +358,8 @@ def evaluate(): finally: self.stop() + if timeout: + self.timeout_queue.pop() history_length = self.definitions.get_history_length() line = line_no - history_length @@ -572,6 +526,24 @@ def check_stopped(self) -> None: if self.stopped: raise TimeoutInterrupt + @property + def timeout(self) -> bool: + curr_time = datetime.now().timestamp() + timeout_levels = len(self.timeout_queue) + for i in range(timeout_levels): + entry = self.timeout_queue[-i - 1] + if entry == True: + return True + t, start = entry + if curr_time - start > t: + self.timeout_queue[-i - 1] = True + return True + return False + + @timeout.setter + def timeout(self, value: bool): + raise ValueError("you cannot set timeout") + def inc_recursion_depth(self) -> None: self.check_stopped() limit = self.definitions.get_config_value( diff --git a/mathics/core/expression.py b/mathics/core/expression.py index 43e62c8de..a1c08fb82 100644 --- a/mathics/core/expression.py +++ b/mathics/core/expression.py @@ -51,6 +51,10 @@ SymbolBlank, SymbolSequence, ) +from mathics.core.interrupt import ( + TimeoutInterrupt, +) + from mathics.core.atoms import String # from mathics.core.util import timeit @@ -440,7 +444,7 @@ def evaluate( Evaluation is a recusive:``rewrite_apply_eval_step()`` may call us. """ if evaluation.timeout: - return + raise TimeoutInterrupt expr = self reevaluate = True diff --git a/mathics/core/rules.py b/mathics/core/rules.py index 44433b23e..8c6c839bd 100644 --- a/mathics/core/rules.py +++ b/mathics/core/rules.py @@ -2,6 +2,7 @@ # cython: language_level=3 # -*- coding: utf-8 -*- +from mathics.core.interrupt import TimeoutInterrupt from mathics.core.element import KeyComparable from mathics.core.expression import Expression from mathics.core.symbols import strip_context @@ -208,6 +209,7 @@ def do_replace(self, expression, vars, options, evaluation): vars_noctx = dict(((strip_context(s), vars[s]) for s in vars)) if self.pass_expression: vars_noctx["expression"] = expression + if options: return self.function(evaluation=evaluation, options=options, **vars_noctx) else: diff --git a/test/builtin/test_datentime.py b/test/builtin/test_datentime.py index 9430a923f..9631461d2 100644 --- a/test/builtin/test_datentime.py +++ b/test/builtin/test_datentime.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from test.helper import check_evaluation, evaluate +from mathics.core.symbols import Symbol import pytest import sys @@ -7,26 +8,65 @@ import time -@pytest.mark.skipif( - sys.platform in ("win32",) or hasattr(sys, "pyston_version_info"), - reason="TimeConstrained needs to be rewritten", -) -def test_timeremaining(): - str_expr = "TimeConstrained[1+2; TimeRemaining[], 0.9]" - result = evaluate(str_expr) - assert result is None or 0 < result.to_python() < 9 - - -@pytest.mark.skip(reason="TimeConstrained needs to be rewritten") -def test_timeconstrained1(): - # +def test_timeconstrained_assignment_1(): + # This test str_expr1 = "a=1.; TimeConstrained[Do[Pause[.1];a=a+1,{1000}],1]" result = evaluate(str_expr1) str_expected = "$Aborted" expected = evaluate(str_expected) assert result == expected time.sleep(1) - assert evaluate("a").to_python() == 10 + # if all the operations where instantaneous, then the + # value of ``a`` should be 10. However, in macOS, ``a`` + # just reach 3... + result = evaluate("a").to_python() + assert result <= 10 + + +def test_timeconstrained_assignment_2(): + # This test checks if the assignment is really aborted + # if the RHS exceeds the wall time. + str_expr1 = "a=1.; TimeConstrained[a=(Pause[.2];2.),.1]" + result = evaluate(str_expr1) + str_expected = "$Aborted" + expected = evaluate(str_expected) + assert result == expected + time.sleep(0.2) + assert evaluate("a").to_python() == 1.0 + + +def test_timeconstrained_assignment_3(): + # This test checks if the assignment is really aborted + # if the RHS exceeds the wall time. + str_expr1 = ( + "a=1.;TimeConstrained[TimeConstrained[a=(Pause[.1];2.), .3, a=-2], .1,a=-3]" + ) + result = evaluate(str_expr1) + str_expected = "-3" + expected = evaluate(str_expected) + assert result == expected + time.sleep(0.3) + assert evaluate("a").to_python() == -3 + + +def test_timeconstrained_sympy(): + # This test tries to run a large and onerous calculus that runs + # in sympy (outside the control of Mathics). + # If the behaviour is the right one, the evaluation + # is interrupted before it saturates memory and raise a SIGEV + # exception. + str_expr = "TimeConstrained[Integrate[Sin[x]^1000000, x], 0.9]" + result = evaluate(str_expr) + assert result is None or result == Symbol("$Aborted") + + +def test_timeremaining(): + str_expr = "TimeConstrained[1+2;Pause[2]; TimeRemaining[], 10.]" + result = evaluate(str_expr) + assert result is None or 0 < result.to_python() < 10.0, ( + result, + "must belong to the interval [0, 10]", + ) def test_datelist(): @@ -55,7 +95,7 @@ def test_datelist(): check_evaluation(str_expr, str_expected) -def test_datestring(): +def test_datestring2(): for str_expr, str_expected in ( ## Check Leading 0s # (