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) {
metrics.setFont(family, size);
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 -------------------------------------------------------------
@@ -123,8 +123,9 @@ public final class Compositor {
return tabs.isEmpty();
}
/** The active pane of the current tab, or {@code null} when no tab is left. */
public TerminalPane activePane() {
return currentTab().activePane();
return isEmpty() ? null : currentTab().activePane();
}
public void navigate(Direction direction) {
@@ -173,10 +174,7 @@ public final class Compositor {
}
public void closeActivePane() {
if (isEmpty()) {
return;
}
TerminalPane active = currentTab().activePane();
TerminalPane active = activePane();
if (active != null) {
closePane(active);
}
@@ -214,7 +212,8 @@ public final class Compositor {
public void newTab() {
// 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.
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));
currentTabIndex = tabs.size() - 1;
layoutVersion++;
@@ -454,11 +453,11 @@ public final class Compositor {
private void handleMouseReleased(MouseEvent event) {
TerminalPane pane = paneAt(event.getX(), event.getY());
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;
MouseTarget target = mouseTarget(pane);
MouseTarget target = pane == null ? null : mouseTarget(pane);
if (target != null) {
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) {
TerminalPane pane = paneAt(event.getX(), event.getY());
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;

View File

@@ -9,6 +9,7 @@ import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
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.
*/
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() {
}
@@ -31,9 +37,9 @@ public final class Daemon {
Path socket = socketPath();
try {
Files.createDirectories(socket.getParent());
trySecureDir(socket.getParent());
secureDir(socket.getParent());
} 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;
}
@@ -107,14 +113,22 @@ public final class Daemon {
manager.openWindow(workingDirectory == null || workingDirectory.isBlank()
? null
: workingDirectory.trim());
connection.configureBlocking(true);
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 {
channel.configureBlocking(false);
long deadline = System.nanoTime() + READ_TIMEOUT_NANOS;
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteBuffer buffer = ByteBuffer.allocate(4096);
while (channel.read(buffer) != -1) {
while (true) {
int n = channel.read(buffer);
if (n > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
byte b = buffer.get();
@@ -122,10 +136,25 @@ public final class Daemon {
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");
}
}
}
}
private static Path socketPath() {
@@ -136,11 +165,19 @@ public final class Daemon {
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 {
Files.setPosixFilePermissions(dir, PosixFilePermissions.fromString("rwx------"));
} catch (IOException | UnsupportedOperationException ignored) {
// Best effort: XDG_RUNTIME_DIR is already user-private; the /tmp fallback we try to lock.
} catch (UnsupportedOperationException ignored) {
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) {
}
// 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 Map<String, Glyph> glyphs = new HashMap<>();
// 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) {
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

View File

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

View File

@@ -317,6 +317,10 @@ public final class LinuxPty implements AutoCloseable {
}
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);
callInt(CLOSE, masterFd);
}

View File

@@ -22,8 +22,11 @@ final class 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) {
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
* from the render loop every frame (it only ever touches FX-thread state).
*/
static void firstFrame() {
static synchronized void firstFrame() {
if (!ENABLED || firstFrameSeen) {
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.
*/
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 TerminalMetrics metrics;
// 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);
}
double floatingWidth = Math.max(420, width * 0.58);
double floatingHeight = Math.max(260, availHeight * 0.58);
double floatingWidth = Math.max(FLOATING_MIN_WIDTH, width * FLOATING_SIZE_FRACTION);
double floatingHeight = Math.max(FLOATING_MIN_HEIGHT, availHeight * FLOATING_SIZE_FRACTION);
for (int i = 0; i < floating.size(); i++) {
double offset = i * 28.0;
double offset = i * FLOATING_CASCADE_OFFSET;
floating.get(i).bounds(
Math.min(width - floatingWidth - 12.0, ((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - 12.0, topInset + ((availHeight - floatingHeight) / 2.0) + offset),
Math.min(width - floatingWidth - FLOATING_EDGE_MARGIN,
((width - floatingWidth) / 2.0) + offset),
Math.min(height - floatingHeight - FLOATING_EDGE_MARGIN,
topInset + ((availHeight - floatingHeight) / 2.0) + offset),
floatingWidth,
floatingHeight);
}
@@ -384,7 +394,9 @@ final class Tab implements AutoCloseable {
private double[] paneSize(boolean asFloating) {
double availHeight = lastHeight - lastTopInset;
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.
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.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
/**
* 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,
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, config.closeSignalNumber()));
attachOrShowError(pane, () -> ShellSession.start(config.shell(), config.envOverride(), pane,
pane.columns, pane.rows, workingDirectory, config.closeSignalNumber()));
return pane;
}
@@ -93,11 +94,23 @@ public final class TerminalPane implements AutoCloseable, RenderTarget {
public static TerminalPane createWithCommand(AppConfig config, TerminalMetrics metrics, Runnable onContentChange,
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, config.closeSignalNumber()));
attachOrShowError(pane, () -> ShellSession.startCommand(config.envOverride(), pane,
pane.columns, pane.rows, workingDirectory, command, config.closeSignalNumber()));
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,
double widthPx, double heightPx) {
int columns = widthPx > 0 ? metrics.columnsFor(widthPx) : config.columns();

View File

@@ -138,8 +138,7 @@ final class TerminalWindow {
}
String encoded = KeyEncoder.encode(event);
if (encoded != null) {
compositor.activePane().send(encoded);
event.consume();
sendToActivePane(encoded, event);
}
}
@@ -150,15 +149,25 @@ final class TerminalWindow {
String text = event.getCharacter();
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();
}
}
private void pasteFromClipboard() {
TerminalPane active = compositor.activePane();
Clipboard clipboard = Clipboard.getSystemClipboard();
if (clipboard.hasString()) {
compositor.activePane().paste(clipboard.getString());
if (active != null && clipboard.hasString()) {
active.paste(clipboard.getString());
}
}
@@ -206,17 +215,22 @@ final class TerminalWindow {
}
private void openScrollbackInEditor() {
try {
// 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 {
Path file = Files.createTempFile("jprototerm-scrollback-", ".txt");
Files.writeString(file, compositor.activePane().scrollbackText());
file.toFile().deleteOnExit();
Files.writeString(file, active.scrollbackText());
// 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
// — no shell startup/rc race — and the pane auto-closes when the editor exits.
compositor.openFloatingPane(scrollbackEditorCommand(file));
// — no shell startup/rc race — and the pane auto-closes when the editor exits. The
// 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) {
System.err.println("Could not open scrollback in editor: " + ex.getMessage());
}