some guard rails

This commit is contained in:
2026-06-12 11:59:25 +02:00
parent 8954482222
commit 08fcb93e01
9 changed files with 157 additions and 52 deletions

View File

@@ -114,7 +114,7 @@ public final class Compositor {
public void setFont(String family, double size) { public void setFont(String family, double size) {
metrics.setFont(family, size); metrics.setFont(family, size);
paneContentVersion.clear(); paneContentVersion.clear();
lastWidth = -1.0; // force a redraw on the next frame layoutVersion++; // recomposite with the new metrics on the next frame
} }
// ---- Tabs and panes ------------------------------------------------------------- // ---- Tabs and panes -------------------------------------------------------------
@@ -123,8 +123,9 @@ public final class Compositor {
return tabs.isEmpty(); return tabs.isEmpty();
} }
/** The active pane of the current tab, or {@code null} when no tab is left. */
public TerminalPane activePane() { public TerminalPane activePane() {
return currentTab().activePane(); return isEmpty() ? null : currentTab().activePane();
} }
public void navigate(Direction direction) { public void navigate(Direction direction) {
@@ -173,10 +174,7 @@ public final class Compositor {
} }
public void closeActivePane() { public void closeActivePane() {
if (isEmpty()) { TerminalPane active = activePane();
return;
}
TerminalPane active = currentTab().activePane();
if (active != null) { if (active != null) {
closePane(active); closePane(active);
} }
@@ -214,7 +212,8 @@ public final class Compositor {
public void newTab() { public void newTab() {
// Open the new tab in the currently active pane's working directory, so it lands where the // Open the new tab in the currently active pane's working directory, so it lands where the
// user currently is rather than always in home. // user currently is rather than always in home.
String workingDirectory = isEmpty() ? null : currentTab().activePane().currentWorkingDirectory(); TerminalPane active = activePane();
String workingDirectory = active != null ? active.currentWorkingDirectory() : null;
tabs.add(new Tab(config, metrics, workingDirectory, this::closePane)); tabs.add(new Tab(config, metrics, workingDirectory, this::closePane));
currentTabIndex = tabs.size() - 1; currentTabIndex = tabs.size() - 1;
layoutVersion++; layoutVersion++;
@@ -454,11 +453,11 @@ public final class Compositor {
private void handleMouseReleased(MouseEvent event) { private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY()); TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) { if (pane == null) {
pane = activePane(); pane = activePane(); // released outside every pane (e.g. mid-drag): route to the active one
} }
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;
MouseTarget target = mouseTarget(pane); MouseTarget target = pane == null ? null : mouseTarget(pane);
if (target != null) { if (target != null) {
send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event); send(pane, target, MouseInput.release(button, localX(event.getX(), pane, target), localY(event.getY(), pane, target), modifiers(event)), false, event);
} }
@@ -469,7 +468,10 @@ public final class Compositor {
private void handleMouseDragged(MouseEvent event) { private void handleMouseDragged(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY()); TerminalPane pane = paneAt(event.getX(), event.getY());
if (pane == null) { if (pane == null) {
pane = activePane(); pane = activePane(); // dragged outside every pane: route to the active one
}
if (pane == null) {
return;
} }
MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton; MouseButton button = pressedButton == MouseButton.UNKNOWN ? mouseButton(event) : pressedButton;

View File

@@ -9,6 +9,7 @@ import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel; import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions; import java.nio.file.attribute.PosixFilePermissions;
@@ -23,6 +24,11 @@ import java.nio.file.attribute.PosixFilePermissions;
* (mode 0700), so only the owning user can connect. * (mode 0700), so only the owning user can connect.
*/ */
public final class Daemon { public final class Daemon {
// One request is a single line holding a filesystem path; anything bigger is bogus.
private static final int MAX_REQUEST_BYTES = 4096;
// The accept loop is single-threaded, so a client that stalls must not wedge the daemon.
private static final long READ_TIMEOUT_NANOS = 5_000_000_000L;
private Daemon() { private Daemon() {
} }
@@ -31,9 +37,9 @@ public final class Daemon {
Path socket = socketPath(); Path socket = socketPath();
try { try {
Files.createDirectories(socket.getParent()); Files.createDirectories(socket.getParent());
trySecureDir(socket.getParent()); secureDir(socket.getParent());
} catch (IOException ex) { } catch (IOException ex) {
System.err.println("jprototerm: cannot create socket dir " + socket.getParent() + ": " + ex.getMessage()); System.err.println("jprototerm: cannot secure socket dir " + socket.getParent() + ": " + ex.getMessage());
return; return;
} }
@@ -107,25 +113,48 @@ public final class Daemon {
manager.openWindow(workingDirectory == null || workingDirectory.isBlank() manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
? null ? null
: workingDirectory.trim()); : workingDirectory.trim());
connection.configureBlocking(true);
connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8))); connection.write(ByteBuffer.wrap("OK\n".getBytes(StandardCharsets.UTF_8)));
} }
} }
// Reads the request line non-blocking with a deadline and a size cap: the accept loop is
// single-threaded, so a client that stalls or never sends a newline must fail the connection
// (an IOException logged by run()) rather than wedge the daemon or grow the buffer unbounded.
private static String readLine(SocketChannel channel) throws IOException { private static String readLine(SocketChannel channel) throws IOException {
channel.configureBlocking(false);
long deadline = System.nanoTime() + READ_TIMEOUT_NANOS;
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteBuffer buffer = ByteBuffer.allocate(4096); ByteBuffer buffer = ByteBuffer.allocate(4096);
while (channel.read(buffer) != -1) { while (true) {
buffer.flip(); int n = channel.read(buffer);
while (buffer.hasRemaining()) { if (n > 0) {
byte b = buffer.get(); buffer.flip();
if (b == '\n') { while (buffer.hasRemaining()) {
return out.toString(StandardCharsets.UTF_8); byte b = buffer.get();
if (b == '\n') {
return out.toString(StandardCharsets.UTF_8);
}
out.write(b);
if (out.size() > MAX_REQUEST_BYTES) {
throw new IOException("request line too long");
}
}
buffer.clear();
} else if (n == -1) {
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
} else {
if (System.nanoTime() >= deadline) {
throw new IOException("request timed out");
}
try {
Thread.sleep(5);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while reading request");
} }
out.write(b);
} }
buffer.clear();
} }
return out.size() == 0 ? null : out.toString(StandardCharsets.UTF_8);
} }
private static Path socketPath() { private static Path socketPath() {
@@ -136,11 +165,19 @@ public final class Daemon {
return dir.resolve("daemon.sock"); return dir.resolve("daemon.sock");
} }
private static void trySecureDir(Path dir) { // Make the socket dir private, and refuse to use it if it is not ours. The /tmp fallback
// path is predictable, so another user could have pre-created it (the classic /tmp race);
// binding a socket inside a directory someone else owns would hand them control of it.
private static void secureDir(Path dir) throws IOException {
try { try {
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------")); Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
} catch (IOException | UnsupportedOperationException ignored) { } catch (UnsupportedOperationException ignored) {
// Best effort: XDG_RUNTIME_DIR is already user-private; the /tmp fallback we try to lock. return; // not a POSIX filesystem: nothing more we can check
}
String owner = Files.getOwner(dir, LinkOption.NOFOLLOW_LINKS).getName();
String user = System.getProperty("user.name");
if (!owner.equals(user)) {
throw new IOException(dir + " is owned by '" + owner + "', not '" + user + "'");
} }
} }
} }

View File

@@ -29,6 +29,11 @@ final class GlyphCache {
record Glyph(int width, int height, byte[] alpha) { record Glyph(int width, int height, byte[] alpha) {
} }
// Bounds the atlas so pathological glyph diversity (e.g. a dump of distinct CJK/emoji cells)
// can't grow it without limit; on overflow it clears and rebuilds on demand, like any
// metrics change.
private static final int MAX_GLYPHS = 4096;
private final TerminalMetrics metrics; private final TerminalMetrics metrics;
private final Map<String, Glyph> glyphs = new HashMap<>(); private final Map<String, Glyph> glyphs = new HashMap<>();
// The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache. // The metrics snapshot the cached glyphs were rasterized for; a mismatch clears the cache.
@@ -43,7 +48,16 @@ final class GlyphCache {
Glyph glyph(String text) { Glyph glyph(String text) {
ensureCurrent(); ensureCurrent();
return glyphs.computeIfAbsent(text, this::renderGlyph); Glyph cached = glyphs.get(text);
if (cached != null) {
return cached;
}
if (glyphs.size() >= MAX_GLYPHS) {
glyphs.clear();
}
Glyph rendered = renderGlyph(text);
glyphs.put(text, rendered);
return rendered;
} }
// Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to // Drop the rasterized masks if the font/cell geometry changed since they were built. Cheap to

View File

@@ -5,17 +5,19 @@ import javafx.scene.input.KeyEvent;
import java.util.Locale; import java.util.Locale;
public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode code) { public record KeyBinding(boolean alt, boolean control, boolean shift, boolean meta, KeyCode code) {
public static KeyBinding parse(String value) { public static KeyBinding parse(String value) {
boolean alt = false; boolean alt = false;
boolean control = false; boolean control = false;
boolean shift = false; boolean shift = false;
boolean meta = false;
KeyCode code = null; KeyCode code = null;
for (String part : value.split("\\+")) { for (String part : value.split("\\+")) {
String token = part.trim().toUpperCase(Locale.ROOT); String token = part.trim().toUpperCase(Locale.ROOT);
switch (token) { switch (token) {
case "ALT", "META" -> alt = true; case "ALT" -> alt = true;
case "META", "SUPER" -> meta = true;
case "CTRL", "CONTROL" -> control = true; case "CTRL", "CONTROL" -> control = true;
case "SHIFT" -> shift = true; case "SHIFT" -> shift = true;
default -> code = keyCode(token); default -> code = keyCode(token);
@@ -25,13 +27,14 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
if (code == null) { if (code == null) {
throw new IllegalArgumentException("Key binding has no key code: " + value); throw new IllegalArgumentException("Key binding has no key code: " + value);
} }
return new KeyBinding(alt, control, shift, code); return new KeyBinding(alt, control, shift, meta, code);
} }
public boolean matches(KeyEvent event) { public boolean matches(KeyEvent event) {
return event.isAltDown() == alt return event.isAltDown() == alt
&& event.isControlDown() == control && event.isControlDown() == control
&& event.isShiftDown() == shift && event.isShiftDown() == shift
&& event.isMetaDown() == meta
&& event.getCode() == code; && event.getCode() == code;
} }
@@ -44,6 +47,9 @@ public record KeyBinding(boolean alt, boolean control, boolean shift, KeyCode co
if (alt) { if (alt) {
builder.append("ALT+"); builder.append("ALT+");
} }
if (meta) {
builder.append("META+");
}
if (shift) { if (shift) {
builder.append("SHIFT+"); builder.append("SHIFT+");
} }

View File

@@ -317,6 +317,10 @@ public final class LinuxPty implements AutoCloseable {
} }
private void closeMaster() { private void closeMaster() {
// Note: closing the master fd does NOT wake a reader thread blocked in read() on it —
// the reader unblocks via EOF when the child exits and the slave end closes. The signal
// here usually does that; if the child ignores it, the SIGKILL escalation in reap()
// guarantees it shortly after.
callKill(pid, closeSignal); callKill(pid, closeSignal);
callInt(CLOSE, masterFd); callInt(CLOSE, masterFd);
} }

View File

@@ -22,8 +22,11 @@ final class StartupTiming {
private StartupTiming() { private StartupTiming() {
} }
/** Records a phase boundary, printing the delta since the previous mark and since JVM start. */ /**
static void mark(String phase) { * Records a phase boundary, printing the delta since the previous mark and since JVM start.
* Synchronized because marks come from both the launcher thread and the FX thread.
*/
static synchronized void mark(String phase) {
if (!ENABLED) { if (!ENABLED) {
return; return;
} }
@@ -38,7 +41,7 @@ final class StartupTiming {
* Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call * Records the first rendered frame exactly once, then becomes a no-op. Safe and cheap to call
* from the render loop every frame (it only ever touches FX-thread state). * from the render loop every frame (it only ever touches FX-thread state).
*/ */
static void firstFrame() { static synchronized void firstFrame() {
if (!ENABLED || firstFrameSeen) { if (!ENABLED || firstFrameSeen) {
return; return;
} }

View File

@@ -18,6 +18,14 @@ import java.util.stream.Stream;
* return whether they actually changed anything so it can bump its layout version. * return whether they actually changed anything so it can bump its layout version.
*/ */
final class Tab implements AutoCloseable { final class Tab implements AutoCloseable {
// Floating-pane sizing policy: a fraction of the tab's size with a floor so panes stay
// usable in small windows, cascaded diagonally per pane, kept off the window edge.
private static final double FLOATING_SIZE_FRACTION = 0.58;
private static final double FLOATING_MIN_WIDTH = 420.0;
private static final double FLOATING_MIN_HEIGHT = 260.0;
private static final double FLOATING_CASCADE_OFFSET = 28.0;
private static final double FLOATING_EDGE_MARGIN = 12.0;
private final AppConfig config; private final AppConfig config;
private final TerminalMetrics metrics; private final TerminalMetrics metrics;
// Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the // Notified (on the FX thread) when one of this tab's panes' process exits on its own, so the
@@ -142,13 +150,15 @@ final class Tab implements AutoCloseable {
tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight); tiled.get(i).bounds(i * tileWidth, topInset, tileWidth, availHeight);
} }
double floatingWidth = Math.max(420, width * 0.58); double floatingWidth = Math.max(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
double floatingHeight = Math.max(260, availHeight * 0.58); double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
for (int i = 0; i < floating.size(); i++) { for (int i = 0; i < floating.size(); i++) {
double offset = i * 28.0; double offset = i * FLOATING_CASCADE_OFFSET;
floating.get(i).bounds( floating.get(i).bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset), Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset), ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth, floatingWidth,
floatingHeight); floatingHeight);
} }
@@ -384,7 +394,9 @@ final class Tab implements AutoCloseable {
private double[] paneSize(boolean asFloating) { private double[] paneSize(boolean asFloating) {
double availHeight = lastHeight - lastTopInset; double availHeight = lastHeight - lastTopInset;
if (asFloating) { if (asFloating) {
return new double[] {Math.max(420, lastWidth * 0.58), Math.max(260, availHeight * 0.58)}; return new double[] {
Math.max(FLOATING_MIN_WIDTH, lastWidth * FLOATING_SIZE_FRACTION),
Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION)};
} }
// A new tiled pane joins the row, so each gets 1/(n+1) of the width. // A new tiled pane joins the row, so each gets 1/(n+1) of the width.
return new double[] {lastWidth / (tiled.size() + 1), availHeight}; return new double[] {lastWidth / (tiled.size() + 1), availHeight};

View File

@@ -18,6 +18,7 @@ import javafx.scene.shape.Shape;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
/** /**
* One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it, * One terminal: owns its ghostty {@link Terminal}, the {@link ShellSession}/pty driving it,
@@ -80,8 +81,8 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, public static TerminalPane create(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory) { double widthPx, double heightPx, String workingDirectory) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx); TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.start(config.shell(), config.envOverride(), pane, pane.columns, pane.rows, attachOrShowError(pane, () -> ShellSession.start(config.shell(), config.envOverride(), pane,
workingDirectory, config.closeSignalNumber())); pane.columns, pane.rows, workingDirectory, config.closeSignalNumber()));
return pane; return pane;
} }
@@ -93,11 +94,23 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx, String workingDirectory, String command) { double widthPx, double heightPx, String workingDirectory, String command) {
TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx); TerminalPane pane = newPane(config, metrics, onContentChange, widthPx, heightPx);
pane.attach(ShellSession.startCommand(config.envOverride(), pane, pane.columns, pane.rows, attachOrShowError(pane, () -> ShellSession.startCommand(config.envOverride(), pane,
workingDirectory, command, config.closeSignalNumber())); pane.columns, pane.rows, workingDirectory, command, config.closeSignalNumber()));
return pane; return pane;
} }
// Start the pane's process, but never let a spawn failure (e.g. a bad `shell` in config)
// propagate and crash window/pane creation. ShellSession has already written the error into
// the pane, so the pane opens showing it; with no session attached it is inert (sends are
// dropped) and the user closes it with the close-pane key.
private static void attachOrShowError(TerminalPane pane, Supplier<ShellSession> start) {
try {
pane.attach(start.get());
} catch (RuntimeException ex) {
System.err.println("jprototerm: " + ex.getMessage());
}
}
private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange, private static TerminalPane newPane(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
double widthPx, double heightPx) { double widthPx, double heightPx) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns(); int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();

View File

@@ -138,8 +138,7 @@ final class TerminalWindow {
} }
String encoded = KeyEncoder.encode(event); String encoded = KeyEncoder.encode(event);
if (encoded != null) { if (encoded != null) {
compositor.activePane().send(encoded); sendToActivePane(encoded, event);
event.consume();
} }
} }
@@ -150,15 +149,25 @@ final class TerminalWindow {
String text = event.getCharacter(); String text = event.getCharacter();
if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) { if (text != null && !text.isEmpty() && text.charAt(0) >= 0x20 && text.charAt(0) != 0x7f) {
compositor.activePane().send(text); sendToActivePane(text, event);
}
}
// Key handlers run on every keystroke, including any that race the window's teardown, so
// tolerate the no-pane-left state instead of assuming one exists.
private void sendToActivePane(String text, KeyEvent event) {
TerminalPane active = compositor.activePane();
if (active != null) {
active.send(text);
event.consume(); event.consume();
} }
} }
private void pasteFromClipboard() { private void pasteFromClipboard() {
TerminalPane active = compositor.activePane();
Clipboard clipboard = Clipboard.getSystemClipboard(); Clipboard clipboard = Clipboard.getSystemClipboard();
if (clipboard.hasString()) { if (active != null && clipboard.hasString()) {
compositor.activePane().paste(clipboard.getString()); active.paste(clipboard.getString());
} }
} }
@@ -206,17 +215,22 @@ final class TerminalWindow {
} }
private void openScrollbackInEditor() { private void openScrollbackInEditor() {
// Capture the active pane's scrollback before opening the floating pane, since that
// makes the new pane active.
TerminalPane active = compositor.activePane();
if (active == null) {
return;
}
try { try {
// Capture the active pane's scrollback before opening the floating pane, since that
// makes the new pane active.
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt"); Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText()); Files.writeString(file, active.scrollbackText());
file.toFile().deleteOnExit();
// Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the // Run the editor as the floating pane's process (via /bin/sh -c) rather than typing the
// command into an interactive shell. The command runs deterministically from the start // command into an interactive shell. The command runs deterministically from the start
// — no shell startup/rc race — and the pane auto-closes when the editor exits. // — no shell startup/rc race — and the pane auto-closes when the editor exits. The
compositor.openFloatingPane(scrollbackEditorCommand(file)); // trailing rm removes the file (which holds terminal contents) when the editor exits;
// deleteOnExit would leak files for the JVM's whole lifetime in daemon mode.
compositor.openFloatingPane(scrollbackEditorCommand(file) + "; rm -f " + shellQuote(file.toString()));
} catch (IOException ex) { } catch (IOException ex) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage()); System.err.println("Could not open scrollback in editor: " + ex.getMessage());
} }