diff --git a/TeXmacs/tests/python/1294.py b/TeXmacs/tests/python/1294.py new file mode 100755 index 0000000000..ba66d92e2e --- /dev/null +++ b/TeXmacs/tests/python/1294.py @@ -0,0 +1,397 @@ +#!/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 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 +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 + + 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: + 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: + 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 < 2.5: + time.sleep(0.2) + t_wait += 0.2 + if not self.is_alive(): + crashed = True + print(" [!] Crashed during continue editing!") + 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_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/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/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 new file mode 100644 index 0000000000..6b0c61fbe8 --- /dev/null +++ b/devel/1294.md @@ -0,0 +1,106 @@ +# 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`(包含用例 249、289 与 pattern_c)。 +- 新增自动化 GUI 回归测试脚本 `TeXmacs/tests/python/1294.py`。 +- 落实「排版引擎」+「LaTeX 转换」双层防御: + 1. 排版引擎层:`concater_rep::typeset_compound` 与 `typeset_auto` 增加宏展开深度限制,超限降级为占位 box,防止任何宏递归引起栈溢出崩溃。 + 2. LaTeX 转换层:`has_macro_cycle` 环路检测,识别自递归或相互递归宏定义并降级返回空,避免向用户文档插入需要手动删除的损坏内容。 + +## Why(根因定位过程) + +用例 249(`\def\crashMacro24{\crashMacro24} \crashMacro24`)与用例 289(`\def\paramRec28#1{\paramRec28{#1}#1} \paramRec28{x}`) +在通过「编辑 → 粘贴自 → LaTeX」粘贴入文档后触发崩溃,通过 addr2line 与 coredump 定位真实根因: + +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/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。 + +## 涉及文件 + +- `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 与 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 粘贴回归测试脚本) + +## 测试 + +```bash +# 1. C++ 单元与回归测试 +xmake b parsetex_test && xmake r parsetex_test +xmake r 0620 +xmake r 0631 + +# 2. GUI 自动化测试(使用 pynput 驱动真实 GUI) +python3 TeXmacs/tests/python/1294.py +# 预期:1294_1.tex、1294_2.tex 和 1294_3.tex 均 PASS, +# 覆盖完整流程:粘贴 -> 关闭错误弹窗 -> 继续键入/分段/删除/光标移动编辑 -> 进程均不崩溃退出 +``` + +### 手动测试方法 + +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),已用未含修复的基线构建复验, +与本任务改动无关。 + +## 备注 + +- 粘贴完整链路:`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 现象消失)由用户执行。 + +## 迭代改进记录 + +- 针对 `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/Graphics/Fonts/find_font.cpp b/src/Graphics/Fonts/find_font.cpp index 4e01acb20c..60e869fd9a 100644 --- a/src/Graphics/Fonts/find_font.cpp +++ b/src/Graphics/Fonts/find_font.cpp @@ -198,12 +198,29 @@ 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; + // 规则驱动的递归转换缺乏终止保证,字体规则成环时会无限递归直到栈溢出, + // 这里限制递归深度,超限按"字体未找到"降级 + 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_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 774c2a70af..b28f0eb927 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,93 @@ latex_to_tree (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; +} + +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 { + 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, ""); + } +} + tree latex_document_to_tree (string s, bool as_pic) { tree r; 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/src/Typeset/Concat/concat_macro.cpp b/src/Typeset/Concat/concat_macro.cpp index 06d7ccbfaf..b1c4d4a6ae 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,30 @@ 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) { - int d; - tree f; + if (macro_depth_exceeded (t, 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 +189,8 @@ concater_rep::typeset_compound (tree t, path ip) { void concater_rep::typeset_auto (tree t, path ip, tree f) { + if (macro_depth_exceeded (t, 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/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/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 new file mode 100644 index 0000000000..d19b1ff0ac --- /dev/null +++ b/tests/Plugins/Tex/parsetex_test.cpp @@ -0,0 +1,116 @@ +/****************************************************************************** + * 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 "file.hpp" +#include "server.hpp" +#include "tm_sys_utils.hpp" +#include + +extern s7_pointer user_env; + +class TestParseTex : public QObject { + Q_OBJECT + +private: + server* sv; + // latex_document_to_tree 是粘贴路径的完整转换入口; + // 复现代码与手动测试共用 TeXmacs/tests/tex/ 下的样本 + void check_crash_case (const char* file); + +private slots: + void initTestCase (); + void test_crash_case_249 (); + void test_crash_case_289 (); + void test_crash_case_1294_3 (); + void test_has_macro_cycle (); +}; + +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::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 () { + // 无参自递归宏定义 + check_crash_case ("1294_1.tex"); +} + +void +TestParseTex::test_crash_case_289 () { + // 带参自递归宏定义 + check_crash_case ("1294_2.tex"); +} + +void +TestParseTex::test_crash_case_1294_3 () { + // 相互递归宏定义 (crash_pattern_c) + 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"