summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 13:08:29 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commitd51e1cd8d7344738ca04578beb8731ab72235a07 (patch)
tree107f768d96b98c0e625e7ead07f6b3d38e655152
parentdc37b4ea777b7eb881a993e737457c18699c5b2c (diff)
downloadpardes-d51e1cd8d7344738ca04578beb8731ab72235a07.tar.gz
pardes-d51e1cd8d7344738ca04578beb8731ab72235a07.zip
A tty editor killed with SIGTERM or SIGHUP puts its terminal back before it dies
A nested pardes killed in a pane died with the default action. That left the pane's terminal on the alternate screen, in raw mode, with mouse and paste reporting on, so the shell under it was unusable until a reset. The tty frontend now handles both signals: it writes the resets a clean exit writes (keyboard protocol, mouse modes, focus, bracketed paste, SGR, cursor, main screen) and restores the cooked termios, all async-signal-safe. Then it dies of the same signal, since SA_RESETHAND put the default back. fs.py kills a tty session with each signal and checks for the main-screen switch and cooked mode; it fails without the handler. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/tty/tty.zig36
-rw-r--r--test/fs.py42
2 files changed, 78 insertions, 0 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index ef7f2761..1d7ff415 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1083,6 +1083,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
defer vx.exitAltScreen(tty.writer()) catch {};
try vx.setMouseMode(tty.writer(), true);
try vx.setBracketedPaste(tty.writer(), true);
+ Killed.arm(tty.fd.handle, tty.termios);
+ defer Killed.disarm();
if (attach != null) {
attach_end = attachSession(init, attach_name, &tty, &vx);
@@ -1119,6 +1121,40 @@ pub fn run(init: std.process.Init, opts: pardes.Options, attach: ?[]const u8) !v
}
}
+/// SIGTERM or SIGHUP: the terminal put back as a clean exit leaves it --
+/// the main screen, no mouse, paste or keyboard modes, the cursor shown,
+/// cooked termios -- and then the signal's own death. A nested editor
+/// killed in a pane must not leave that pane's terminal wedged.
+/// Async-signal-safe: write, tcsetattr, sigaction, raise.
+const Killed = struct {
+ var fd: posix.fd_t = -1;
+ var cooked: posix.termios = undefined;
+ const reset = "\x1b[<u\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1004l\x1b[?2004l\x1b[0m\x1b[?25h\x1b[?1049l";
+ const signals = [_]posix.SIG{ .TERM, .HUP };
+
+ fn arm(tty_fd: posix.fd_t, termios: posix.termios) void {
+ cooked = termios;
+ fd = tty_fd;
+ const on: posix.Sigaction = .{ .handler = .{ .handler = handle }, .mask = posix.sigemptyset(), .flags = posix.SA.RESETHAND };
+ for (signals) |sig| posix.sigaction(sig, &on, null);
+ }
+
+ fn disarm() void {
+ fd = -1;
+ const default: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 };
+ for (signals) |sig| posix.sigaction(sig, &default, null);
+ }
+
+ fn handle(sig: posix.SIG) callconv(.c) void {
+ if (fd >= 0) {
+ _ = std.c.write(fd, reset, reset.len);
+ _ = std.c.tcsetattr(fd, .FLUSH, &cooked);
+ }
+ // SA_RESETHAND put the default back: the same signal, now fatal.
+ _ = std.c.raise(sig);
+ }
+};
+
fn localSession(
init: std.process.Init,
opts: pardes.Options,
diff --git a/test/fs.py b/test/fs.py
index 98f46900..10802e19 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -4,6 +4,7 @@ import fcntl
import json
import os
import shutil
+import select
import signal
from pathlib import Path
import subprocess
@@ -1329,6 +1330,47 @@ def test(binary, quic=False):
assert orphan.poll() is None
assert orphan.wait(timeout=5) == 1
+ # Killed with SIGTERM or SIGHUP, a tty editor puts its terminal
+ # back (main screen, cooked mode) before it dies: a nested one
+ # killed in a pane leaves that pane usable.
+ for sig in (signal.SIGTERM, signal.SIGHUP):
+ master, slave = os.openpty()
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0))
+ cooked = termios.tcgetattr(slave)
+ env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')}
+ env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), TERM='xterm-256color', PARDES_NOTIME='1')
+ killed = subprocess.Popen([binary, '--tty', '--9p=killed', 'x.txt'], cwd=root, env=env,
+ stdin=slave, stdout=slave, stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ preexec_fn=lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0))
+ seen = b''
+ try:
+ deadline = time.monotonic() + 10
+ while b'\x1b[?1049h' not in seen:
+ assert time.monotonic() < deadline, seen[-200:]
+ if select.select([master], [], [], .1)[0]:
+ seen += os.read(master, 65536)
+ time.sleep(.3)
+ killed.send_signal(sig)
+ assert killed.wait(timeout=5) == -sig
+ while select.select([master], [], [], .2)[0]:
+ try:
+ chunk = os.read(master, 65536)
+ except OSError:
+ break
+ if not chunk:
+ break
+ seen += chunk
+ tail = seen[seen.rindex(b'\x1b[?1049h'):]
+ assert b'\x1b[?1049l' in tail, tail[-200:]
+ assert termios.tcgetattr(slave)[3] & termios.ICANON == cooked[3] & termios.ICANON
+ finally:
+ if killed.poll() is None:
+ killed.kill()
+ killed.wait()
+ os.close(master)
+ os.close(slave)
+
# A launch with no terminal to draw on (a pty that is no one's
# controlling terminal) says so and exits 1, no error trace.
master, slave = os.openpty()