Skip to content

Commit 2a829f3

Browse files
da-liiiclaude
andauthored
[1294] 修复自递归/循环宏 LaTeX 粘贴导致进程崩溃(用例 249/289/pattern_c) (#4535)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 66cb97f commit 2a829f3

13 files changed

Lines changed: 762 additions & 7 deletions

File tree

TeXmacs/tests/python/1294.py

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""1294.py: 复现与验证 LaTeX 自递归宏粘贴崩溃的自动化测试脚本。
4+
5+
支持用例:
6+
- 1294_1.tex: 无参自递归宏 (用例 249)
7+
- 1294_2.tex: 带参自递归宏 (用例 289)
8+
- 1294_3.tex: 相互递归宏 (crash_pattern_c)
9+
10+
启动命令: xmake r stem -d
11+
用法:
12+
python3 TeXmacs/tests/python/1294.py # 默认依序测试 1294_1.tex, 1294_2.tex, 1294_3.tex
13+
python3 TeXmacs/tests/python/1294.py [path/to.tex] # 测试指定文件
14+
"""
15+
16+
import os
17+
import sys
18+
import glob
19+
import time
20+
import subprocess
21+
import threading
22+
23+
from Xlib import X, display, Xatom
24+
from Xlib.protocol import event
25+
from pynput.keyboard import Controller as Kbd, Key
26+
from pynput.mouse import Controller as Mouse, Button
27+
28+
# 定位工程根目录
29+
_cur = os.path.dirname(os.path.abspath(__file__))
30+
while _cur and _cur != "/" and not os.path.isdir(os.path.join(_cur, "TeXmacs")):
31+
_cur = os.path.dirname(_cur)
32+
ROOT_DIR = _cur if _cur else os.path.dirname(os.path.abspath(__file__))
33+
HERE = ROOT_DIR
34+
35+
# 4K (3840x2312) 实测坐标
36+
POS_DOC = (960, 900)
37+
POS_EDIT_MENU = (220, 140)
38+
POS_PASTE_FROM = (227, 1008)
39+
POS_LATEX_ITEM = (618, 1135)
40+
41+
DRAFT_DIRS = [
42+
os.path.expanduser("~/文档/LiiiSTEM/no_name"),
43+
os.path.expanduser("~/Documents/LiiiSTEM/no_name"),
44+
]
45+
46+
DEFAULT_CASES = [
47+
os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_1.tex"),
48+
os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_2.tex"),
49+
os.path.join(ROOT_DIR, "TeXmacs", "tests", "tex", "1294_3.tex"),
50+
]
51+
52+
53+
class X11Clipboard:
54+
"""X11 剪贴板持有者,在后台响应 SelectionRequest。"""
55+
56+
def __init__(self):
57+
self.disp = display.Display()
58+
self.screen = self.disp.screen()
59+
self.window = self.screen.root.create_window(
60+
0, 0, 1, 1, 0, self.screen.root_depth
61+
)
62+
self.CLIPBOARD = self.disp.intern_atom('CLIPBOARD')
63+
self.PRIMARY = self.disp.intern_atom('PRIMARY')
64+
self.TARGETS = self.disp.intern_atom('TARGETS')
65+
self.UTF8 = self.disp.intern_atom('UTF8_STRING')
66+
self.TEXT = self.disp.intern_atom('TEXT')
67+
self.STRING = Xatom.STRING
68+
self.text = ""
69+
self.running = True
70+
self.thread = threading.Thread(target=self._loop, daemon=True)
71+
self.thread.start()
72+
73+
def set_text(self, text):
74+
self.text = text
75+
self.window.set_selection_owner(self.CLIPBOARD, X.CurrentTime)
76+
self.window.set_selection_owner(self.PRIMARY, X.CurrentTime)
77+
self.disp.flush()
78+
79+
def _loop(self):
80+
while self.running:
81+
try:
82+
if self.disp.pending_events() == 0:
83+
time.sleep(0.01)
84+
continue
85+
e = self.disp.next_event()
86+
if e.type == X.SelectionRequest:
87+
req_win = self.disp.create_resource_object('window', e.requestor)
88+
prop = e.property
89+
if prop == X.NONE:
90+
prop = e.target
91+
if e.target == self.TARGETS:
92+
req_win.change_property(
93+
prop, Xatom.ATOM, 32,
94+
[self.TARGETS, self.UTF8, self.STRING, self.TEXT]
95+
)
96+
ev = event.SelectionNotify(
97+
time=e.time, requestor=e.requestor,
98+
selection=e.selection, target=e.target,
99+
property=prop
100+
)
101+
elif e.target in (self.UTF8, self.STRING, self.TEXT):
102+
req_win.change_property(
103+
prop, e.target, 8, self.text.encode('utf-8')
104+
)
105+
ev = event.SelectionNotify(
106+
time=e.time, requestor=e.requestor,
107+
selection=e.selection, target=e.target,
108+
property=prop
109+
)
110+
else:
111+
ev = event.SelectionNotify(
112+
time=e.time, requestor=e.requestor,
113+
selection=e.selection, target=e.target,
114+
property=X.NONE
115+
)
116+
req_win.send_event(ev)
117+
self.disp.flush()
118+
except Exception:
119+
pass
120+
121+
def stop(self):
122+
self.running = False
123+
124+
125+
def clean_drafts():
126+
"""清理自动保存草稿,防止弹窗阻碍启动。"""
127+
for d in DRAFT_DIRS:
128+
if os.path.isdir(d):
129+
for f in glob.glob(os.path.join(d, "draft_*.tmu")):
130+
try:
131+
os.remove(f)
132+
except OSError:
133+
pass
134+
135+
136+
def activate_window():
137+
"""激活 Mogan STEM 窗口。"""
138+
js_code = (
139+
'const wins = workspace.windowList ? workspace.windowList() : workspace.windows();\n'
140+
'for (let i = 0; i < wins.length; i++) {\n'
141+
' const w = wins[i];\n'
142+
' const cls = (w.resourceClass || "").toLowerCase();\n'
143+
' const name = (w.resourceName || "").toLowerCase();\n'
144+
' const title = (w.caption || "");\n'
145+
' if (cls.includes("stem") || name.includes("stem") || title.includes("Mogan")) {\n'
146+
' workspace.activeWindow = w;\n'
147+
' if (w.activate) w.activate();\n'
148+
' }\n'
149+
'}\n'
150+
)
151+
tmp_js = "/tmp/activate_moganstem.js"
152+
with open(tmp_js, "w", encoding="utf-8") as f:
153+
f.write(js_code)
154+
155+
try:
156+
import dbus
157+
bus = dbus.SessionBus()
158+
kwin = bus.get_object("org.kde.KWin", "/Scripting")
159+
iface = dbus.Interface(kwin, "org.kde.kwin.Scripting")
160+
script_id = iface.loadScript(tmp_js)
161+
script_obj = bus.get_object("org.kde.KWin", f"/Scripting/Script{script_id}")
162+
script_iface = dbus.Interface(script_obj, "org.kde.kwin.Script")
163+
script_iface.run()
164+
iface.unloadScript(str(script_id))
165+
except Exception:
166+
pass
167+
168+
try:
169+
d = display.Display()
170+
root = d.screen().root
171+
NET_ACTIVE_WINDOW = d.intern_atom('_NET_ACTIVE_WINDOW')
172+
NET_CLIENT_LIST = d.intern_atom('_NET_CLIENT_LIST')
173+
prop = root.get_full_property(NET_CLIENT_LIST, Xatom.WINDOW)
174+
if prop:
175+
for wid in prop.value:
176+
win = d.create_resource_object('window', wid)
177+
c = str(win.get_wm_class() or "").lower()
178+
if "stem" in c or "mogan" in c:
179+
data = [2, X.CurrentTime, 0, 0, 0]
180+
ev = event.ClientMessage(window=wid, client_type=NET_ACTIVE_WINDOW, data=(32, data))
181+
root.send_event(ev, event_mask=X.SubstructureRedirectMask | X.SubstructureNotifyMask)
182+
d.flush()
183+
break
184+
except Exception:
185+
pass
186+
187+
188+
def is_window_ready():
189+
try:
190+
out = subprocess.check_output(
191+
["xprop", "-root", "_NET_CLIENT_LIST"],
192+
text=True, stderr=subprocess.DEVNULL
193+
)
194+
for wid in out.split("#")[-1].replace(",", " ").split():
195+
c = subprocess.check_output(
196+
["xprop", "-id", wid, "WM_CLASS"],
197+
text=True, stderr=subprocess.DEVNULL
198+
)
199+
if "moganstem" in c.lower() or "liiistem" in c.lower():
200+
return True
201+
except Exception:
202+
pass
203+
return False
204+
205+
206+
class TestRunner:
207+
def __init__(self):
208+
self.proc = None
209+
self.clip = X11Clipboard()
210+
self.kbd = Kbd()
211+
self.mouse = Mouse()
212+
213+
def start_app(self):
214+
self.stop_app()
215+
clean_drafts()
216+
subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL)
217+
218+
print("[1294] Starting Mogan STEM via: xmake r stem -d")
219+
self.proc = subprocess.Popen(["xmake", "r", "stem", "-d"], cwd=HERE)
220+
221+
t0 = time.time()
222+
while time.time() - t0 < 15:
223+
if is_window_ready():
224+
break
225+
time.sleep(0.3)
226+
227+
time.sleep(1.0)
228+
activate_window()
229+
time.sleep(0.5)
230+
231+
def is_alive(self):
232+
if self.proc is None:
233+
return False
234+
return self.proc.poll() is None
235+
236+
def stop_app(self):
237+
if self.proc is not None:
238+
if self.proc.poll() is None:
239+
self.proc.terminate()
240+
try:
241+
self.proc.wait(timeout=2)
242+
except Exception:
243+
self.proc.kill()
244+
self.proc = None
245+
subprocess.run(["pkill", "-9", "-f", "moganstem"], stderr=subprocess.DEVNULL)
246+
clean_drafts()
247+
248+
def run_case(self, case_name, tex_content):
249+
"""运行单个用例的粘贴测试。"""
250+
if not self.is_alive():
251+
self.start_app()
252+
253+
print(f"\n--- Running: {case_name} ---")
254+
print(f"Content: {tex_content.strip()}")
255+
256+
# 新建标签页并聚焦
257+
activate_window()
258+
time.sleep(0.3)
259+
self.kbd.press(Key.ctrl)
260+
self.kbd.tap('t')
261+
self.kbd.release(Key.ctrl)
262+
time.sleep(0.8)
263+
264+
self.mouse.position = POS_DOC
265+
time.sleep(0.2)
266+
self.mouse.click(Button.left)
267+
time.sleep(0.4)
268+
269+
# 设置剪贴板并粘贴
270+
self.clip.set_text(tex_content)
271+
activate_window()
272+
time.sleep(0.2)
273+
274+
print(" -> Clicking: Edit -> Paste from -> LaTeX")
275+
self.mouse.position = POS_EDIT_MENU
276+
time.sleep(0.3)
277+
self.mouse.click(Button.left)
278+
time.sleep(0.4)
279+
280+
self.mouse.position = POS_PASTE_FROM
281+
time.sleep(0.4)
282+
283+
self.mouse.position = POS_LATEX_ITEM
284+
time.sleep(0.3)
285+
self.mouse.click(Button.left)
286+
287+
# 检查粘贴阶段是否崩溃
288+
t_wait = 0.0
289+
crashed = False
290+
while t_wait < 3.5:
291+
time.sleep(0.2)
292+
t_wait += 0.2
293+
if not self.is_alive():
294+
crashed = True
295+
print(" [!] Crashed during paste!")
296+
break
297+
298+
# 处理可能的错误弹窗并关闭
299+
if not crashed:
300+
time.sleep(0.5)
301+
print(" -> Closing possible error popup (Enter)...")
302+
self.kbd.tap(Key.enter)
303+
time.sleep(0.5)
304+
if not self.is_alive():
305+
crashed = True
306+
print(" [!] Crashed after dismissing popup!")
307+
308+
# 关闭弹窗后,继续编辑测试(防止损坏状态下继续操作引起崩溃)
309+
if not crashed:
310+
print(" -> Testing continue editing after paste...")
311+
activate_window()
312+
time.sleep(0.3)
313+
self.mouse.position = POS_DOC
314+
time.sleep(0.2)
315+
self.mouse.click(Button.left)
316+
time.sleep(0.3)
317+
318+
# 键入文本
319+
self.kbd.type("testing edit after paste ")
320+
time.sleep(0.3)
321+
# 回车触发分段排版
322+
self.kbd.tap(Key.enter)
323+
time.sleep(0.3)
324+
# 键入更多内容
325+
self.kbd.type("continue typing 12345")
326+
time.sleep(0.3)
327+
# 回车
328+
self.kbd.tap(Key.enter)
329+
time.sleep(0.3)
330+
# 退格删除
331+
for _ in range(8):
332+
self.kbd.tap(Key.backspace)
333+
time.sleep(0.05)
334+
# 光标移动
335+
self.kbd.tap(Key.up)
336+
time.sleep(0.1)
337+
self.kbd.tap(Key.down)
338+
time.sleep(0.1)
339+
self.kbd.type(" finished")
340+
time.sleep(0.5)
341+
342+
# 等待排版和可能的后台处理
343+
t_wait = 0.0
344+
while t_wait < 2.5:
345+
time.sleep(0.2)
346+
t_wait += 0.2
347+
if not self.is_alive():
348+
crashed = True
349+
print(" [!] Crashed during continue editing!")
350+
break
351+
352+
status = "CRASH" if crashed else "PASS"
353+
print(f" Result for {case_name}: {status}")
354+
return not crashed
355+
356+
def close(self):
357+
self.clip.stop()
358+
self.stop_app()
359+
360+
361+
def main():
362+
if len(sys.argv) > 1:
363+
test_files = sys.argv[1:]
364+
else:
365+
test_files = [f for f in DEFAULT_CASES if os.path.exists(f)]
366+
if not test_files:
367+
print("[!] Default test cases not found in TeXmacs/tests/tex/!")
368+
sys.exit(1)
369+
370+
runner = TestRunner()
371+
results = {}
372+
373+
try:
374+
for tf in test_files:
375+
name = os.path.basename(tf)
376+
with open(tf, "r", encoding="utf-8") as f:
377+
content = f.read().strip()
378+
passed = runner.run_case(name, content)
379+
results[name] = "PASS" if passed else "CRASH"
380+
381+
print("\n" + "=" * 50)
382+
print("TEST SUMMARY:")
383+
print("=" * 50)
384+
all_passed = True
385+
for name, status in results.items():
386+
print(f" {name:20s}: {status}")
387+
if status != "PASS":
388+
all_passed = False
389+
390+
sys.exit(0 if all_passed else 1)
391+
392+
finally:
393+
runner.close()
394+
395+
396+
if __name__ == "__main__":
397+
main()

TeXmacs/tests/tex/1294_1.tex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
\def\crashMacro24{\crashMacro24} \crashMacro24 % 自递归宏定义,触发粘贴 LaTeX 崩溃 (用例 249, devel/1294.md)

0 commit comments

Comments
 (0)