Zsh Mailing List Archive
Messages sorted by: Reverse Date, Date, Thread, Author

[PATCH] zle: prevent signal trap re-entrancy during zrefresh



From: Phil Hord <phil.hord@xxxxxxxxx>

When asynchronous signal traps (e.g., TRAPALRM, TRAPUSR1, or background
job completions) invoke 'zle reset-prompt' while zrefresh() is already
executing, recursive invocation of zrefresh() / resetvideo() reallocates
and frees video line buffers (nbuf / obuf) mid-render. This causes memory
corruption and double-free segfaults in glibc free() within freevideo().

Protect zrefresh() against signal trap dispatch by wrapping the refresh
cycle with queue_signals() and unqueue_signals().

Add Test/X06zlereentrancy.ztst and Test/test_zlereentrancy.py regression
tests to stress concurrent window resizing, signal trap delivery, and
interactive key input. This test reliably causes zsh to crash for me on
zsh 5.9.0 on Ubuntu 24.4. This specific test may be overkill for
continued testing going forward, however.

I do not subscribe to the mailing list, so please copy me on any replies.
---
 Src/Zle/zle_refresh.c      |   4 ++
 Test/X06zlereentrancy.ztst |  21 ++++++
 Test/test_zlereentrancy.py | 130 +++++++++++++++++++++++++++++++++++++
 3 files changed, 155 insertions(+)
 create mode 100644 Test/X06zlereentrancy.ztst
 create mode 100644 Test/test_zlereentrancy.py

diff --git a/Src/Zle/zle_refresh.c b/Src/Zle/zle_refresh.c
index 9e70921b9..35621f267 100644
--- a/Src/Zle/zle_refresh.c
+++ b/Src/Zle/zle_refresh.c
@@ -1058,6 +1058,8 @@ zrefresh(void)
     if (inlist)
 	return;

