From 58888384d23bf84d84212c661ecfb1e397a1b0ff Mon Sep 17 00:00:00 2001 From: David de Hilster Date: Sun, 25 Oct 2026 23:46:49 -0400 Subject: [PATCH] analyze() takes a timeout An NLP++ analyzer whose loop never ends could only be stopped by killing the process, and one that makes a string a turn got there by itself, out of memory. Engine 4.2.41 can stop a run at a limit; this passes one through. - Engine.analyze(..., timeout=seconds) and NLPPlus.analyze(..., timeout=). None, the default, is no limit, as before. - A run stopped at it raises AnalysisStopped, which carries the analyzer name and the timeout. It is an EngineException and also an Exception, so ordinary error handling catches it, which EngineException alone (a BaseException) does not. - The binding takes timeout in analyze() and exposes last_run_stopped(). TimeoutTest builds a throwaway analyzer that loops forever: it is stopped, the pass after it does not run, and the same Engine then runs an analyzer that finishes, with and without a timeout. Needs the nlp-engine submodule at 4.2.41 or later. Co-Authored-By: Claude Opus 5.5 --- NLPPlus/__init__.py | 39 ++++++++++++++++++++--- bindings.cpp | 17 ++++++++-- tests/test_engine.py | 76 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 7 deletions(-) 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.