diff --git a/NLPPlus/__init__.py b/NLPPlus/__init__.py index 35aed7f..6f9ad5e 100644 --- a/NLPPlus/__init__.py +++ b/NLPPlus/__init__.py @@ -89,6 +89,24 @@ class EngineException(BaseException): pass +class AnalysisStopped(EngineException, Exception): + """An analysis ran past its ``timeout`` and was stopped. + + The engine stops the run at its next while turn or pass, skips the + passes after it, and frees what it built, so the engine is fit for the + next call. Whatever the analyzer wrote before it stopped is partial. + + Also an :class:`Exception`, unlike :class:`EngineException`, so code + that handles ordinary errors handles this one too. + """ + + def __init__(self, analyzer_name, timeout: float): + self.analyzer_name = str(analyzer_name) + self.timeout = timeout + super().__init__(f"{self.analyzer_name} ran past its timeout of " + f"{timeout:g}s and was stopped") + + class Results: """Various results produced by the NLP++ analyzer.""" @@ -270,7 +288,8 @@ def clear_logs(self, analyzer_name: PathLike) -> int: return removed def analyze(self, text: str, analyzer_name: PathLike, develop: bool = False, - compiled: bool = False, datum: Optional[str] = None) -> Results: + compiled: bool = False, datum: Optional[str] = None, + timeout: Optional[float] = None) -> Results: """Analyze text with the named analyzer. Args: @@ -291,6 +310,11 @@ def analyze(self, text: str, analyzer_name: PathLike, develop: bool = False, call only -- eg the output format the caller wants (``"json"``, ``"xml"``). None (the default) is none, and ``G("$datum")`` is then 0. + timeout: seconds this analysis may take; None (the default) is no + limit. A run past it is stopped inside the engine and + :class:`AnalysisStopped` is raised. Without one, an + analyzer whose loop never ends cannot be stopped short of + killing the process. """ outdir = self.analyzer_dir(analyzer_name) / "output" # Delete the files of the previous run in the outdir. @@ -298,7 +322,10 @@ def analyze(self, text: str, analyzer_name: PathLike, develop: bool = False, if os.path.isfile(file_path): os.remove(file_path) outtext = self.engine.analyze(self._engine_analyzer_arg(analyzer_name), - text, develop, compiled, datum or "") + text, develop, compiled, datum or "", + timeout if timeout and timeout > 0 else 0.0) + if timeout and timeout > 0 and self.engine.last_run_stopped(): + raise AnalysisStopped(analyzer_name, timeout) return Results(outtext, outdir) def compile(self, analyzer_name: PathLike, develop: bool = False, @@ -528,7 +555,8 @@ def put_json_object(analyzer_name: str, obj: Any, name: str) -> Path: def analyze(text: str, parser: str = "parse-en-us", develop: bool = False, - compiled: bool = False, datum: Optional[str] = None) -> str: + compiled: bool = False, datum: Optional[str] = None, + timeout: Optional[float] = None) -> str: """Run the analyzer named on the input string. If ``compiled=True``, the engine loads the analyzer's compiled @@ -536,8 +564,11 @@ def analyze(text: str, parser: str = "parse-en-us", develop: bool = False, running interpreted. See :func:`compile` for producing those. ``datum`` reaches the analyzer as ``G("$datum")``; see :meth:`Engine.analyze`. + ``timeout`` (seconds) stops a run that takes longer and raises + :class:`AnalysisStopped`; see :meth:`Engine.analyze`. """ - return engine.analyze(text, parser, develop, compiled, datum).output_text + return engine.analyze(text, parser, develop, compiled, datum, + timeout).output_text def compile(analyzer: str = "parse-en-us", develop: bool = False, diff --git a/bindings.cpp b/bindings.cpp index d99b12e..b24f3cc 100644 --- a/bindings.cpp +++ b/bindings.cpp @@ -108,11 +108,17 @@ wrap_init(NLP_ENGINE *self, const std::string &workingFolder, const std::string wrap_analyze(NLP_ENGINE &engine, const std::string &parser, const std::string &input, const bool develop, - const bool compiled, const std::string &datum) { + const bool compiled, const std::string &datum, + const double timeout) { CLocaleGuard cLocale; // For this call only: the analyzer sees it as G("$datum"), eg the // output format the caller wants. "" (the default) is none. engine.setDatum(datum.c_str()); + // A limit for this call only: 0 (the default) is none. A run past it is + // stopped inside the engine and last_run_stopped() then says so -- the + // only way to stop an NLP++ loop that never ends without killing the + // process (engine 4.2.41+). + engine.setRunLimit(timeout > 0 ? (long long)(timeout * 1000.0) : 0); _TCHAR *_parser = _tcsdup(parser.c_str()); std::istringstream instream(input); std::ostringstream outstream; @@ -187,7 +193,7 @@ NB_MODULE(bindings, m) { "silent"_a = true) .def("analyze", &wrap_analyze, "parser"_a, "input"_a, "develop"_a = false, - "compiled"_a = false, "datum"_a = "", + "compiled"_a = false, "datum"_a = "", "timeout"_a = 0.0, "Analyze `input` with `parser`.\n" "The `parser` argument refers to an analyzer contained in the\n" "`analyzers` folder inside the workingFolder used to create\n" @@ -198,7 +204,12 @@ NB_MODULE(bindings, m) { "libraries first via `compile()` plus an external cmake/\n" "cloud build step.\n" "`datum` reaches the analyzer as G(\"$datum\"), eg the output\n" - "format wanted; \"\" (the default) is none.") + "format wanted; \"\" (the default) is none.\n" + "`timeout` (seconds, 0 = none) stops a run that takes longer\n" + "at its next while turn or pass; last_run_stopped() is then\n" + "True.") + .def("last_run_stopped", &NLP_ENGINE::lastRunStopped, + "True if the last analyze() stopped a run at its timeout.") .def("compile", &wrap_compile, "analyzer"_a, "develop"_a = false, "kbOnly"_a = false, "analyzerOnly"_a = false, diff --git a/tests/test_engine.py b/tests/test_engine.py index 36eea3e..d45b31b 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -104,6 +104,82 @@ def test_datum(self): NLPPlus.engine = NLPPlus.Engine() +SPIN_SEQ = "tokenize\tnil\t# t\nnlp\tspin\t# s\nnlp\tafter\t# a\n" +SPIN_NLP = """@CODE +L("n") = 0; +while (L("n") >= 0) { + L("n")++; + L("s") = "x" + str(L("n")); +} +@@CODE +""" +DONE_NLP = """@CODE +"after.txt" << "after"; +@@CODE +""" +QUICK_SEQ = "tokenize\tnil\t# t\nnlp\tafter\t# a\n" + + +class TimeoutTest(TestCase): + """analyze(timeout=) stops an analyzer whose loop never ends. + + Without it the only way out of such a loop was killing the process -- + and a loop that makes a string a turn gets there by itself, out of + memory. Engine 4.2.41+. + """ + + def setUp(self): + self.tmpdir = TemporaryDirectory(prefix="test-nlpplus-timeout", + ignore_cleanup_errors=True) + root = Path(self.tmpdir.name) + spec = root / "spin" / "spec" + spec.mkdir(parents=True) + (root / "spin" / "kb" / "user").mkdir(parents=True) + (spec / "analyzer.seq").write_text(SPIN_SEQ) + (spec / "spin.nlp").write_text(SPIN_NLP) + (spec / "after.nlp").write_text(DONE_NLP) + # One that finishes, for runs that must be unaffected. + quick = root / "quick" / "spec" + quick.mkdir(parents=True) + (root / "quick" / "kb" / "user").mkdir(parents=True) + (quick / "analyzer.seq").write_text(QUICK_SEQ) + (quick / "after.nlp").write_text(DONE_NLP) + self.eng = NLPPlus.Engine() + self.eng.set_analyzers_folder(root) + + def _quick_ran(self, **kwargs): + out = self.eng.analyzer_dir("quick") / "output" / "after.txt" + self.eng.analyze("text", "quick", **kwargs) + return out.exists() and out.read_text() == "after" + + def tearDown(self): + self.eng.close() + self.tmpdir.cleanup() + + def test_a_loop_is_stopped(self): + import time + start = time.monotonic() + with self.assertRaises(NLPPlus.AnalysisStopped) as caught: + self.eng.analyze("text", "spin", timeout=1) + self.assertLess(time.monotonic() - start, 30) + self.assertEqual(caught.exception.timeout, 1) + # An ordinary `except Exception` catches it. + self.assertIsInstance(caught.exception, Exception) + # The pass after the loop did not run. + out = self.eng.analyzer_dir("spin") / "output" / "after.txt" + self.assertFalse(out.exists()) + + def test_the_engine_works_after_a_stop(self): + with self.assertRaises(NLPPlus.AnalysisStopped): + self.eng.analyze("text", "spin", timeout=1) + self.assertTrue(self._quick_ran(timeout=30)) + self.assertTrue(self._quick_ran()) + + def test_no_timeout_and_a_run_inside_it_are_unchanged(self): + self.assertTrue(self._quick_ran()) + self.assertTrue(self._quick_ran(timeout=30)) + + class ReentrancyRegressionTest(TestCase): """nlp-engine #658 / #632 regression.