+    queue_signals();
+
     /*
      * zrefresh() is called from all over the place, so we can't
      * be sure if the line is metafied for completion or not.
@@ -1828,6 +1830,8 @@ singlelineout:

     if (remetafy)
 	metafy_line();
+
+    unqueue_signals();
 }

 #define tcinscost(X)   (tccan(TCMULTINS) ? tclen[TCMULTINS] : (X)*tclen[TCINS])
diff --git a/Test/X06zlereentrancy.ztst b/Test/X06zlereentrancy.ztst
new file mode 100644
index 000000000..bde9a8f3b
--- /dev/null
+++ b/Test/X06zlereentrancy.ztst
@@ -0,0 +1,21 @@
+# Tests for signal re-entrancy during ZLE refresh and buffer reallocation.
+#
+# Background:
+# When ZLE is redrawing or resizing the terminal, resetvideo() allocates and
+# frees multi-line video buffers (nbuf/obuf). If an asynchronous signal trap
+# (e.g. TRAPUSR1 or TRAPALRM) runs 'zle reset-prompt' during an ongoing
+# refresh, re-entering zrefresh() / resetvideo() causes memory corruption
+# and double-free in glibc free().
+#
+# The helper script test_zlereentrancy.py drives a PTY session with
+# concurrent TIOCSWINSZ window resizing and SIGUSR1 trap delivery.
+
+%prep
+  if ! (( $+commands[python3] )); then
+    ZTST_unimplemented="python3 is not available"
+  fi
+
+%test
+
+  python3 $ZTST_srcdir/test_zlereentrancy.py "${ZTST_exe:-../Src/zsh}"
+0:Signal re-entrancy during ZLE refresh does not crash
diff --git a/Test/test_zlereentrancy.py b/Test/test_zlereentrancy.py
new file mode 100644
index 000000000..56fa56436
--- /dev/null
+++ b/Test/test_zlereentrancy.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""
+ZLE Signal Re-entrancy Reproduction Harness
+
+Tests Zsh line editor (ZLE) against asynchronous signal re-entrancy
+during screen refresh and video buffer reallocation.
+"""
+import fcntl
+import os
+import pty
+import random
+import signal
+import struct
+import subprocess
+import sys
+import termios
+import threading
+import time
+
+def run_test(rep, iterations, zsh_bin):
+    master, slave = pty.openpty()
+    proc = subprocess.Popen(
+        [zsh_bin, "-f", "-i"],
+        stdin=slave,
+        stdout=slave,
+        stderr=slave,
+        preexec_fn=os.setsid,
+        close_fds=True,
+    )
+    os.close(slave)
+
+    # Only point module_path to in-tree Test/Modules if testing an in-tree build (Src/zsh).
+    # For an installed zsh (like /bin/zsh or 'zsh'), never load local in-tree modules.
+    setup_lines = []
+    bin_dir = os.path.dirname(zsh_bin)
+    if "Src" in bin_dir or os.path.exists(os.path.join(bin_dir, "zsh.h")):
+        repo_modules = os.path.abspath(os.path.join(bin_dir, "../Test/Modules"))
+        setup_lines.append(f'[[ -d "{repo_modules}" ]] && module_path=("{repo_modules}" $module_path)')
+
+    setup_lines.extend([
+        "zmodload zsh/zle",
+        "TRAPUSR1() { zle reset-prompt; }",
+        'PROMPT="> "',
+    ])
+    setup = ("\n".join(setup_lines) + "\n").encode()
+    os.write(master, setup)
+    time.sleep(0.2)
+
+    stop_event = threading.Event()
+
+    def resizer():
+        w = 0
+        while not stop_event.is_set():
+            w = (w + 11) % 400
+            try:
+                fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 280+w, 0, 0))
+                os.kill(proc.pid, signal.SIGWINCH)
+            except OSError:
+                break
+            time.sleep(random.uniform(0.00005, 0.0002))
+
+    def signaller():
+        while not stop_event.is_set():
+            try:
+                os.kill(proc.pid, signal.SIGUSR1)
+            except OSError:
+                break
+            time.sleep(random.uniform(0.00005, 0.00025))
+
+    t_resizer = threading.Thread(target=resizer, daemon=True)
+    t_signaller = threading.Thread(target=signaller, daemon=True)
+    t_resizer.start()
+    t_signaller.start()
+
+    crashed = False
+    crash_iter = 0
+
+    for i in range(iterations):
+        if proc.poll() is not None:
+            crashed = True
+            crash_iter = i
+            break
+        try:
+            os.write(master, b"a\x08")
+        except OSError:
+            break
+        time.sleep(random.uniform(0.0001, 0.0003))
+
+    stop_event.set()
+    t_resizer.join(timeout=0.5)
+    t_signaller.join(timeout=0.5)
+    if not crashed:
+        time.sleep(0.05)
+        if proc.poll() is not None:
+            crashed = True
+            crash_iter = iterations
+
+    try:
+        os.close(master)
+    except OSError:
+        pass
+
+    if proc.poll() is None:
+        try:
+            proc.kill()
+        except OSError:
+            pass
+
+    if crashed:
+        sig = -proc.returncode if proc.returncode < 0 else None
+        if sig:
+            signame = signal.Signals(sig).name if hasattr(signal, "Signals") else str(sig)
+            msg = f"crashed with signal {sig} ({signame})"
+        else:
+            # Unexpected error exit without crashing
+            msg =f"exited with status {proc.returncode}"
+        sys.stderr.write(f"ZLE process {msg} at pass {rep} iteration {crash_iter}\n")
+        sys.exit(1)
+
+    return
+
+def main():
+    zsh_bin = sys.argv[1] if len(sys.argv) > 1 else "zsh"
+    for i in range(4):
+        run_test(i, 1000, zsh_bin)
+
+    sys.exit(0)
+
+if __name__ == "__main__":
+    main()
--
2.51.0.491.g4b71b29477




Messages sorted by: Reverse Date, Date, Thread, Author