diff --git a/config.example.toml b/config.example.toml index 2b965d1..9eab560 100644 --- a/config.example.toml +++ b/config.example.toml @@ -3,6 +3,9 @@ columns = 100 rows = 30 max_scrollback = 100000 shell = "/bin/bash" +# Signal sent to a pane's shell process when the pane is closed (e.g. ALT+X). +# Use SIGKILL to force-kill instead of asking the shell to terminate. +close_signal = "SIGTERM" font_family = "JetBrainsMono Nerd Font" font_size = 15 diff --git a/src/main/java/com/gregor/jprototerm/AppConfig.java b/src/main/java/com/gregor/jprototerm/AppConfig.java index a20a00c..9936b87 100644 --- a/src/main/java/com/gregor/jprototerm/AppConfig.java +++ b/src/main/java/com/gregor/jprototerm/AppConfig.java @@ -29,6 +29,7 @@ public record AppConfig( double windowHeight, boolean kittyGraphics, String scrollbackEditorCommand, + String closeSignal, Map envOverride, Map keybindings ) { @@ -71,6 +72,7 @@ public record AppConfig( doubleValue(document, "window.height", defaults.windowHeight), booleanValue(document, "kitty_graphics.enabled", defaults.kittyGraphics), stringValue(document, "scrollback.editor_command", defaults.scrollbackEditorCommand), + closeSignalValue(document, defaults.closeSignal), envOverride(document, defaults.envOverride), keybindings(document, defaults) ); @@ -92,6 +94,7 @@ public record AppConfig( 760.0, true, defaultScrollbackEditorCommand(), + "SIGTERM", Map.of(), Map.ofEntries( Map.entry("navigate_left", KeyBinding.parse("ALT+H")), @@ -125,11 +128,22 @@ public record AppConfig( windowHeight, kittyGraphics, scrollbackEditorCommand, + closeSignal, envOverride, keybindings ); } + /** + * The {@link #closeSignal} as a Linux signal number, sent to a pane's shell process when the + * pane is closed (e.g. via the close-pane key). Falls back to SIGTERM (15) if the configured + * name is somehow unresolvable. + */ + public int closeSignalNumber() { + int number = LinuxPty.signalNumber(closeSignal); + return number < 0 ? 15 : number; + } + public void save() { save(configPath(), this); } @@ -156,6 +170,23 @@ public record AppConfig( return editor.trim() + " {file}"; } + /** + * Reads {@code terminal.close_signal}, normalising it to a canonical {@code SIG*} name. An + * unknown or unset value keeps {@code fallback} so a typo can't leave a pane unkillable. + */ + private static String closeSignalValue(TomlTable table, String fallback) { + String value = stringValue(table, "terminal.close_signal", null); + if (value == null) { + return fallback; + } + if (LinuxPty.signalNumber(value) < 0) { + System.err.println("Unknown terminal.close_signal '" + value + "', using " + fallback); + return fallback; + } + String normalized = value.trim().toUpperCase(java.util.Locale.ROOT); + return normalized.startsWith("SIG") ? normalized : "SIG" + normalized; + } + private static Map keybindings(TomlTable table, AppConfig defaults) { Map parsed = new LinkedHashMap<>(); for (String key : KEYBINDING_KEYS) { @@ -193,6 +224,7 @@ public record AppConfig( builder.append("rows = ").append(rows).append('\n'); builder.append("max_scrollback = ").append(maxScrollback).append('\n'); builder.append("shell = ").append(quotedList(shell)).append('\n'); + builder.append("close_signal = ").append(quoted(closeSignal)).append('\n'); builder.append("font_family = ").append(quoted(fontFamily)).append('\n'); builder.append("font_size = ").append(trimDouble(fontSize)).append("\n\n"); builder.append("[window]\n"); diff --git a/src/main/java/com/gregor/jprototerm/LinuxPty.java b/src/main/java/com/gregor/jprototerm/LinuxPty.java index e9dd2a9..02f3637 100644 --- a/src/main/java/com/gregor/jprototerm/LinuxPty.java +++ b/src/main/java/com/gregor/jprototerm/LinuxPty.java @@ -63,7 +63,10 @@ public final class LinuxPty implements AutoCloseable { private static final long TIOCSWINSZ = 0x5414L; private static final short POSIX_SPAWN_SETSID = 0x80; private static final int SIGHUP = 1; + private static final int SIGINT = 2; + private static final int SIGQUIT = 3; private static final int SIGKILL = 9; + private static final int SIGTERM = 15; private static final int WNOHANG = 1; // struct winsize { unsigned short ws_row, ws_col, ws_xpixel, ws_ypixel; } @@ -105,11 +108,36 @@ public final class LinuxPty implements AutoCloseable { private final Object writeLock = new Object(); private final int masterFd; private final int pid; + private final int closeSignal; private volatile boolean closed; - private LinuxPty(int masterFd, int pid) { + private LinuxPty(int masterFd, int pid, int closeSignal) { this.masterFd = masterFd; this.pid = pid; + this.closeSignal = closeSignal; + } + + /** + * Resolves a signal name (e.g. {@code "SIGTERM"}, {@code "TERM"}, {@code "SIGKILL"}) to its + * Linux signal number, or {@code -1} if the name is not one we recognise. Case-insensitive and + * tolerant of a missing {@code SIG} prefix. + */ + public static int signalNumber(String name) { + if (name == null) { + return -1; + } + String normalized = name.trim().toUpperCase(java.util.Locale.ROOT); + if (normalized.startsWith("SIG")) { + normalized = normalized.substring(3); + } + return switch (normalized) { + case "HUP" -> SIGHUP; + case "INT" -> SIGINT; + case "QUIT" -> SIGQUIT; + case "KILL" -> SIGKILL; + case "TERM" -> SIGTERM; + default -> -1; + }; } /** @@ -118,8 +146,10 @@ public final class LinuxPty implements AutoCloseable { * @param argv command and arguments (e.g. {@code {"/bin/zsh", "-i"}}) * @param environment environment for the child, as KEY=VALUE pairs * @param workingDirectory directory the child starts in, or {@code null} to inherit + * @param closeSignal signal number sent to the child on {@link #close()} (e.g. SIGTERM) */ - public static LinuxPty spawn(String[] argv, Map environment, String workingDirectory) { + public static LinuxPty spawn(String[] argv, Map environment, String workingDirectory, + int closeSignal) { Arena setup = Arena.ofConfined(); try { int master = check(callInt(POSIX_OPENPT, O_RDWR | O_NOCTTY), "posix_openpt"); @@ -158,7 +188,7 @@ public final class LinuxPty implements AutoCloseable { if (rc != 0) { throw new IllegalStateException("posix_spawnp failed for " + argv[0] + " (rc=" + rc + ")"); } - return new LinuxPty(master, pidOut.get(C_INT, 0)); + return new LinuxPty(master, pidOut.get(C_INT, 0), closeSignal); } finally { callInt(ATTR_DESTROY, attr); callInt(FA_DESTROY, actions); @@ -249,7 +279,7 @@ public final class LinuxPty implements AutoCloseable { return; } closed = true; - callKill(pid, SIGHUP); + callKill(pid, closeSignal); callInt(CLOSE, masterFd); reap(); arena.close(); diff --git a/src/main/java/com/gregor/jprototerm/ShellSession.java b/src/main/java/com/gregor/jprototerm/ShellSession.java index 0885a73..263de60 100644 --- a/src/main/java/com/gregor/jprototerm/ShellSession.java +++ b/src/main/java/com/gregor/jprototerm/ShellSession.java @@ -27,9 +27,10 @@ public final class ShellSession implements AutoCloseable { * config, not assumed here. */ public static ShellSession start(List shellCommand, Map envOverride, TerminalPane pane, - int columns, int rows, String workingDirectory) { + int columns, int rows, String workingDirectory, int closeSignal) { try { - return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory); + return spawn(shellCommand.toArray(new String[0]), envOverride, columns, rows, workingDirectory, + closeSignal); } catch (RuntimeException ex) { pane.write("failed to start shell: " + ex.getMessage() + "\r\n"); throw new IllegalStateException("Could not start shell " + String.join(" ", shellCommand), ex); @@ -45,9 +46,10 @@ public final class ShellSession implements AutoCloseable { * flags. {@code command} must not be null. */ public static ShellSession startCommand(Map envOverride, TerminalPane pane, - int columns, int rows, String workingDirectory, String command) { + int columns, int rows, String workingDirectory, String command, int closeSignal) { try { - return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory); + return spawn(new String[] {"/bin/sh", "-c", command}, envOverride, columns, rows, workingDirectory, + closeSignal); } catch (RuntimeException ex) { pane.write("failed to run command: " + ex.getMessage() + "\r\n"); throw new IllegalStateException("Could not run command: " + command, ex); @@ -55,7 +57,7 @@ public final class ShellSession implements AutoCloseable { } private static ShellSession spawn(String[] argv, Map envOverride, - int columns, int rows, String workingDirectory) { + int columns, int rows, String workingDirectory, int closeSignal) { Map environment = new HashMap<>(System.getenv()); environment.put("TERM", "xterm-kitty"); environment.put("COLORTERM", "truecolor"); @@ -65,7 +67,8 @@ public final class ShellSession implements AutoCloseable { LinuxPty pty = LinuxPty.spawn( argv, environment, - workingDirectory != null ? workingDirectory : System.getProperty("user.home")); + workingDirectory != null ? workingDirectory : System.getProperty("user.home"), + closeSignal); ShellSession session = new ShellSession(pty); session.resize(columns, rows); return session; diff --git a/src/main/java/com/gregor/jprototerm/TerminalPane.java b/src/main/java/com/gregor/jprototerm/TerminalPane.java index 360b283..8224da8 100644 --- a/src/main/java/com/gregor/jprototerm/TerminalPane.java +++ b/src/main/java/com/gregor/jprototerm/TerminalPane.java @@ -81,7 +81,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { double widthPx, double heightPx, String workingDirectory) { TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx); pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows, - workingDirectory)); + workingDirectory, config.closeSignalNumber())); return pane; } @@ -94,7 +94,7 @@ public final class TerminalPane implements AutoCloseable, RenderTarget { double widthPx, double heightPx, String workingDirectory, String command) { TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx); pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows, - workingDirectory, command)); + workingDirectory, command, config.closeSignalNumber())); return pane; }