From ead3ebe251388fb3c38172becdd9206917107ce7 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Thu, 10 Sep 2026 10:11:19 +0800 Subject: [PATCH 1/7] =?UTF-8?q?[1294]=20=E5=A2=9E=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=20249=20=E8=87=AA=E9=80=92=E5=BD=92=E5=AE=8F=20LaTeX?= =?UTF-8?q?=20=E7=B2=98=E8=B4=B4=E5=B4=A9=E6=BA=83=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95=E4=B8=8E=E4=BB=BB=E5=8A=A1=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- devel/1294.md | 79 +++++++++++++++++++++++++++++ tests/Plugins/Tex/parsetex_test.cpp | 65 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 devel/1294.md create mode 100644 tests/Plugins/Tex/parsetex_test.cpp diff --git a/devel/1294.md b/devel/1294.md new file mode 100644 index 0000000000..e63919ac67 --- /dev/null +++ b/devel/1294.md @@ -0,0 +1,79 @@ +# 1294 LaTeX 崩溃用例修复(latex_crash_1) + +## 背景 + +崩溃用例集(`/home/da/git/crash/`)中的用例 249: + +```latex +\def\crashMacro24{\crashMacro24} \crashMacro24 +``` + +在 GUI 中执行「编辑 → 粘贴自 → LaTeX」后约 2.6 秒进程崩溃退出(用户现场 SIGSEGV, +本地 C++ 测试环境下表现为未捕获异常导致进程终止,退出码 255)。 + +## What + +- 新增 C++ 回归测试 `tests/Plugins/Tex/parsetex_test.cpp`,以粘贴路径的完整转换入口 + `latex_document_to_tree` 复现该崩溃(修复前进程以未捕获异常终止)。 +- 修复转换链路上两处健壮性缺陷,使转换失败时降级而不是杀死进程。 + +## Why(根因定位过程) + +用例元数据最初指认 `parse_backslash` 的 replace 宏展开递归,经隔离验证不成立: +`parse_latex (s, true)` 与 `parse_latex (s, true, true)` 对该输入均正常返回、无递归。 + +通过临时在 `load_tex` 失败点注入 `get_stacktrace` 输出并用 addr2line 解析, +定位真实链路(`latex_document_to_tree` → `latex_to_tree`): + +``` +latex_to_tree (fromtex_post.cpp) + → upgrade_tex (upgradetm.cpp) + → *_correct (tree_correct.cpp) → with_drd (get_document_drd) + → get_style_drd → compute_env_and_drd (new_style.cpp) + → env->exec (USE_PACKAGE "generic") → update_font (env_semantics.cpp) + → smart_font → closest_font → find_font ↔ find_font_bis (find_font.cpp) + → tex_ec_font → tex_font → load_tex (load_tex.cpp) + → TM_FAILED("Tex seems not to be installed properly") 抛 string 异常 +``` + +两处缺陷: + +1. `latex_to_tree` 转换过程中会触发样式环境求值与字体解析;环境缺 TeX 字体时 + `load_tex` 以 `TM_FAILED` 抛 `string` 异常,而转换链路(直到 scheme glue 边界) + 无任何捕获,异常穿过 s7 C 栈导致 `std::terminate`,进程终止 + (用户机器上同链路的另一表现为栈溢出 SIGSEGV)。 +2. `find_font` ↔ `find_font_bis` 经 `font_conversion` 规则表递归转换字体描述符, + 无递归深度上限;规则成环时无限递归直至栈溢出(SIGSEGV)。 + +## How + +- `src/Plugins/Tex/fromtex_post.cpp`:原实现重命名为 `latex_to_tree_body`(static), + `latex_to_tree` 作为入口加 `try/catch (string)` 兜底(与 `edit_typeset_rep::typeset_sub` + 的既有模式一致),失败时 `failed_error` 记录并降级返回空 `DOCUMENT`。 +- `src/Graphics/Fonts/find_font.cpp`:`find_font (tree)` 增加静态递归深度计数 + (上限 100,正常链路嵌套约 5~10 层),超限按"字体未找到"降级返回 nil font; + 计数用 `try/catch (...)` 保证异常路径下回退,避免计数泄漏导致后续查找全部误判。 + +## 涉及文件 + +- `tests/Plugins/Tex/parsetex_test.cpp`(新增,回归测试) +- `src/Plugins/Tex/fromtex_post.cpp` +- `src/Graphics/Fonts/find_font.cpp` + +## 测试 + +```bash +xmake b parsetex_test && xmake r parsetex_test # 回归用例,修复前进程终止,修复后通过 +xmake r 0620 # 3 correct, 0 failed(latex 导入,验证正常路径不受影响) +xmake r 0631 # 24 correct, 0 failed +``` + +0628 / 0190 存量失败(导出方向 / scheme lazy-define),已用未含修复的基线构建复验, +与本任务改动无关。 + +## 备注 + +- 粘贴完整链路:`clipboard-paste-import "latex"` → `selection_paste`(C++)→ + `generic_to_tree (s, "latex-snippet")` → scheme `convert` → `parse-latex`(C++ + `parse_latex`)→ `latex->texmacs`(C++ `latex_to_tree`)。 +- GUI 手动验证(含崩溃用例 249 现象消失)由用户执行。 diff --git a/tests/Plugins/Tex/parsetex_test.cpp b/tests/Plugins/Tex/parsetex_test.cpp new file mode 100644 index 0000000000..c9c5997f51 --- /dev/null +++ b/tests/Plugins/Tex/parsetex_test.cpp @@ -0,0 +1,65 @@ +/****************************************************************************** + * MODULE : parsetex_test.cpp + * DESCRIPTION: Tests for latex parser + * COPYRIGHT : (C) 2026 Darcy Shen + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#include + +#include "Tex/tex.hpp" +#include "base.hpp" +#include "boot.hpp" +#include "server.hpp" +#include "tm_sys_utils.hpp" +#include + +extern s7_pointer user_env; + +class TestParseTex : public QObject { + Q_OBJECT + +private: + server* sv; + +private slots: + void initTestCase (); + void cleanupTestCase (); + void test_crash_case_249 (); +}; + +void +TestParseTex::initTestCase () { + init_lolly (); + init_texmacs_home_path (); + init_texmacs_front (); + int argc = 1; + char* argv[]= {(char*) "parsetex_test", nullptr}; + gui_open (argc, argv); + if (!tm_s7) { + tm_s7= s7_init (); + user_env= s7_inlet (tm_s7, s7_nil (tm_s7)); + s7_gc_protect (tm_s7, user_env); + } + sv= new server (app_type::RESEARCH); +} + +void +TestParseTex::cleanupTestCase () { + // server rep is managed +} + +void +TestParseTex::test_crash_case_249 () { + // 自递归宏定义用例,GUI「粘贴自 LaTeX」触发进程崩溃, + // latex_document_to_tree 是粘贴路径的完整转换入口 + string s= "\\def\\crashMacro24{\\crashMacro24} \\crashMacro24\n"; + tree doc= latex_document_to_tree (s); + QVERIFY (is_func (doc, moebius::DOCUMENT)); +} + +QTEST_MAIN (TestParseTex) +#include "parsetex_test.moc" From 92db2e3da8600e1295ed3ecd5fc7cbb54855e400 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Thu, 10 Sep 2026 10:11:32 +0800 Subject: [PATCH 2/7] =?UTF-8?q?[1294]=20=E4=BF=AE=E5=A4=8D=20LaTeX=20?= =?UTF-8?q?=E7=B2=98=E8=B4=B4=E8=BD=AC=E6=8D=A2=E5=9B=A0=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E4=B8=8E=20find=5Ffont=20=E8=A7=84=E5=88=99?= =?UTF-8?q?=E9=80=92=E5=BD=92=E5=AF=BC=E8=87=B4=E8=BF=9B=E7=A8=8B=E5=B4=A9?= =?UTF-8?q?=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - latex_to_tree 入口兜底捕获底层字体解析抛出的 string 异常, 降级返回空文档,避免异常穿过 scheme 边界终止进程 - find_font 增加递归深度上限,规则成环时按未找到降级,防止栈溢出 Co-Authored-By: Claude --- src/Graphics/Fonts/find_font.cpp | 23 ++++++++++++++++++++--- src/Plugins/Tex/fromtex_post.cpp | 17 +++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/Graphics/Fonts/find_font.cpp b/src/Graphics/Fonts/find_font.cpp index 4e01acb20c..1858e7ae48 100644 --- a/src/Graphics/Fonts/find_font.cpp +++ b/src/Graphics/Fonts/find_font.cpp @@ -200,10 +200,27 @@ find_font_bis (tree t) { font find_font (tree t) { + static int find_font_level= 0; + // 规则驱动的递归转换缺乏终止保证,字体规则成环时会无限递归直到栈溢出, + // 这里限制递归深度,超限按"字体未找到"降级 + static const int max_find_font_level= 100; + if (find_font_level >= max_find_font_level) { + failed_error << "find_font recursion too deep, giving up on " << t << "\n"; + return font (); + } bench_start ("find font"); - font fn= find_font_bis (t); - bench_cumul ("find font"); - return fn; + find_font_level++; + try { + font fn= find_font_bis (t); + find_font_level--; + bench_cumul ("find font"); + return fn; + } catch (...) { + // 底层字体加载失败会抛异常,计数必须回退,否则后续查找全部误判为过深 + find_font_level--; + bench_cumul ("find font"); + throw; + } } font diff --git a/src/Plugins/Tex/fromtex_post.cpp b/src/Plugins/Tex/fromtex_post.cpp index 774c2a70af..c5a0937327 100644 --- a/src/Plugins/Tex/fromtex_post.cpp +++ b/src/Plugins/Tex/fromtex_post.cpp @@ -2443,8 +2443,8 @@ guess_missing (tree t) { * Interface ******************************************************************************/ -tree -latex_to_tree (tree t0) { +static tree +latex_to_tree_body (tree t0) { // cout << "\n\nt0= " << t0 << "\n\n"; tree t1= kill_space_invaders (t0); string style, lan= ""; @@ -2539,6 +2539,19 @@ latex_to_tree (tree t0) { else return t15; } +tree +latex_to_tree (tree t0) { + // 转换过程中会触发样式环境求值与字体解析,环境缺字体时底层以异常上报 + // (TM_FAILED 抛 string),异常穿过 scheme 边界会直接终止进程, + // 因此在转换入口兜底,降级为空文档 + try { + return latex_to_tree_body (t0); + } catch (string msg) { + failed_error << "latex_to_tree failure: " << msg << "\n"; + return tree (DOCUMENT, ""); + } +} + tree latex_document_to_tree (string s, bool as_pic) { tree r; From 5454d158f1f6c96fd446b660819083addcffc05b Mon Sep 17 00:00:00 2001 From: Da Shen Date: Thu, 10 Sep 2026 10:15:30 +0800 Subject: [PATCH 3/7] =?UTF-8?q?[1294]=20=E5=B4=A9=E6=BA=83=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E5=A4=8D=E7=8E=B0=E4=BB=A3=E7=A0=81=E8=90=BD=E7=9B=98?= =?UTF-8?q?=201294=5F1.tex=20=E5=B9=B6=E8=A1=A5=E5=85=85=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- TeXmacs/tests/tex/1294_1.tex | 1 + devel/1294.md | 11 +++++++++++ tests/Plugins/Tex/parsetex_test.cpp | 10 +++++++--- 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 TeXmacs/tests/tex/1294_1.tex diff --git a/TeXmacs/tests/tex/1294_1.tex b/TeXmacs/tests/tex/1294_1.tex new file mode 100644 index 0000000000..d1762fe4d3 --- /dev/null +++ b/TeXmacs/tests/tex/1294_1.tex @@ -0,0 +1 @@ +\def\crashMacro24{\crashMacro24} \crashMacro24 % 自递归宏定义,触发粘贴 LaTeX 崩溃 (用例 249, devel/1294.md) diff --git a/devel/1294.md b/devel/1294.md index e63919ac67..70df63bba4 100644 --- a/devel/1294.md +++ b/devel/1294.md @@ -57,6 +57,7 @@ latex_to_tree (fromtex_post.cpp) ## 涉及文件 - `tests/Plugins/Tex/parsetex_test.cpp`(新增,回归测试) +- `TeXmacs/tests/tex/1294_1.tex`(新增,崩溃用例复现代码,自动/手动测试共用) - `src/Plugins/Tex/fromtex_post.cpp` - `src/Graphics/Fonts/find_font.cpp` @@ -68,6 +69,16 @@ xmake r 0620 # 3 correct, 0 failed(latex 导入,验证正常路径不受 xmake r 0631 # 24 correct, 0 failed ``` +### 手动测试方法 + +1. 构建并启动 Mogan STEM:`xmake r stem`; +2. 用文本编辑器打开仓库中的 `TeXmacs/tests/tex/1294_1.tex`,全选并复制文件全部内容; +3. 在 Mogan STEM 中新建一个空白文档; +4. 点击菜单「编辑 → 粘贴自 → LaTeX」; +5. 预期: + - 修复前:粘贴后约 2.6 秒进程闪退(SIGSEGV); + - 修复后:粘贴正常完成,进程不退出、不闪退。 + 0628 / 0190 存量失败(导出方向 / scheme lazy-define),已用未含修复的基线构建复验, 与本任务改动无关。 diff --git a/tests/Plugins/Tex/parsetex_test.cpp b/tests/Plugins/Tex/parsetex_test.cpp index c9c5997f51..7190ba5fea 100644 --- a/tests/Plugins/Tex/parsetex_test.cpp +++ b/tests/Plugins/Tex/parsetex_test.cpp @@ -40,7 +40,7 @@ TestParseTex::initTestCase () { char* argv[]= {(char*) "parsetex_test", nullptr}; gui_open (argc, argv); if (!tm_s7) { - tm_s7= s7_init (); + tm_s7 = s7_init (); user_env= s7_inlet (tm_s7, s7_nil (tm_s7)); s7_gc_protect (tm_s7, user_env); } @@ -55,9 +55,13 @@ TestParseTex::cleanupTestCase () { void TestParseTex::test_crash_case_249 () { // 自递归宏定义用例,GUI「粘贴自 LaTeX」触发进程崩溃, + // 复现代码与手动测试共用 TeXmacs/tests/tex/1294_1.tex + string s; + QVERIFY2 ( + !load_string (url_system ("$TEXMACS_PATH/tests/tex/1294_1.tex"), s, true), + "cannot load 1294_1.tex"); // latex_document_to_tree 是粘贴路径的完整转换入口 - string s= "\\def\\crashMacro24{\\crashMacro24} \\crashMacro24\n"; - tree doc= latex_document_to_tree (s); + tree doc= latex_document_to_tree (s); QVERIFY (is_func (doc, moebius::DOCUMENT)); } From d55a4c9e6865bf39520cdaa8852eaf8b5be36295 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Thu, 10 Sep 2026 10:59:45 +0800 Subject: [PATCH 4/7] =?UTF-8?q?[1294]=20=E5=A2=9E=E5=8A=A0=E6=8E=92?= =?UTF-8?q?=E7=89=88=E5=AE=8F=E5=B1=95=E5=BC=80=E6=B7=B1=E5=BA=A6=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E5=B9=B6=E5=9C=A8=20LaTeX=20=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E5=B1=82=E6=A3=80=E6=B5=8B=E9=80=92=E5=BD=92=E5=AE=8F=E4=B8=A2?= =?UTF-8?q?=E5=BC=83=E9=97=AE=E9=A2=98=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 1294.py | 329 ++++++++++++++++++++++++++++ TeXmacs/tests/python/1294.py | 329 ++++++++++++++++++++++++++++ TeXmacs/tests/tex/1294_2.tex | 1 + devel/1294.md | 82 +++---- src/Plugins/Tex/fromtex_post.cpp | 80 ++++++- src/Typeset/Concat/concat_macro.cpp | 26 ++- src/Typeset/Env/env.cpp | 1 + src/Typeset/env.hpp | 1 + tests/Plugins/Tex/parsetex_test.cpp | 13 ++ 9 files changed, 820 insertions(+), 42 deletions(-) create mode 100755 1294.py create mode 100755 TeXmacs/tests/python/1294.py create mode 100644 TeXmacs/tests/tex/1294_2.tex diff --git a/1294.py b/1294.py new file mode 100755 index 0000000000..21108bcb8a --- /dev/null +++ b/1294.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""1294.py: 复现与验证 LaTeX 自递归宏粘贴崩溃的自动化测试脚本。 + +支持用例: + - 1294_1.tex: 无参自递归宏 (用例 249) + - 1294_2.tex: 带参自递归宏 (用例 289) + +启动命令: xmake r stem -d +用法: + python3 1294.py # 默认依序测试 1294_1.tex 和 1294_2.tex + python3 1294.py [path/to.tex] # 测试指定文件 +""" + +import os +import sys +import glob +import time +import subprocess +import threading + +from Xlib import X, display, Xatom +from Xlib.protocol import event +from pynput.keyboard import Controller as Kbd, Key +from pynput.mouse import Controller as Mouse, Button + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# 4K (3840x2312) 实测坐标 +POS_DOC = (960, 900) +POS_EDIT_MENU = (220, 140) +POS_PASTE_FROM = (227, 1008) +POS_LATEX_ITEM = (618, 1135) + +DRAFT_DIRS = [ + os.path.expanduser("~/文档/LiiiSTEM/no_name"), + os.path.expanduser("~/Documents/LiiiSTEM/no_name"), +] + +DEFAULT_CASES = [ + os.path.join(HERE, "TeXmacs", "tests", "tex", "1294_1.tex"), + os.path.join(HERE, "TeXmacs", "tests", "tex", "1294_2.tex"), +] + + +class X11Clipboard: + """X11 剪贴板持有者,在后台响应 SelectionRequest。""" + + def __init__(self): + self.disp = display.Display() + self.screen = self.disp.screen() + self.window = self.screen.root.create_window( + 0, 0, 1, 1, 0, self.screen.root_depth + ) + self.CLIPBOARD = self.disp.intern_atom('CLIPBOARD') + self.PRIMARY = self.disp.intern_atom('PRIMARY') + self.TARGETS = self.disp.intern_atom('TARGETS') + self.UTF8 = self.disp.intern_atom('UTF8_STRING') + self.TEXT = self.disp.intern_atom('TEXT') + self.STRING = Xatom.STRING + self.text = "" + self.running = True + self.thread = threading.Thread(target=self._loop, daemon=True) + self.thread.start() + + def set_text(self, text): + self.text = text + self.window.set_selection_owner(self.CLIPBOARD, X.CurrentTime) + self.window.set_selection_owner(self.PRIMARY, X.CurrentTime) + self.disp.flush() + + def _loop(self): + while self.running: + try: + if self.disp.pending_events() == 0: + time.sleep(0.01) + continue + e = self.disp.next_event() + if e.type == X.SelectionRequest: + req_win = self.disp.create_resource_object('window', e.requestor) + prop = e.property + if prop == X.NONE: + prop = e.target + if e.target == self.TARGETS: + req_win.change_property( + prop, Xatom.ATOM, 32, + [self.TARGETS, self.UTF8, self.STRING, self.TEXT] + ) + ev = event.SelectionNotify( + time=e.time, requestor=e.requestor, + selection=e.selection, target=e.target, + property=prop + ) + elif e.target in (self.UTF8, self.STRING, self.TEXT): + req_win.change_property( + prop, e.target, 8, self.text.encode('utf-8') + ) + ev = event.SelectionNotify( + time=e.time, requestor=e.requestor, + selection=e.selection, target=e.target, + property=prop + ) + else: + ev = event.SelectionNotify( + time=e.time, requestor=e.requestor, + selection=e.selection, target=e.target, + property=X.NONE + ) + req_win.send_event(ev) + self.disp.flush() + except Exception: + pass + + def stop(self): + self.running = False + + +def clean_drafts(): + """清理自动保存草稿,防止弹窗阻碍启动。""" + for d in DRAFT_DIRS: + if os.path.isdir(d): + for f in glob.glob(os.path.join(d, "draft_*.tmu")): + try: + os.remove(f) + except OSError: + pass + + +def activate_window(): + """激活 Mogan STEM 窗口。""" + js_code = ( + 'const wins = workspace.windowList ? workspace.windowList() : workspace.windows();\n' + 'for (let i = 0; i < wins.length; i++) {\n' + ' const w = wins[i];\n' + ' const cls = (w.resourceClass || "").toLowerCase();\n' + ' const name = (w.resourceName || "").toLowerCase();\n' + ' const title = (w.caption || "");\n' + ' if (cls.includes("stem") || name.includes("stem") || title.includes("Mogan")) {\n' + ' workspace.activeWindow = w;\n' + ' if (w.activate) w.activate();\n' + ' }\n' + '}\n' + ) + tmp_js = "/tmp/activate_moganstem.js" + with open(tmp_js, "w", encoding="utf-8") as f: + f.write(js_code) + + try: + import dbus + bus = dbus.SessionBus() + kwin = bus.get_object("org.kde.KWin", "/Scripting") + iface = dbus.Interface(kwin, "org.kde.kwin.Scripting") + script_id = iface.loadScript(tmp_js) + script_obj = bus.get_object("org.kde.KWin", f"/Scripting/Script{script_id}") + script_iface = dbus.Interface(script_obj, "org.kde.kwin.Script") + script_iface.run() + iface.unloadScript(str(script_id)) + except Exception: + pass + + +def is_window_ready(): + try: + out = subprocess.check_output( + ["xprop", "-root", "_NET_CLIENT_LIST"], + text=True, stderr=subprocess.DEVNULL + ) + for wid in out.split("#")[-1].replace(",", " ").split(): + c = subprocess.check_output( + ["xprop", "-id", wid, "WM_CLASS"], + text=True, stderr=subprocess.DEVNULL + ) + if "moganstem" in c.lower() or "liiistem" in c.lower(): + return True + except Exception: + pass + return False + + +class TestRunner: + def __init__(self): + self.proc = None + self.clip = X11Clipboard() + self.kbd = Kbd() + self.mouse = Mouse() + + def start_app(self): + self.stop_app() + clean_drafts() + subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL) + + print("[1294] Starting Mogan STEM via: xmake r stem -d") + self.proc = subprocess.Popen(["xmake", "r", "stem", "-d"], cwd=HERE) + + t0 = time.time() + while time.time() - t0 < 15: + if is_window_ready(): + break + time.sleep(0.3) + + time.sleep(1.0) + activate_window() + time.sleep(0.5) + + def is_alive(self): + if self.proc is None: + return False + return self.proc.poll() is None + + def stop_app(self): + if self.proc is not None: + if self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=2) + except Exception: + self.proc.kill() + self.proc = None + subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL) + clean_drafts() + + def run_case(self, case_name, tex_content): + """运行单个用例的粘贴测试。""" + if not self.is_alive(): + self.start_app() + + print(f"\n--- Running: {case_name} ---") + print(f"Content: {tex_content.strip()}") + + # 新建标签页并聚焦 + activate_window() + time.sleep(0.3) + self.kbd.press(Key.ctrl) + self.kbd.tap('t') + self.kbd.release(Key.ctrl) + time.sleep(0.8) + + self.mouse.position = POS_DOC + time.sleep(0.2) + self.mouse.click(Button.left) + time.sleep(0.4) + + # 设置剪贴板并粘贴 + self.clip.set_text(tex_content) + activate_window() + time.sleep(0.2) + + print(" -> Clicking: Edit -> Paste from -> LaTeX") + self.mouse.position = POS_EDIT_MENU + time.sleep(0.3) + self.mouse.click(Button.left) + time.sleep(0.4) + + self.mouse.position = POS_PASTE_FROM + time.sleep(0.4) + + self.mouse.position = POS_LATEX_ITEM + time.sleep(0.3) + self.mouse.click(Button.left) + + # 检查粘贴阶段是否崩溃 + t_wait = 0.0 + crashed = False + while t_wait < 3.5: + time.sleep(0.2) + t_wait += 0.2 + if not self.is_alive(): + crashed = True + print(" [!] Crashed during paste!") + break + + if not crashed: + print(" -> Pressing Enter...") + self.kbd.tap(Key.enter) + t_wait = 0.0 + while t_wait < 3.5: + time.sleep(0.2) + t_wait += 0.2 + if not self.is_alive(): + crashed = True + print(" [!] Crashed after Enter!") + break + + status = "CRASH" if crashed else "PASS" + print(f" Result for {case_name}: {status}") + return not crashed + + def close(self): + self.clip.stop() + self.stop_app() + + +def main(): + if len(sys.argv) > 1: + test_files = sys.argv[1:] + else: + test_files = [f for f in DEFAULT_CASES if os.path.exists(f)] + if not test_files: + print("[!] Default test cases not found in TeXmacs/tests/tex/!") + sys.exit(1) + + runner = TestRunner() + results = {} + + try: + for tf in test_files: + name = os.path.basename(tf) + with open(tf, "r", encoding="utf-8") as f: + content = f.read().strip() + passed = runner.run_case(name, content) + results[name] = "PASS" if passed else "CRASH" + + print("\n" + "=" * 50) + print("TEST SUMMARY:") + print("=" * 50) + all_passed = True + for name, status in results.items(): + print(f" {name:20s}: {status}") + if status != "PASS": + all_passed = False + + sys.exit(0 if all_passed else 1) + + finally: + runner.close() + + +if __name__ == "__main__": + main() diff --git a/TeXmacs/tests/python/1294.py b/TeXmacs/tests/python/1294.py new file mode 100755 index 0000000000..21108bcb8a --- /dev/null +++ b/TeXmacs/tests/python/1294.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""1294.py: 复现与验证 LaTeX 自递归宏粘贴崩溃的自动化测试脚本。 + +支持用例: + - 1294_1.tex: 无参自递归宏 (用例 249) + - 1294_2.tex: 带参自递归宏 (用例 289) + +启动命令: xmake r stem -d +用法: + python3 1294.py # 默认依序测试 1294_1.tex 和 1294_2.tex + python3 1294.py [path/to.tex] # 测试指定文件 +""" + +import os +import sys +import glob +import time +import subprocess +import threading + +from Xlib import X, display, Xatom +from Xlib.protocol import event +from pynput.keyboard import Controller as Kbd, Key +from pynput.mouse import Controller as Mouse, Button + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# 4K (3840x2312) 实测坐标 +POS_DOC = (960, 900) +POS_EDIT_MENU = (220, 140) +POS_PASTE_FROM = (227, 1008) +POS_LATEX_ITEM = (618, 1135) + +DRAFT_DIRS = [ + os.path.expanduser("~/文档/LiiiSTEM/no_name"), + os.path.expanduser("~/Documents/LiiiSTEM/no_name"), +] + +DEFAULT_CASES = [ + os.path.join(HERE, "TeXmacs", "tests", "tex", "1294_1.tex"), + os.path.join(HERE, "TeXmacs", "tests", "tex", "1294_2.tex"), +] + + +class X11Clipboard: + """X11 剪贴板持有者,在后台响应 SelectionRequest。""" + + def __init__(self): + self.disp = display.Display() + self.screen = self.disp.screen() + self.window = self.screen.root.create_window( + 0, 0, 1, 1, 0, self.screen.root_depth + ) + self.CLIPBOARD = self.disp.intern_atom('CLIPBOARD') + self.PRIMARY = self.disp.intern_atom('PRIMARY') + self.TARGETS = self.disp.intern_atom('TARGETS') + self.UTF8 = self.disp.intern_atom('UTF8_STRING') + self.TEXT = self.disp.intern_atom('TEXT') + self.STRING = Xatom.STRING + self.text = "" + self.running = True + self.thread = threading.Thread(target=self._loop, daemon=True) + self.thread.start() + + def set_text(self, text): + self.text = text + self.window.set_selection_owner(self.CLIPBOARD, X.CurrentTime) + self.window.set_selection_owner(self.PRIMARY, X.CurrentTime) + self.disp.flush() + + def _loop(self): + while self.running: + try: + if self.disp.pending_events() == 0: + time.sleep(0.01) + continue + e = self.disp.next_event() + if e.type == X.SelectionRequest: + req_win = self.disp.create_resource_object('window', e.requestor) + prop = e.property + if prop == X.NONE: + prop = e.target + if e.target == self.TARGETS: + req_win.change_property( + prop, Xatom.ATOM, 32, + [self.TARGETS, self.UTF8, self.STRING, self.TEXT] + ) + ev = event.SelectionNotify( + time=e.time, requestor=e.requestor, + selection=e.selection, target=e.target, + property=prop + ) + elif e.target in (self.UTF8, self.STRING, self.TEXT): + req_win.change_property( + prop, e.target, 8, self.text.encode('utf-8') + ) + ev = event.SelectionNotify( + time=e.time, requestor=e.requestor, + selection=e.selection, target=e.target, + property=prop + ) + else: + ev = event.SelectionNotify( + time=e.time, requestor=e.requestor, + selection=e.selection, target=e.target, + property=X.NONE + ) + req_win.send_event(ev) + self.disp.flush() + except Exception: + pass + + def stop(self): + self.running = False + + +def clean_drafts(): + """清理自动保存草稿,防止弹窗阻碍启动。""" + for d in DRAFT_DIRS: + if os.path.isdir(d): + for f in glob.glob(os.path.join(d, "draft_*.tmu")): + try: + os.remove(f) + except OSError: + pass + + +def activate_window(): + """激活 Mogan STEM 窗口。""" + js_code = ( + 'const wins = workspace.windowList ? workspace.windowList() : workspace.windows();\n' + 'for (let i = 0; i < wins.length; i++) {\n' + ' const w = wins[i];\n' + ' const cls = (w.resourceClass || "").toLowerCase();\n' + ' const name = (w.resourceName || "").toLowerCase();\n' + ' const title = (w.caption || "");\n' + ' if (cls.includes("stem") || name.includes("stem") || title.includes("Mogan")) {\n' + ' workspace.activeWindow = w;\n' + ' if (w.activate) w.activate();\n' + ' }\n' + '}\n' + ) + tmp_js = "/tmp/activate_moganstem.js" + with open(tmp_js, "w", encoding="utf-8") as f: + f.write(js_code) + + try: + import dbus + bus = dbus.SessionBus() + kwin = bus.get_object("org.kde.KWin", "/Scripting") + iface = dbus.Interface(kwin, "org.kde.kwin.Scripting") + script_id = iface.loadScript(tmp_js) + script_obj = bus.get_object("org.kde.KWin", f"/Scripting/Script{script_id}") + script_iface = dbus.Interface(script_obj, "org.kde.kwin.Script") + script_iface.run() + iface.unloadScript(str(script_id)) + except Exception: + pass + + +def is_window_ready(): + try: + out = subprocess.check_output( + ["xprop", "-root", "_NET_CLIENT_LIST"], + text=True, stderr=subprocess.DEVNULL + ) + for wid in out.split("#")[-1].replace(",", " ").split(): + c = subprocess.check_output( + ["xprop", "-id", wid, "WM_CLASS"], + text=True, stderr=subprocess.DEVNULL + ) + if "moganstem" in c.lower() or "liiistem" in c.lower(): + return True + except Exception: + pass + return False + + +class TestRunner: + def __init__(self): + self.proc = None + self.clip = X11Clipboard() + self.kbd = Kbd() + self.mouse = Mouse() + + def start_app(self): + self.stop_app() + clean_drafts() + subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL) + + print("[1294] Starting Mogan STEM via: xmake r stem -d") + self.proc = subprocess.Popen(["xmake", "r", "stem", "-d"], cwd=HERE) + + t0 = time.time() + while time.time() - t0 < 15: + if is_window_ready(): + break + time.sleep(0.3) + + time.sleep(1.0) + activate_window() + time.sleep(0.5) + + def is_alive(self): + if self.proc is None: + return False + return self.proc.poll() is None + + def stop_app(self): + if self.proc is not None: + if self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=2) + except Exception: + self.proc.kill() + self.proc = None + subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL) + clean_drafts() + + def run_case(self, case_name, tex_content): + """运行单个用例的粘贴测试。""" + if not self.is_alive(): + self.start_app() + + print(f"\n--- Running: {case_name} ---") + print(f"Content: {tex_content.strip()}") + + # 新建标签页并聚焦 + activate_window() + time.sleep(0.3) + self.kbd.press(Key.ctrl) + self.kbd.tap('t') + self.kbd.release(Key.ctrl) + time.sleep(0.8) + + self.mouse.position = POS_DOC + time.sleep(0.2) + self.mouse.click(Button.left) + time.sleep(0.4) + + # 设置剪贴板并粘贴 + self.clip.set_text(tex_content) + activate_window() + time.sleep(0.2) + + print(" -> Clicking: Edit -> Paste from -> LaTeX") + self.mouse.position = POS_EDIT_MENU + time.sleep(0.3) + self.mouse.click(Button.left) + time.sleep(0.4) + + self.mouse.position = POS_PASTE_FROM + time.sleep(0.4) + + self.mouse.position = POS_LATEX_ITEM + time.sleep(0.3) + self.mouse.click(Button.left) + + # 检查粘贴阶段是否崩溃 + t_wait = 0.0 + crashed = False + while t_wait < 3.5: + time.sleep(0.2) + t_wait += 0.2 + if not self.is_alive(): + crashed = True + print(" [!] Crashed during paste!") + break + + if not crashed: + print(" -> Pressing Enter...") + self.kbd.tap(Key.enter) + t_wait = 0.0 + while t_wait < 3.5: + time.sleep(0.2) + t_wait += 0.2 + if not self.is_alive(): + crashed = True + print(" [!] Crashed after Enter!") + break + + status = "CRASH" if crashed else "PASS" + print(f" Result for {case_name}: {status}") + return not crashed + + def close(self): + self.clip.stop() + self.stop_app() + + +def main(): + if len(sys.argv) > 1: + test_files = sys.argv[1:] + else: + test_files = [f for f in DEFAULT_CASES if os.path.exists(f)] + if not test_files: + print("[!] Default test cases not found in TeXmacs/tests/tex/!") + sys.exit(1) + + runner = TestRunner() + results = {} + + try: + for tf in test_files: + name = os.path.basename(tf) + with open(tf, "r", encoding="utf-8") as f: + content = f.read().strip() + passed = runner.run_case(name, content) + results[name] = "PASS" if passed else "CRASH" + + print("\n" + "=" * 50) + print("TEST SUMMARY:") + print("=" * 50) + all_passed = True + for name, status in results.items(): + print(f" {name:20s}: {status}") + if status != "PASS": + all_passed = False + + sys.exit(0 if all_passed else 1) + + finally: + runner.close() + + +if __name__ == "__main__": + main() diff --git a/TeXmacs/tests/tex/1294_2.tex b/TeXmacs/tests/tex/1294_2.tex new file mode 100644 index 0000000000..e98c96d8ba --- /dev/null +++ b/TeXmacs/tests/tex/1294_2.tex @@ -0,0 +1 @@ +\def\paramRec28#1{\paramRec28{#1}#1} \paramRec28{x} % 带参递归 (用例 289, devel/1294.md) diff --git a/devel/1294.md b/devel/1294.md index 70df63bba4..ed74fb841c 100644 --- a/devel/1294.md +++ b/devel/1294.md @@ -13,60 +13,66 @@ ## What -- 新增 C++ 回归测试 `tests/Plugins/Tex/parsetex_test.cpp`,以粘贴路径的完整转换入口 - `latex_document_to_tree` 复现该崩溃(修复前进程以未捕获异常终止)。 -- 修复转换链路上两处健壮性缺陷,使转换失败时降级而不是杀死进程。 +- 新增 C++ 回归测试 `tests/Plugins/Tex/parsetex_test.cpp`(包含用例 249 与 289)。 +- 新增自动化 GUI 回归测试脚本 `TeXmacs/tests/python/1294.py`(以及根目录 `1294.py`)。 +- 落实「排版引擎」+「LaTeX 转换」双层防御: + 1. 排版引擎层:`concater_rep::typeset_compound` 与 `typeset_auto` 增加宏展开深度限制,超限降级为占位 box,防止任何宏递归引起栈溢出崩溃。 + 2. LaTeX 转换层:`has_macro_cycle` 环路检测,识别自递归或相互递归宏定义并降级返回空,避免向用户文档插入需要手动删除的损坏内容。 ## Why(根因定位过程) -用例元数据最初指认 `parse_backslash` 的 replace 宏展开递归,经隔离验证不成立: -`parse_latex (s, true)` 与 `parse_latex (s, true, true)` 对该输入均正常返回、无递归。 +用例 249(`\def\crashMacro24{\crashMacro24} \crashMacro24`)与用例 289(`\def\paramRec28#1{\paramRec28{#1}#1} \paramRec28{x}`) +在通过「编辑 → 粘贴自 → LaTeX」粘贴入文档后触发崩溃,通过 addr2line 与 coredump 定位真实根因: -通过临时在 `load_tex` 失败点注入 `get_stacktrace` 输出并用 addr2line 解析, -定位真实链路(`latex_document_to_tree` → `latex_to_tree`): - -``` -latex_to_tree (fromtex_post.cpp) - → upgrade_tex (upgradetm.cpp) - → *_correct (tree_correct.cpp) → with_drd (get_document_drd) - → get_style_drd → compute_env_and_drd (new_style.cpp) - → env->exec (USE_PACKAGE "generic") → update_font (env_semantics.cpp) - → smart_font → closest_font → find_font ↔ find_font_bis (find_font.cpp) - → tex_ec_font → tex_font → load_tex (load_tex.cpp) - → TM_FAILED("Tex seems not to be installed properly") 抛 string 异常 -``` - -两处缺陷: - -1. `latex_to_tree` 转换过程中会触发样式环境求值与字体解析;环境缺 TeX 字体时 - `load_tex` 以 `TM_FAILED` 抛 `string` 异常,而转换链路(直到 scheme glue 边界) - 无任何捕获,异常穿过 s7 C 栈导致 `std::terminate`,进程终止 - (用户机器上同链路的另一表现为栈溢出 SIGSEGV)。 -2. `find_font` ↔ `find_font_bis` 经 `font_conversion` 规则表递归转换字体描述符, - 无递归深度上限;规则成环时无限递归直至栈溢出(SIGSEGV)。 +1. **LaTeX 转换层面**: + `\def\crashMacro24{...}` 会被正常解析为 `24>>`, + 属于带有自引用环路的宏定义。 +2. **排版引擎层面**: + 在文档被插入或用户按回车排版时,排版引擎进入 `concater_rep::typeset_compound`。 + 展开宏体后再次遇到宏调用,形成递归环路: + `typeset_compound` (concat_macro.cpp) → `typeset` (concater.cpp) → `typeset_concat` (concat_text.cpp) → `typeset` → `typeset_compound`。 + 无展开深度上限,导致调用栈超过 47,000+ 帧直至栈溢出(SIGSEGV)。 ## How -- `src/Plugins/Tex/fromtex_post.cpp`:原实现重命名为 `latex_to_tree_body`(static), - `latex_to_tree` 作为入口加 `try/catch (string)` 兜底(与 `edit_typeset_rep::typeset_sub` - 的既有模式一致),失败时 `failed_error` 记录并降级返回空 `DOCUMENT`。 -- `src/Graphics/Fonts/find_font.cpp`:`find_font (tree)` 增加静态递归深度计数 - (上限 100,正常链路嵌套约 5~10 层),超限按"字体未找到"降级返回 nil font; - 计数用 `try/catch (...)` 保证异常路径下回退,避免计数泄漏导致后续查找全部误判。 +- `src/Typeset/env.hpp` & `src/Typeset/Env/env.cpp`: + 在 `edit_env` 中增加 `macro_depth` 深度计数。 +- `src/Typeset/Concat/concat_macro.cpp`: + 引入 RAII 深度保护器 `macro_depth_guard`,在 `concater_rep::typeset_compound` 与 + `concater_rep::typeset_auto` 中限制展开深度(上限 100)。超限时输出警告并降级渲染 + 占位 box,杜绝无限递归死循环与爆栈。 +- `src/Plugins/Tex/fromtex_post.cpp`: + 新增 `has_macro_cycle` 依赖图环路检测算法。在 `latex_to_tree` 入口对转换后的树 + 进行宏定义成环校验;检测到自递归或相互递归宏时记录警告并降级返回空,确保 + 损坏的递归宏不会被粘贴进用户文档。 +- `src/Graphics/Fonts/find_font.cpp`: + `find_font (tree)` 增加静态递归深度计数,规则成环时按"字体未找到"降级返回 nil font。 ## 涉及文件 -- `tests/Plugins/Tex/parsetex_test.cpp`(新增,回归测试) -- `TeXmacs/tests/tex/1294_1.tex`(新增,崩溃用例复现代码,自动/手动测试共用) +- `devel/1294.md`(任务文档) +- `src/Typeset/env.hpp` +- `src/Typeset/Env/env.cpp` +- `src/Typeset/Concat/concat_macro.cpp` - `src/Plugins/Tex/fromtex_post.cpp` - `src/Graphics/Fonts/find_font.cpp` +- `tests/Plugins/Tex/parsetex_test.cpp`(回归测试,含 249 与 289 用例) +- `TeXmacs/tests/tex/1294_1.tex`(用例 249 样本) +- `TeXmacs/tests/tex/1294_2.tex`(用例 289 样本) +- `TeXmacs/tests/python/1294.py`(自动化 GUI 粘贴回归测试脚本) +- `1294.py` ## 测试 ```bash -xmake b parsetex_test && xmake r parsetex_test # 回归用例,修复前进程终止,修复后通过 -xmake r 0620 # 3 correct, 0 failed(latex 导入,验证正常路径不受影响) -xmake r 0631 # 24 correct, 0 failed +# 1. C++ 单元与回归测试 +xmake b parsetex_test && xmake r parsetex_test +xmake r 0620 +xmake r 0631 + +# 2. GUI 自动化测试 +python3 1294.py +# 预期:1294_1.tex 和 1294_2.tex 均 PASS,进程不退出、无垃圾内容插入 ``` ### 手动测试方法 diff --git a/src/Plugins/Tex/fromtex_post.cpp b/src/Plugins/Tex/fromtex_post.cpp index c5a0937327..8f0db70c29 100644 --- a/src/Plugins/Tex/fromtex_post.cpp +++ b/src/Plugins/Tex/fromtex_post.cpp @@ -2539,13 +2539,89 @@ latex_to_tree_body (tree t0) { else return t15; } +static bool +tree_calls_macro (tree t, string name) { + if (is_atomic (t)) { + return t->label == name; + } + string l= as_string (L (t)); + if (l == name) return true; + if (is_func (t, COMPOUND) && N (t) > 0 && is_atomic (t[0]) && + t[0]->label == name) + return true; + if (is_func (t, APPLY) && N (t) > 0 && is_atomic (t[0]) && + t[0]->label == name) + return true; + for (int i= 0; i < N (t); i++) + if (tree_calls_macro (t[i], name)) return true; + return false; +} + +static void +find_macro_definitions (tree t, array& names, array& bodies) { + if (is_atomic (t)) return; + if (is_func (t, ASSIGN, 2)) { + tree var = t[0]; + tree val = t[1]; + string name= is_atomic (var) ? var->label : as_string (var); + if (N (name) > 0 && !is_atomic (val)) { + names << name; + bodies << val; + } + } + for (int i= 0; i < N (t); i++) + find_macro_definitions (t[i], names, bodies); +} + +static bool +dfs_macro_cycle (int u, const array>& adj, array& state) { + state[u]= 1; + for (int k= 0; k < N (adj[u]); k++) { + int v= adj[u][k]; + if (state[v] == 1) return true; + if (state[v] == 0 && dfs_macro_cycle (v, adj, state)) return true; + } + state[u]= 2; + return false; +} + +static bool +has_macro_cycle (tree t) { + array names; + array bodies; + find_macro_definitions (t, names, bodies); + int n= N (names); + if (n == 0) return false; + + array> adj (n); + for (int i= 0; i < n; i++) { + adj[i]= array (); + for (int j= 0; j < n; j++) + if (tree_calls_macro (bodies[i], names[j])) adj[i] << j; + } + + array state (n); + for (int i= 0; i < n; i++) + state[i]= 0; + for (int i= 0; i < n; i++) + if (state[i] == 0 && dfs_macro_cycle (i, adj, state)) return true; + return false; +} + tree latex_to_tree (tree t0) { // 转换过程中会触发样式环境求值与字体解析,环境缺字体时底层以异常上报 // (TM_FAILED 抛 string),异常穿过 scheme 边界会直接终止进程, - // 因此在转换入口兜底,降级为空文档 + // 因此在转换入口兜底,降级为空文档。 + // 同时检测自递归/循环宏定义,存在环路时直接降级返回空,避免向文档插入死循环内容。 try { - return latex_to_tree_body (t0); + tree r= latex_to_tree_body (t0); + if (has_macro_cycle (r)) { + failed_error << "latex_to_tree: recursive macro definition detected, " + "returning empty\n"; + return tree (DOCUMENT, ""); + } + return r; } catch (string msg) { failed_error << "latex_to_tree failure: " << msg << "\n"; return tree (DOCUMENT, ""); diff --git a/src/Typeset/Concat/concat_macro.cpp b/src/Typeset/Concat/concat_macro.cpp index 06d7ccbfaf..2766059021 100644 --- a/src/Typeset/Concat/concat_macro.cpp +++ b/src/Typeset/Concat/concat_macro.cpp @@ -11,6 +11,7 @@ #include "concater.hpp" #include "observers.hpp" +#include "tm_debug.hpp" #include "tm_url.hpp" using namespace moebius; @@ -93,10 +94,25 @@ concater_rep::typeset_with (tree t, path ip) { STACK_DELETE_ARRAY (newv); } +struct macro_depth_guard { + int& depth; + macro_depth_guard (int& d) : depth (d) { depth++; } + ~macro_depth_guard () { depth--; } +}; + void concater_rep::typeset_compound (tree t, path ip) { - int d; - tree f; + // \def\a{\a} 之类的自递归宏在这里无限展开直至栈溢出, + // 限制宏展开嵌套深度,超限按占位标记降级渲染,防止栈溢出 + static const int max_macro_depth= 100; + if (env->macro_depth >= max_macro_depth) { + failed_error << "macro expansion too deep: " << as_string (L (t)) << "\n"; + print (test_box (ip)); + return; + } + macro_depth_guard guard (env->macro_depth); + int d; + tree f; if (L (t) == COMPOUND) { if (N (t) == 0) { typeset_error (t, ip); @@ -168,6 +184,12 @@ concater_rep::typeset_compound (tree t, path ip) { void concater_rep::typeset_auto (tree t, path ip, tree f) { + static const int max_macro_depth= 100; + if (env->macro_depth >= max_macro_depth) { + print (test_box (ip)); + return; + } + macro_depth_guard guard (env->macro_depth); env->macro_arg= list> (hashmap (UNINIT), env->macro_arg); env->macro_src= list> ( diff --git a/src/Typeset/Env/env.cpp b/src/Typeset/Env/env.cpp index a6f47860d6..d7cdebcb01 100644 --- a/src/Typeset/Env/env.cpp +++ b/src/Typeset/Env/env.cpp @@ -48,6 +48,7 @@ edit_env_rep::edit_env_rep (drd_info& drd2, url base_file_name2, complete = false; recover_env= tuple (); anim_start= anim_end= anim_portion= 0.0; + macro_depth = 0; } edit_env::edit_env (drd_info& drd, url base_file_name, diff --git a/src/Typeset/env.hpp b/src/Typeset/env.hpp index 0229433eec..42f4d81a4b 100644 --- a/src/Typeset/env.hpp +++ b/src/Typeset/env.hpp @@ -189,6 +189,7 @@ class edit_env_rep : public concrete_struct { int spacing_policy; tree math_font_sizes; int nesting_level; + int macro_depth; int info_level; int src_style; diff --git a/tests/Plugins/Tex/parsetex_test.cpp b/tests/Plugins/Tex/parsetex_test.cpp index 7190ba5fea..814e5cd271 100644 --- a/tests/Plugins/Tex/parsetex_test.cpp +++ b/tests/Plugins/Tex/parsetex_test.cpp @@ -13,6 +13,7 @@ #include "Tex/tex.hpp" #include "base.hpp" #include "boot.hpp" +#include "file.hpp" #include "server.hpp" #include "tm_sys_utils.hpp" #include @@ -29,6 +30,7 @@ private slots: void initTestCase (); void cleanupTestCase (); void test_crash_case_249 (); + void test_crash_case_289 (); }; void @@ -65,5 +67,16 @@ TestParseTex::test_crash_case_249 () { QVERIFY (is_func (doc, moebius::DOCUMENT)); } +void +TestParseTex::test_crash_case_289 () { + // 带参递归宏定义用例,复现代码 TeXmacs/tests/tex/1294_2.tex + string s; + QVERIFY2 ( + !load_string (url_system ("$TEXMACS_PATH/tests/tex/1294_2.tex"), s, true), + "cannot load 1294_2.tex"); + tree doc= latex_document_to_tree (s); + QVERIFY (is_func (doc, moebius::DOCUMENT)); +} + QTEST_MAIN (TestParseTex) #include "parsetex_test.moc" From a2641f65959be76a9a2e9a92e8d24862ee7dbdf4 Mon Sep 17 00:00:00 2001 From: Da Shen Date: Thu, 10 Sep 2026 11:11:09 +0800 Subject: [PATCH 5/7] =?UTF-8?q?[1294]=20=E5=B0=86=E7=9B=B8=E4=BA=92?= =?UTF-8?q?=E9=80=92=E5=BD=92=E5=AE=8F=E5=B4=A9=E6=BA=83=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=20crash=5Fpattern=5Fc=20=E7=BA=B3=E5=85=A5=201294=5F3.tex=20?= =?UTF-8?q?=E4=B8=8E=201294.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 1294.py | 15 +++++++++++---- TeXmacs/tests/python/1294.py | 15 +++++++++++---- TeXmacs/tests/tex/1294_3.tex | 3 +++ devel/1294.md | 7 ++++--- tests/Plugins/Tex/parsetex_test.cpp | 12 ++++++++++++ 5 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 TeXmacs/tests/tex/1294_3.tex diff --git a/1294.py b/1294.py index 21108bcb8a..faaf90d5e0 100755 --- a/1294.py +++ b/1294.py @@ -5,10 +5,11 @@ 支持用例: - 1294_1.tex: 无参自递归宏 (用例 249) - 1294_2.tex: 带参自递归宏 (用例 289) + - 1294_3.tex: 相互递归宏 (crash_pattern_c) 启动命令: xmake r stem -d 用法: - python3 1294.py # 默认依序测试 1294_1.tex 和 1294_2.tex + python3 1294.py # 默认依序测试 1294_1.tex, 1294_2.tex, 1294_3.tex python3 1294.py [path/to.tex] # 测试指定文件 """ @@ -24,7 +25,12 @@ from pynput.keyboard import Controller as Kbd, Key from pynput.mouse import Controller as Mouse, Button -HERE = os.path.dirname(os.path.abspath(__file__)) +# 定位工程根目录 +_cur = os.path.dirname(os.path.abspath(__file__)) +while _cur and _cur != "/" and not os.path.isdir(os.path.join(_cur, "TeXmacs")): + _cur = os.path.dirname(_cur) +ROOT_DIR = _cur if _cur else os.path.dirname(os.path.abspath(__file__)) +HERE = ROOT_DIR # 4K (3840x2312) 实测坐标 POS_DOC = (960, 900) @@ -38,8 +44,9 @@ ] DEFAULT_CASES = [ - os.path.join(HERE, "TeXmacs", "tests", "tex", "1294_1.tex"), - os.path.join(HERE, "TeXmacs", "tests", "tex", "1294_2.tex"), + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_1.tex"), + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_2.tex"), + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_3.tex"), ] diff --git a/TeXmacs/tests/python/1294.py b/TeXmacs/tests/python/1294.py index 21108bcb8a..faaf90d5e0 100755 --- a/TeXmacs/tests/python/1294.py +++ b/TeXmacs/tests/python/1294.py @@ -5,10 +5,11 @@ 支持用例: - 1294_1.tex: 无参自递归宏 (用例 249) - 1294_2.tex: 带参自递归宏 (用例 289) + - 1294_3.tex: 相互递归宏 (crash_pattern_c) 启动命令: xmake r stem -d 用法: - python3 1294.py # 默认依序测试 1294_1.tex 和 1294_2.tex + python3 1294.py # 默认依序测试 1294_1.tex, 1294_2.tex, 1294_3.tex python3 1294.py [path/to.tex] # 测试指定文件 """ @@ -24,7 +25,12 @@ from pynput.keyboard import Controller as Kbd, Key from pynput.mouse import Controller as Mouse, Button -HERE = os.path.dirname(os.path.abspath(__file__)) +# 定位工程根目录 +_cur = os.path.dirname(os.path.abspath(__file__)) +while _cur and _cur != "/" and not os.path.isdir(os.path.join(_cur, "TeXmacs")): + _cur = os.path.dirname(_cur) +ROOT_DIR = _cur if _cur else os.path.dirname(os.path.abspath(__file__)) +HERE = ROOT_DIR # 4K (3840x2312) 实测坐标 POS_DOC = (960, 900) @@ -38,8 +44,9 @@ ] DEFAULT_CASES = [ - os.path.join(HERE, "TeXmacs", "tests", "tex", "1294_1.tex"), - os.path.join(HERE, "TeXmacs", "tests", "tex", "1294_2.tex"), + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_1.tex"), + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_2.tex"), + os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_3.tex"), ] diff --git a/TeXmacs/tests/tex/1294_3.tex b/TeXmacs/tests/tex/1294_3.tex new file mode 100644 index 0000000000..ffc345a2b9 --- /dev/null +++ b/TeXmacs/tests/tex/1294_3.tex @@ -0,0 +1,3 @@ +\newcommand{\macroA}{\macroB} +\newcommand{\macroB}{\macroA} +\macroA diff --git a/devel/1294.md b/devel/1294.md index ed74fb841c..f4107cbb46 100644 --- a/devel/1294.md +++ b/devel/1294.md @@ -13,7 +13,7 @@ ## What -- 新增 C++ 回归测试 `tests/Plugins/Tex/parsetex_test.cpp`(包含用例 249 与 289)。 +- 新增 C++ 回归测试 `tests/Plugins/Tex/parsetex_test.cpp`(包含用例 249、289 与 pattern_c)。 - 新增自动化 GUI 回归测试脚本 `TeXmacs/tests/python/1294.py`(以及根目录 `1294.py`)。 - 落实「排版引擎」+「LaTeX 转换」双层防御: 1. 排版引擎层:`concater_rep::typeset_compound` 与 `typeset_auto` 增加宏展开深度限制,超限降级为占位 box,防止任何宏递归引起栈溢出崩溃。 @@ -56,9 +56,10 @@ - `src/Typeset/Concat/concat_macro.cpp` - `src/Plugins/Tex/fromtex_post.cpp` - `src/Graphics/Fonts/find_font.cpp` -- `tests/Plugins/Tex/parsetex_test.cpp`(回归测试,含 249 与 289 用例) +- `tests/Plugins/Tex/parsetex_test.cpp`(回归测试,含 249、289 与 1294_3 用例) - `TeXmacs/tests/tex/1294_1.tex`(用例 249 样本) - `TeXmacs/tests/tex/1294_2.tex`(用例 289 样本) +- `TeXmacs/tests/tex/1294_3.tex`(用例 crash_pattern_c 相互递归样本) - `TeXmacs/tests/python/1294.py`(自动化 GUI 粘贴回归测试脚本) - `1294.py` @@ -72,7 +73,7 @@ xmake r 0631 # 2. GUI 自动化测试 python3 1294.py -# 预期:1294_1.tex 和 1294_2.tex 均 PASS,进程不退出、无垃圾内容插入 +# 预期:1294_1.tex、1294_2.tex 和 1294_3.tex 均 PASS,进程不退出、无垃圾内容插入 ``` ### 手动测试方法 diff --git a/tests/Plugins/Tex/parsetex_test.cpp b/tests/Plugins/Tex/parsetex_test.cpp index 814e5cd271..9639192615 100644 --- a/tests/Plugins/Tex/parsetex_test.cpp +++ b/tests/Plugins/Tex/parsetex_test.cpp @@ -31,6 +31,7 @@ private slots: void cleanupTestCase (); void test_crash_case_249 (); void test_crash_case_289 (); + void test_crash_case_1294_3 (); }; void @@ -78,5 +79,16 @@ TestParseTex::test_crash_case_289 () { QVERIFY (is_func (doc, moebius::DOCUMENT)); } +void +TestParseTex::test_crash_case_1294_3 () { + // 相互递归宏定义用例 (crash_pattern_c),复现代码 TeXmacs/tests/tex/1294_3.tex + string s; + QVERIFY2 ( + !load_string (url_system ("$TEXMACS_PATH/tests/tex/1294_3.tex"), s, true), + "cannot load 1294_3.tex"); + tree doc= latex_document_to_tree (s); + QVERIFY (is_func (doc, moebius::DOCUMENT)); +} + QTEST_MAIN (TestParseTex) #include "parsetex_test.moc" From dfd860e0f413b9a3315839cf9b114c2d6f81d2ef Mon Sep 17 00:00:00 2001 From: Da Shen Date: Thu, 10 Sep 2026 11:19:23 +0800 Subject: [PATCH 6/7] =?UTF-8?q?[1294]=20=E6=B8=85=E7=90=86=E9=98=B2?= =?UTF-8?q?=E5=BE=A1=E4=BB=A3=E7=A0=81=EF=BC=9A=E7=BB=9F=E4=B8=80=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E5=AE=88=E5=8D=AB=E6=A8=A1=E5=BC=8F=E3=80=81=E7=B2=BE?= =?UTF-8?q?=E7=AE=80=E7=8E=AF=E6=A3=80=E6=B5=8B=E4=B8=8E=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - concat_macro.cpp: max_macro_depth 提升为文件级常量,两处超限检查收敛为 concater_rep::macro_depth_exceeded(消除 typeset_auto 静默不降级的漂移) - find_font.cpp: 手写计数+try/catch 回退改为 RAII find_font_guard, 与 macro_depth_guard 同构 - fromtex_post.cpp: tree_calls_macro 删除被子节点递归覆盖的 COMPOUND/APPLY 特判及 per-node as_string 标签比较 - parsetex_test.cpp: 三个用例收敛为 check_crash_case helper,删除空的 cleanupTestCase - 删除根目录重复的 1294.py,保留 TeXmacs/tests/python/ 下的规范位置副本 Co-Authored-By: Claude --- 1294.py | 336 ---------------------------- TeXmacs/tests/python/1294.py | 4 +- devel/1294.md | 5 +- src/Graphics/Fonts/find_font.cpp | 24 +- src/Plugins/Tex/fromtex_post.cpp | 13 +- src/Typeset/Concat/concat_macro.cpp | 27 +-- src/Typeset/Concat/concater.hpp | 1 + tests/Plugins/Tex/parsetex_test.cpp | 43 ++-- 8 files changed, 50 insertions(+), 403 deletions(-) delete mode 100755 1294.py diff --git a/1294.py b/1294.py deleted file mode 100755 index faaf90d5e0..0000000000 --- a/1294.py +++ /dev/null @@ -1,336 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""1294.py: 复现与验证 LaTeX 自递归宏粘贴崩溃的自动化测试脚本。 - -支持用例: - - 1294_1.tex: 无参自递归宏 (用例 249) - - 1294_2.tex: 带参自递归宏 (用例 289) - - 1294_3.tex: 相互递归宏 (crash_pattern_c) - -启动命令: xmake r stem -d -用法: - python3 1294.py # 默认依序测试 1294_1.tex, 1294_2.tex, 1294_3.tex - python3 1294.py [path/to.tex] # 测试指定文件 -""" - -import os -import sys -import glob -import time -import subprocess -import threading - -from Xlib import X, display, Xatom -from Xlib.protocol import event -from pynput.keyboard import Controller as Kbd, Key -from pynput.mouse import Controller as Mouse, Button - -# 定位工程根目录 -_cur = os.path.dirname(os.path.abspath(__file__)) -while _cur and _cur != "/" and not os.path.isdir(os.path.join(_cur, "TeXmacs")): - _cur = os.path.dirname(_cur) -ROOT_DIR = _cur if _cur else os.path.dirname(os.path.abspath(__file__)) -HERE = ROOT_DIR - -# 4K (3840x2312) 实测坐标 -POS_DOC = (960, 900) -POS_EDIT_MENU = (220, 140) -POS_PASTE_FROM = (227, 1008) -POS_LATEX_ITEM = (618, 1135) - -DRAFT_DIRS = [ - os.path.expanduser("~/文档/LiiiSTEM/no_name"), - os.path.expanduser("~/Documents/LiiiSTEM/no_name"), -] - -DEFAULT_CASES = [ - os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_1.tex"), - os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_2.tex"), - os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_3.tex"), -] - - -class X11Clipboard: - """X11 剪贴板持有者,在后台响应 SelectionRequest。""" - - def __init__(self): - self.disp = display.Display() - self.screen = self.disp.screen() - self.window = self.screen.root.create_window( - 0, 0, 1, 1, 0, self.screen.root_depth - ) - self.CLIPBOARD = self.disp.intern_atom('CLIPBOARD') - self.PRIMARY = self.disp.intern_atom('PRIMARY') - self.TARGETS = self.disp.intern_atom('TARGETS') - self.UTF8 = self.disp.intern_atom('UTF8_STRING') - self.TEXT = self.disp.intern_atom('TEXT') - self.STRING = Xatom.STRING - self.text = "" - self.running = True - self.thread = threading.Thread(target=self._loop, daemon=True) - self.thread.start() - - def set_text(self, text): - self.text = text - self.window.set_selection_owner(self.CLIPBOARD, X.CurrentTime) - self.window.set_selection_owner(self.PRIMARY, X.CurrentTime) - self.disp.flush() - - def _loop(self): - while self.running: - try: - if self.disp.pending_events() == 0: - time.sleep(0.01) - continue - e = self.disp.next_event() - if e.type == X.SelectionRequest: - req_win = self.disp.create_resource_object('window', e.requestor) - prop = e.property - if prop == X.NONE: - prop = e.target - if e.target == self.TARGETS: - req_win.change_property( - prop, Xatom.ATOM, 32, - [self.TARGETS, self.UTF8, self.STRING, self.TEXT] - ) - ev = event.SelectionNotify( - time=e.time, requestor=e.requestor, - selection=e.selection, target=e.target, - property=prop - ) - elif e.target in (self.UTF8, self.STRING, self.TEXT): - req_win.change_property( - prop, e.target, 8, self.text.encode('utf-8') - ) - ev = event.SelectionNotify( - time=e.time, requestor=e.requestor, - selection=e.selection, target=e.target, - property=prop - ) - else: - ev = event.SelectionNotify( - time=e.time, requestor=e.requestor, - selection=e.selection, target=e.target, - property=X.NONE - ) - req_win.send_event(ev) - self.disp.flush() - except Exception: - pass - - def stop(self): - self.running = False - - -def clean_drafts(): - """清理自动保存草稿,防止弹窗阻碍启动。""" - for d in DRAFT_DIRS: - if os.path.isdir(d): - for f in glob.glob(os.path.join(d, "draft_*.tmu")): - try: - os.remove(f) - except OSError: - pass - - -def activate_window(): - """激活 Mogan STEM 窗口。""" - js_code = ( - 'const wins = workspace.windowList ? workspace.windowList() : workspace.windows();\n' - 'for (let i = 0; i < wins.length; i++) {\n' - ' const w = wins[i];\n' - ' const cls = (w.resourceClass || "").toLowerCase();\n' - ' const name = (w.resourceName || "").toLowerCase();\n' - ' const title = (w.caption || "");\n' - ' if (cls.includes("stem") || name.includes("stem") || title.includes("Mogan")) {\n' - ' workspace.activeWindow = w;\n' - ' if (w.activate) w.activate();\n' - ' }\n' - '}\n' - ) - tmp_js = "/tmp/activate_moganstem.js" - with open(tmp_js, "w", encoding="utf-8") as f: - f.write(js_code) - - try: - import dbus - bus = dbus.SessionBus() - kwin = bus.get_object("org.kde.KWin", "/Scripting") - iface = dbus.Interface(kwin, "org.kde.kwin.Scripting") - script_id = iface.loadScript(tmp_js) - script_obj = bus.get_object("org.kde.KWin", f"/Scripting/Script{script_id}") - script_iface = dbus.Interface(script_obj, "org.kde.kwin.Script") - script_iface.run() - iface.unloadScript(str(script_id)) - except Exception: - pass - - -def is_window_ready(): - try: - out = subprocess.check_output( - ["xprop", "-root", "_NET_CLIENT_LIST"], - text=True, stderr=subprocess.DEVNULL - ) - for wid in out.split("#")[-1].replace(",", " ").split(): - c = subprocess.check_output( - ["xprop", "-id", wid, "WM_CLASS"], - text=True, stderr=subprocess.DEVNULL - ) - if "moganstem" in c.lower() or "liiistem" in c.lower(): - return True - except Exception: - pass - return False - - -class TestRunner: - def __init__(self): - self.proc = None - self.clip = X11Clipboard() - self.kbd = Kbd() - self.mouse = Mouse() - - def start_app(self): - self.stop_app() - clean_drafts() - subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL) - - print("[1294] Starting Mogan STEM via: xmake r stem -d") - self.proc = subprocess.Popen(["xmake", "r", "stem", "-d"], cwd=HERE) - - t0 = time.time() - while time.time() - t0 < 15: - if is_window_ready(): - break - time.sleep(0.3) - - time.sleep(1.0) - activate_window() - time.sleep(0.5) - - def is_alive(self): - if self.proc is None: - return False - return self.proc.poll() is None - - def stop_app(self): - if self.proc is not None: - if self.proc.poll() is None: - self.proc.terminate() - try: - self.proc.wait(timeout=2) - except Exception: - self.proc.kill() - self.proc = None - subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL) - clean_drafts() - - def run_case(self, case_name, tex_content): - """运行单个用例的粘贴测试。""" - if not self.is_alive(): - self.start_app() - - print(f"\n--- Running: {case_name} ---") - print(f"Content: {tex_content.strip()}") - - # 新建标签页并聚焦 - activate_window() - time.sleep(0.3) - self.kbd.press(Key.ctrl) - self.kbd.tap('t') - self.kbd.release(Key.ctrl) - time.sleep(0.8) - - self.mouse.position = POS_DOC - time.sleep(0.2) - self.mouse.click(Button.left) - time.sleep(0.4) - - # 设置剪贴板并粘贴 - self.clip.set_text(tex_content) - activate_window() - time.sleep(0.2) - - print(" -> Clicking: Edit -> Paste from -> LaTeX") - self.mouse.position = POS_EDIT_MENU - time.sleep(0.3) - self.mouse.click(Button.left) - time.sleep(0.4) - - self.mouse.position = POS_PASTE_FROM - time.sleep(0.4) - - self.mouse.position = POS_LATEX_ITEM - time.sleep(0.3) - self.mouse.click(Button.left) - - # 检查粘贴阶段是否崩溃 - t_wait = 0.0 - crashed = False - while t_wait < 3.5: - time.sleep(0.2) - t_wait += 0.2 - if not self.is_alive(): - crashed = True - print(" [!] Crashed during paste!") - break - - if not crashed: - print(" -> Pressing Enter...") - self.kbd.tap(Key.enter) - t_wait = 0.0 - while t_wait < 3.5: - time.sleep(0.2) - t_wait += 0.2 - if not self.is_alive(): - crashed = True - print(" [!] Crashed after Enter!") - break - - status = "CRASH" if crashed else "PASS" - print(f" Result for {case_name}: {status}") - return not crashed - - def close(self): - self.clip.stop() - self.stop_app() - - -def main(): - if len(sys.argv) > 1: - test_files = sys.argv[1:] - else: - test_files = [f for f in DEFAULT_CASES if os.path.exists(f)] - if not test_files: - print("[!] Default test cases not found in TeXmacs/tests/tex/!") - sys.exit(1) - - runner = TestRunner() - results = {} - - try: - for tf in test_files: - name = os.path.basename(tf) - with open(tf, "r", encoding="utf-8") as f: - content = f.read().strip() - passed = runner.run_case(name, content) - results[name] = "PASS" if passed else "CRASH" - - print("\n" + "=" * 50) - print("TEST SUMMARY:") - print("=" * 50) - all_passed = True - for name, status in results.items(): - print(f" {name:20s}: {status}") - if status != "PASS": - all_passed = False - - sys.exit(0 if all_passed else 1) - - finally: - runner.close() - - -if __name__ == "__main__": - main() diff --git a/TeXmacs/tests/python/1294.py b/TeXmacs/tests/python/1294.py index faaf90d5e0..3adbb23a3c 100755 --- a/TeXmacs/tests/python/1294.py +++ b/TeXmacs/tests/python/1294.py @@ -9,8 +9,8 @@ 启动命令: xmake r stem -d 用法: - python3 1294.py # 默认依序测试 1294_1.tex, 1294_2.tex, 1294_3.tex - python3 1294.py [path/to.tex] # 测试指定文件 + python3 TeXmacs/tests/python/1294.py # 默认依序测试 1294_1.tex, 1294_2.tex, 1294_3.tex + python3 TeXmacs/tests/python/1294.py [path/to.tex] # 测试指定文件 """ import os diff --git a/devel/1294.md b/devel/1294.md index f4107cbb46..a7cbf5dfac 100644 --- a/devel/1294.md +++ b/devel/1294.md @@ -14,7 +14,7 @@ ## What - 新增 C++ 回归测试 `tests/Plugins/Tex/parsetex_test.cpp`(包含用例 249、289 与 pattern_c)。 -- 新增自动化 GUI 回归测试脚本 `TeXmacs/tests/python/1294.py`(以及根目录 `1294.py`)。 +- 新增自动化 GUI 回归测试脚本 `TeXmacs/tests/python/1294.py`。 - 落实「排版引擎」+「LaTeX 转换」双层防御: 1. 排版引擎层:`concater_rep::typeset_compound` 与 `typeset_auto` 增加宏展开深度限制,超限降级为占位 box,防止任何宏递归引起栈溢出崩溃。 2. LaTeX 转换层:`has_macro_cycle` 环路检测,识别自递归或相互递归宏定义并降级返回空,避免向用户文档插入需要手动删除的损坏内容。 @@ -61,7 +61,6 @@ - `TeXmacs/tests/tex/1294_2.tex`(用例 289 样本) - `TeXmacs/tests/tex/1294_3.tex`(用例 crash_pattern_c 相互递归样本) - `TeXmacs/tests/python/1294.py`(自动化 GUI 粘贴回归测试脚本) -- `1294.py` ## 测试 @@ -72,7 +71,7 @@ xmake r 0620 xmake r 0631 # 2. GUI 自动化测试 -python3 1294.py +python3 TeXmacs/tests/python/1294.py # 预期:1294_1.tex、1294_2.tex 和 1294_3.tex 均 PASS,进程不退出、无垃圾内容插入 ``` diff --git a/src/Graphics/Fonts/find_font.cpp b/src/Graphics/Fonts/find_font.cpp index 1858e7ae48..60e869fd9a 100644 --- a/src/Graphics/Fonts/find_font.cpp +++ b/src/Graphics/Fonts/find_font.cpp @@ -198,6 +198,16 @@ find_font_bis (tree t) { return font (); } +// 析构时回退递归计数并结算计时,异常路径(底层字体加载抛异常)同样生效 +struct find_font_guard { + int& level; + find_font_guard (int& l) : level (l) { level++; } + ~find_font_guard () { + level--; + bench_cumul ("find font"); + } +}; + font find_font (tree t) { static int find_font_level= 0; @@ -209,18 +219,8 @@ find_font (tree t) { return font (); } bench_start ("find font"); - find_font_level++; - try { - font fn= find_font_bis (t); - find_font_level--; - bench_cumul ("find font"); - return fn; - } catch (...) { - // 底层字体加载失败会抛异常,计数必须回退,否则后续查找全部误判为过深 - find_font_level--; - bench_cumul ("find font"); - throw; - } + find_font_guard guard (find_font_level); + return find_font_bis (t); } font diff --git a/src/Plugins/Tex/fromtex_post.cpp b/src/Plugins/Tex/fromtex_post.cpp index 8f0db70c29..f7d32d1b42 100644 --- a/src/Plugins/Tex/fromtex_post.cpp +++ b/src/Plugins/Tex/fromtex_post.cpp @@ -2541,17 +2541,8 @@ latex_to_tree_body (tree t0) { static bool tree_calls_macro (tree t, string name) { - if (is_atomic (t)) { - return t->label == name; - } - string l= as_string (L (t)); - if (l == name) return true; - if (is_func (t, COMPOUND) && N (t) > 0 && is_atomic (t[0]) && - t[0]->label == name) - return true; - if (is_func (t, APPLY) && N (t) > 0 && is_atomic (t[0]) && - t[0]->label == name) - return true; + // 宏调用在树中即同名原子叶(compound/apply 的头也是原子子节点,由递归覆盖) + if (is_atomic (t)) return t->label == name; for (int i= 0; i < N (t); i++) if (tree_calls_macro (t[i], name)) return true; return false; diff --git a/src/Typeset/Concat/concat_macro.cpp b/src/Typeset/Concat/concat_macro.cpp index 2766059021..b1c4d4a6ae 100644 --- a/src/Typeset/Concat/concat_macro.cpp +++ b/src/Typeset/Concat/concat_macro.cpp @@ -94,22 +94,27 @@ concater_rep::typeset_with (tree t, path ip) { STACK_DELETE_ARRAY (newv); } +// \def\a{\a} 之类的自递归宏在宏展开处无限递归直至栈溢出, +// 限制宏展开嵌套深度,超限按占位标记降级渲染 +static const int max_macro_depth= 100; + struct macro_depth_guard { int& depth; macro_depth_guard (int& d) : depth (d) { depth++; } ~macro_depth_guard () { depth--; } }; +bool +concater_rep::macro_depth_exceeded (tree t, path ip) { + if (env->macro_depth < max_macro_depth) return false; + failed_error << "macro expansion too deep: " << as_string (L (t)) << "\n"; + print (test_box (ip)); + return true; +} + void concater_rep::typeset_compound (tree t, path ip) { - // \def\a{\a} 之类的自递归宏在这里无限展开直至栈溢出, - // 限制宏展开嵌套深度,超限按占位标记降级渲染,防止栈溢出 - static const int max_macro_depth= 100; - if (env->macro_depth >= max_macro_depth) { - failed_error << "macro expansion too deep: " << as_string (L (t)) << "\n"; - print (test_box (ip)); - return; - } + if (macro_depth_exceeded (t, ip)) return; macro_depth_guard guard (env->macro_depth); int d; tree f; @@ -184,11 +189,7 @@ concater_rep::typeset_compound (tree t, path ip) { void concater_rep::typeset_auto (tree t, path ip, tree f) { - static const int max_macro_depth= 100; - if (env->macro_depth >= max_macro_depth) { - print (test_box (ip)); - return; - } + if (macro_depth_exceeded (t, ip)) return; macro_depth_guard guard (env->macro_depth); env->macro_arg= list> (hashmap (UNINIT), env->macro_arg); diff --git a/src/Typeset/Concat/concater.hpp b/src/Typeset/Concat/concater.hpp index 9bc9abd44c..eedfc9e915 100644 --- a/src/Typeset/Concat/concater.hpp +++ b/src/Typeset/Concat/concater.hpp @@ -107,6 +107,7 @@ class concater_rep { void typeset_error (tree t, path ip); // active macro mechanisms + bool macro_depth_exceeded (tree t, path ip); void typeset_assign (tree t, path ip); void typeset_provide (tree t, path ip); void typeset_with (tree t, path ip); diff --git a/tests/Plugins/Tex/parsetex_test.cpp b/tests/Plugins/Tex/parsetex_test.cpp index 9639192615..f364e83aca 100644 --- a/tests/Plugins/Tex/parsetex_test.cpp +++ b/tests/Plugins/Tex/parsetex_test.cpp @@ -25,10 +25,12 @@ class TestParseTex : public QObject { private: server* sv; + // latex_document_to_tree 是粘贴路径的完整转换入口; + // 复现代码与手动测试共用 TeXmacs/tests/tex/ 下的样本 + void check_crash_case (const char* file); private slots: void initTestCase (); - void cleanupTestCase (); void test_crash_case_249 (); void test_crash_case_289 (); void test_crash_case_1294_3 (); @@ -51,43 +53,32 @@ TestParseTex::initTestCase () { } void -TestParseTex::cleanupTestCase () { - // server rep is managed +TestParseTex::check_crash_case (const char* file) { + string s; + QVERIFY2 (!load_string (url_system (string ("$TEXMACS_PATH/tests/tex/") * + string (file)), + s, true), + "cannot load crash case tex file"); + tree doc= latex_document_to_tree (s); + QVERIFY (is_func (doc, moebius::DOCUMENT)); } void TestParseTex::test_crash_case_249 () { - // 自递归宏定义用例,GUI「粘贴自 LaTeX」触发进程崩溃, - // 复现代码与手动测试共用 TeXmacs/tests/tex/1294_1.tex - string s; - QVERIFY2 ( - !load_string (url_system ("$TEXMACS_PATH/tests/tex/1294_1.tex"), s, true), - "cannot load 1294_1.tex"); - // latex_document_to_tree 是粘贴路径的完整转换入口 - tree doc= latex_document_to_tree (s); - QVERIFY (is_func (doc, moebius::DOCUMENT)); + // 无参自递归宏定义 + check_crash_case ("1294_1.tex"); } void TestParseTex::test_crash_case_289 () { - // 带参递归宏定义用例,复现代码 TeXmacs/tests/tex/1294_2.tex - string s; - QVERIFY2 ( - !load_string (url_system ("$TEXMACS_PATH/tests/tex/1294_2.tex"), s, true), - "cannot load 1294_2.tex"); - tree doc= latex_document_to_tree (s); - QVERIFY (is_func (doc, moebius::DOCUMENT)); + // 带参自递归宏定义 + check_crash_case ("1294_2.tex"); } void TestParseTex::test_crash_case_1294_3 () { - // 相互递归宏定义用例 (crash_pattern_c),复现代码 TeXmacs/tests/tex/1294_3.tex - string s; - QVERIFY2 ( - !load_string (url_system ("$TEXMACS_PATH/tests/tex/1294_3.tex"), s, true), - "cannot load 1294_3.tex"); - tree doc= latex_document_to_tree (s); - QVERIFY (is_func (doc, moebius::DOCUMENT)); + // 相互递归宏定义 (crash_pattern_c) + check_crash_case ("1294_3.tex"); } QTEST_MAIN (TestParseTex) From ec55aa0926cdc11fb6312f05e5f05ce9a06f8d1c Mon Sep 17 00:00:00 2001 From: Da Shen Date: Thu, 10 Sep 2026 13:10:45 +0800 Subject: [PATCH 7/7] =?UTF-8?q?[1294]=20=E4=BF=AE=E5=A4=8D=E5=AE=8F?= =?UTF-8?q?=E7=8E=AF=E8=B7=AF=E6=A3=80=E6=B5=8B=E6=BC=8F=E6=A3=80=E5=B9=B6?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20pynput=20=E8=A6=86=E7=9B=96=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E5=BC=B9=E7=AA=97=E5=8F=8A=E7=BB=A7=E7=BB=AD=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TeXmacs/tests/python/1294.py | 67 +++++++++++++++++++++++++++-- devel/1294.md | 14 +++++- src/Plugins/Tex/fromtex_post.cpp | 11 ++++- src/Plugins/Tex/tex.hpp | 1 + tests/Plugins/Tex/parsetex_test.cpp | 31 +++++++++++++ 5 files changed, 117 insertions(+), 7 deletions(-) diff --git a/TeXmacs/tests/python/1294.py b/TeXmacs/tests/python/1294.py index 3adbb23a3c..ba66d92e2e 100755 --- a/TeXmacs/tests/python/1294.py +++ b/TeXmacs/tests/python/1294.py @@ -165,6 +165,25 @@ def activate_window(): except Exception: pass + try: + d = display.Display() + root = d.screen().root + NET_ACTIVE_WINDOW = d.intern_atom('_NET_ACTIVE_WINDOW') + NET_CLIENT_LIST = d.intern_atom('_NET_CLIENT_LIST') + prop = root.get_full_property(NET_CLIENT_LIST, Xatom.WINDOW) + if prop: + for wid in prop.value: + win = d.create_resource_object('window', wid) + c = str(win.get_wm_class() or "").lower() + if "stem" in c or "mogan" in c: + data = [2, X.CurrentTime, 0, 0, 0] + ev = event.ClientMessage(window=wid, client_type=NET_ACTIVE_WINDOW, data=(32, data)) + root.send_event(ev, event_mask=X.SubstructureRedirectMask | X.SubstructureNotifyMask) + d.flush() + break + except Exception: + pass + def is_window_ready(): try: @@ -276,16 +295,58 @@ def run_case(self, case_name, tex_content): print(" [!] Crashed during paste!") break + # 处理可能的错误弹窗并关闭 if not crashed: - print(" -> Pressing Enter...") + time.sleep(0.5) + print(" -> Closing possible error popup (Enter)...") self.kbd.tap(Key.enter) + time.sleep(0.5) + if not self.is_alive(): + crashed = True + print(" [!] Crashed after dismissing popup!") + + # 关闭弹窗后,继续编辑测试(防止损坏状态下继续操作引起崩溃) + if not crashed: + print(" -> Testing continue editing after paste...") + activate_window() + time.sleep(0.3) + self.mouse.position = POS_DOC + time.sleep(0.2) + self.mouse.click(Button.left) + time.sleep(0.3) + + # 键入文本 + self.kbd.type("testing edit after paste ") + time.sleep(0.3) + # 回车触发分段排版 + self.kbd.tap(Key.enter) + time.sleep(0.3) + # 键入更多内容 + self.kbd.type("continue typing 12345") + time.sleep(0.3) + # 回车 + self.kbd.tap(Key.enter) + time.sleep(0.3) + # 退格删除 + for _ in range(8): + self.kbd.tap(Key.backspace) + time.sleep(0.05) + # 光标移动 + self.kbd.tap(Key.up) + time.sleep(0.1) + self.kbd.tap(Key.down) + time.sleep(0.1) + self.kbd.type(" finished") + time.sleep(0.5) + + # 等待排版和可能的后台处理 t_wait = 0.0 - while t_wait < 3.5: + while t_wait < 2.5: time.sleep(0.2) t_wait += 0.2 if not self.is_alive(): crashed = True - print(" [!] Crashed after Enter!") + print(" [!] Crashed during continue editing!") break status = "CRASH" if crashed else "PASS" diff --git a/devel/1294.md b/devel/1294.md index a7cbf5dfac..6b0c61fbe8 100644 --- a/devel/1294.md +++ b/devel/1294.md @@ -70,9 +70,10 @@ xmake b parsetex_test && xmake r parsetex_test xmake r 0620 xmake r 0631 -# 2. GUI 自动化测试 +# 2. GUI 自动化测试(使用 pynput 驱动真实 GUI) python3 TeXmacs/tests/python/1294.py -# 预期:1294_1.tex、1294_2.tex 和 1294_3.tex 均 PASS,进程不退出、无垃圾内容插入 +# 预期:1294_1.tex、1294_2.tex 和 1294_3.tex 均 PASS, +# 覆盖完整流程:粘贴 -> 关闭错误弹窗 -> 继续键入/分段/删除/光标移动编辑 -> 进程均不崩溃退出 ``` ### 手动测试方法 @@ -94,3 +95,12 @@ python3 TeXmacs/tests/python/1294.py `generic_to_tree (s, "latex-snippet")` → scheme `convert` → `parse-latex`(C++ `parse_latex`)→ `latex->texmacs`(C++ `latex_to_tree`)。 - GUI 手动验证(含崩溃用例 249 现象消失)由用户执行。 + +## 迭代改进记录 + +- 针对 `dfd860e0f` 中精简 `tree_calls_macro` 时误删 `L(t)` 与 `COMPOUND`/`APPLY` 匹配导致 `has_macro_cycle` 漏判宏环路的问题进行了修复: + - `src/Plugins/Tex/fromtex_post.cpp`:恢复 `L(t)` 比较与 `COMPOUND`/`APPLY` 运算符提取,准确识别宏复合节点。 + - `src/Plugins/Tex/tex.hpp`:导出 `bool has_macro_cycle (tree t)` 接口。 + - `tests/Plugins/Tex/parsetex_test.cpp`:新增 `test_has_macro_cycle` 独立断言用例,覆盖自递归、带参自递归、相互递归与正常宏,杜绝漏检回归。 + - `TeXmacs/tests/python/1294.py`:明确采用 `pynput` 完善全流程自动化测试,在粘贴后主动关闭可能的错误弹窗,并模拟用户在文档中继续输入文本、回车分段排版、退格删除以及光标移动等连续编辑操作,验证全过程均不 crash。 + diff --git a/src/Plugins/Tex/fromtex_post.cpp b/src/Plugins/Tex/fromtex_post.cpp index f7d32d1b42..b28f0eb927 100644 --- a/src/Plugins/Tex/fromtex_post.cpp +++ b/src/Plugins/Tex/fromtex_post.cpp @@ -2541,8 +2541,15 @@ latex_to_tree_body (tree t0) { static bool tree_calls_macro (tree t, string name) { - // 宏调用在树中即同名原子叶(compound/apply 的头也是原子子节点,由递归覆盖) if (is_atomic (t)) return t->label == name; + string l= as_string (L (t)); + if (l == name) return true; + if (is_func (t, COMPOUND) && N (t) > 0 && is_atomic (t[0]) && + t[0]->label == name) + return true; + if (is_func (t, APPLY) && N (t) > 0 && is_atomic (t[0]) && + t[0]->label == name) + return true; for (int i= 0; i < N (t); i++) if (tree_calls_macro (t[i], name)) return true; return false; @@ -2576,7 +2583,7 @@ dfs_macro_cycle (int u, const array>& adj, array& state) { return false; } -static bool +bool has_macro_cycle (tree t) { array names; array bodies; diff --git a/src/Plugins/Tex/tex.hpp b/src/Plugins/Tex/tex.hpp index 59dd3c22ec..fcd5f75dd3 100644 --- a/src/Plugins/Tex/tex.hpp +++ b/src/Plugins/Tex/tex.hpp @@ -21,6 +21,7 @@ tree parse_latex_document (string s, bool change= false, bool as_pic= false); tree latex_to_tree (tree t); tree latex_document_to_tree (string s, bool as_pic= false); tree latex_class_document_to_tree (string s); +bool has_macro_cycle (tree t); string latex_verbarg_to_string (tree t); string get_latex_style (tree t); string string_arg (tree t, bool u= false); diff --git a/tests/Plugins/Tex/parsetex_test.cpp b/tests/Plugins/Tex/parsetex_test.cpp index f364e83aca..d19b1ff0ac 100644 --- a/tests/Plugins/Tex/parsetex_test.cpp +++ b/tests/Plugins/Tex/parsetex_test.cpp @@ -34,6 +34,7 @@ private slots: void test_crash_case_249 (); void test_crash_case_289 (); void test_crash_case_1294_3 (); + void test_has_macro_cycle (); }; void @@ -81,5 +82,35 @@ TestParseTex::test_crash_case_1294_3 () { check_crash_case ("1294_3.tex"); } +void +TestParseTex::test_has_macro_cycle () { + // 1. 无参自递归: \def\foo{\foo} -> >> + tree self_rec= + tuple (compound ("assign", "foo", compound ("macro", compound ("foo")))); + QVERIFY (has_macro_cycle (self_rec)); + + // 2. 带参自递归: \def\foo#1{\foo{#1}} -> >>> + tree param_rec= tuple (compound ( + "assign", "foo", + compound ("macro", "x", compound ("foo", compound ("arg", "x"))))); + QVERIFY (has_macro_cycle (param_rec)); + + // 3. 相互递归: foo 调 bar, bar 调 foo + tree mutual_rec= + tuple (compound ("assign", "foo", compound ("macro", compound ("bar"))), + compound ("assign", "bar", compound ("macro", compound ("foo")))); + QVERIFY (has_macro_cycle (mutual_rec)); + + // 4. 非递归正常宏: foo 调内置命令,无环 + tree normal_macro= tuple (compound ( + "assign", "foo", + compound ("macro", "x", compound ("bold", compound ("arg", "x"))))); + QVERIFY (!has_macro_cycle (normal_macro)); + + // 5. 普通文档无宏定义 + tree no_macro= compound ("document", "Hello world"); + QVERIFY (!has_macro_cycle (no_macro)); +} + QTEST_MAIN (TestParseTex) #include "parsetex_test.moc"