From 06ee14735ee684f31db195c86765d6e4999f0c71 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:31:18 -0500 Subject: [PATCH 01/77] Transport(fix[lifecycle]): Bound control and process IO why: Process and control streams could block indefinitely, lose dispatch certainty, or leak workers and descendants during timeout and close races. what: - Bound admission, output, deadlines, and process reclamation - Parse control replies on dedicated workers with bounded subscriptions - Cover interruption, close barriers, malformed bytes, and stream loss --- docs/guide/streaming.md | 40 +- .../libtmux/examples/WatchPaneOutput.java | 25 +- .../libtmux/examples/WatchWhatChanges.java | 25 +- .../it/ControlModeIntegrationTest.java | 111 +++-- .../it/ControlWatchIntegrationTest.java | 107 +++-- .../io/github/libtmux/it/ExamplesTest.java | 67 +-- .../github/libtmux/control/ControlClient.java | 394 +++++++++++------ .../libtmux/control/ControlLineReader.java | 59 +++ .../libtmux/control/ControlProtocol.java | 115 +++++ .../github/libtmux/control/ControlWriter.java | 353 +++++++++++++++ .../libtmux/control/EventSubscription.java | 152 +++++++ .../libtmux/internal/CommandStrings.java | 37 ++ .../java/io/github/libtmux/internal/Utf8.java | 51 +++ .../libtmux/transport/OutputDecoder.java | 44 +- .../libtmux/transport/ProcessTransport.java | 389 ++++++++++++++--- .../transport/TmuxTimeoutException.java | 18 + .../transport/TmuxTransportException.java | 2 +- .../libtmux/control/ControlClientTest.java | 224 +++++++++- .../control/ControlLineReaderTest.java | 39 ++ .../libtmux/control/ControlProtocolTest.java | 58 +++ .../libtmux/control/ControlWriterTest.java | 275 ++++++++++++ .../control/EventSubscriptionTest.java | 175 ++++++++ .../transport/ProcessTransportTest.java | 413 +++++++++++++++++- 23 files changed, 2751 insertions(+), 422 deletions(-) create mode 100644 libtmux/src/main/java/io/github/libtmux/control/ControlLineReader.java create mode 100644 libtmux/src/main/java/io/github/libtmux/control/ControlProtocol.java create mode 100644 libtmux/src/main/java/io/github/libtmux/control/ControlWriter.java create mode 100644 libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java create mode 100644 libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java create mode 100644 libtmux/src/main/java/io/github/libtmux/internal/Utf8.java create mode 100644 libtmux/src/main/java/io/github/libtmux/transport/TmuxTimeoutException.java create mode 100644 libtmux/src/test/java/io/github/libtmux/control/ControlLineReaderTest.java create mode 100644 libtmux/src/test/java/io/github/libtmux/control/ControlProtocolTest.java create mode 100644 libtmux/src/test/java/io/github/libtmux/control/ControlWriterTest.java create mode 100644 libtmux/src/test/java/io/github/libtmux/control/EventSubscriptionTest.java diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index af0af4d..fba2cb9 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -6,42 +6,26 @@ A control client stays attached and pushes terminal output as tmux produces it, rather than being asked: ```java -try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - List seen = new CopyOnWriteArrayList<>(); - client.onOutput(seen::add); +try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription output = client.subscribeOutput(32)) { client.send("send-keys", "-t", session.name(), "echo streamed", "Enter"); - // Listeners run on the reader thread; a slow one delays every reply. - client.isAlive(); // → true + PaneOutput arrived = output.next(Duration.ofSeconds(5)).orElseThrow(); + arrived.data().contains("streamed"); // → true } ``` Attaching is what makes tmux push at all. A control client that never attaches sees no output, however long it waits. -## Requests stay independent +Each subscriber chooses a fixed buffer capacity. A full buffer drops its oldest +value, and `droppedCount()` reports the exact loss. The control reader only fills +those buffers; caller code runs on the thread that calls `next()`. -Control-mode requests do not queue behind each other's failures: a failure -discards nothing behind it, and every reply carries the request that produced it. +## Requests are serialized -That matters more than it sounds. tmux frames a reply *per command*, so a line -holding two commands answers with two blocks — which is why a command group is -carried by a process even under [`CONTROL`](execution-modes.md), rather than -being sent down a client that expects one block per request. - -## Streaming versus carrying - -These are two different uses of the same tmux feature: - -| you want | use | -| ------------------------------- | -------------------------------------------- | -| output pushed to you as it happens | `ControlClient.attach(…)` and `onOutput` | -| every command to cost less | `ExecutionMode.CONTROL` on the config | - -The first is a client you drive. The second is a carrier the library drives for -you, and it changes nothing about what your calls return — see -[execution modes](execution-modes.md). - -They compose: a server in `CONTROL` mode and a `ControlClient` for streaming are -separate connections, and neither disturbs the other. +A control client has one reply stream, so `send` calls run one at a time. A +timeout closes the client because the next reply can no longer be attributed +safely. Use `Server` for ordinary commands; use `ControlClient` when the +persistent event stream is the requirement. diff --git a/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java b/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java index c969532..30fc8e0 100644 --- a/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java +++ b/examples/src/main/java/io/github/libtmux/examples/WatchPaneOutput.java @@ -5,11 +5,12 @@ import io.github.libtmux.ServerEndpoint; import io.github.libtmux.Session; import io.github.libtmux.control.ControlClient; +import io.github.libtmux.control.EventSubscription; import io.github.libtmux.control.PaneOutput; import java.nio.file.Path; import java.time.Duration; +import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; /** @@ -38,22 +39,30 @@ public static List run(Path socket, Duration watchFor, Consumer seen = new CopyOnWriteArrayList<>(); + List seen = new ArrayList<>(); try (Server server = Server.open(config)) { Session session = server.sessions().get(0); // Attaching is what makes tmux push %output at all. A client that never attaches hears // about command replies and nothing else. - try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - client.onOutput(output -> { - seen.add(output); - onOutput.accept(output); - }); + try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription output = client.subscribeOutput(32)) { client.send("send-keys", "-t", session.name(), "echo watched", "Enter"); long deadline = System.nanoTime() + watchFor.toNanos(); while (System.nanoTime() < deadline && seen.isEmpty()) { - Thread.onSpinWait(); + try { + var next = output.next(Duration.ofNanos(Math.max(0L, deadline - System.nanoTime()))); + if (next.isEmpty()) { + break; + } + PaneOutput arrived = next.orElseThrow(); + seen.add(arrived); + onOutput.accept(arrived); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } } } } diff --git a/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java b/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java index efd225c..382f2d6 100644 --- a/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java +++ b/examples/src/main/java/io/github/libtmux/examples/WatchWhatChanges.java @@ -6,10 +6,11 @@ import io.github.libtmux.Session; import io.github.libtmux.control.ControlClient; import io.github.libtmux.control.ControlEvent; +import io.github.libtmux.control.EventSubscription; import java.nio.file.Path; import java.time.Duration; +import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; /** @@ -42,15 +43,12 @@ public static List run(Path socket, Duration watchFor, Consumer seen = new CopyOnWriteArrayList<>(); + List seen = new ArrayList<>(); try (Server server = Server.open(config)) { Session session = server.sessions().get(0); - try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - client.onEvent(event -> { - seen.add(event); - onChange.accept(event); - }); + try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription events = client.subscribeEvents(32)) { // Every window's name, reported whenever one of them changes. The comparison happens // inside tmux; this client is idle until something is different. @@ -60,7 +58,18 @@ public static List run(Path socket, Duration watchFor, Consumer seen = new CopyOnWriteArrayList<>(); - client.onOutput(seen::add); + try (ControlClient client = attach(server); + EventSubscription output = client.subscribeOutput(32)) { client.send("send-keys", "-t", "libtmux", "echo control-mode-saw-this", "Enter"); assertTrue( - await(() -> seen.stream().anyMatch(output -> output.data().contains("control-mode-saw-this"))), + awaitOutput(output, "control-mode-saw-this"), "attaching is what makes tmux push output, and it did not arrive"); } } - /** - * Listeners run on the reader thread, which is also the only thread that resolves replies. A - * listener that threw would end it, and the client would then answer nothing at all — every - * later request timing out for a reason belonging to somebody else's callback. - */ @Test - void aListenerThatThrowsDoesNotTakeTheClientDownWithIt(Server server) throws Exception { - try (ControlClient client = attach(server)) { - List seen = new CopyOnWriteArrayList<>(); - client.onOutput(output -> { - throw new IllegalStateException("this listener is broken"); - }); - client.onOutput(seen::add); + void anIdleSubscriberDoesNotDelayAnotherSubscriberOrReplies(Server server) throws Exception { + try (ControlClient client = attach(server); + EventSubscription idle = client.subscribeOutput(1); + EventSubscription active = client.subscribeOutput(32)) { + assertFalse(idle.isClosed()); - client.send("send-keys", "-t", "libtmux", "echo listener-survived-this", "Enter"); + client.send("send-keys", "-t", "libtmux", "echo active-subscriber-saw-this", "Enter"); assertTrue( - await(() -> seen.stream().anyMatch(output -> output.data().contains("listener-survived-this"))), - "a listener registered after the broken one still has to be told"); + awaitOutput(active, "active-subscriber-saw-this"), + "the idle subscriber delayed delivery to the active one"); assertEquals( List.of("still answering"), client.send("display-message", "-p", "still answering").lines(), - "the reader survived, so replies still arrive"); + "the idle subscriber delayed command replies"); + } + } + + @Test + void aConsumerCanSendACommandFromItsOwnThread(Server server) throws Exception { + try (ControlClient client = attach(server); + EventSubscription output = client.subscribeOutput(32)) { + FutureTask reentrant = new FutureTask<>(() -> { + if (!awaitOutput(output, "consumer-can-send")) { + throw new IllegalStateException("the triggering output never arrived"); + } + return client.send("display-message", "-p", "sent-from-consumer"); + }); + Thread consumer = Thread.ofVirtual().start(reentrant); + + client.send("send-keys", "-t", "libtmux", "echo consumer-can-send", "Enter"); + + assertEquals( + List.of("sent-from-consumer"), + reentrant.get(10, TimeUnit.SECONDS).lines()); + consumer.join(); } } @@ -204,6 +222,31 @@ void closeIsIdempotent(Server server) { client.close(); } + @Test + void closingTheClientWakesAWaitingSubscriber(Server server) throws Exception { + ControlClient client = attach(server); + EventSubscription output = client.subscribeOutput(1); + CountDownLatch entered = new CountDownLatch(1); + FutureTask> waiting = new FutureTask<>(() -> { + entered.countDown(); + return output.next(); + }); + Thread consumer = Thread.ofVirtual().start(waiting); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> waiting.get(100, TimeUnit.MILLISECONDS)); + + client.close(); + + assertEquals(Optional.empty(), waiting.get(1, TimeUnit.SECONDS)); + } finally { + waiting.cancel(true); + output.close(); + client.close(); + consumer.join(); + } + } + /** * A request nobody answered is unanswered, not failed. No ordinary tmux command can produce * this — control mode replies as soon as it queues a command, even a blocking one — so the @@ -215,14 +258,14 @@ void aRequestThatIsNeverAnsweredIsUnknownRatherThanFailed(Server server) throws try (ControlClient client = attach(server)) { signal("-STOP", pid); try { - ControlReply reply = - client.send(List.of("display-message", "-p", "unanswerable"), Duration.ofMillis(500)); + TmuxTimeoutException failure = assertThrows( + TmuxTimeoutException.class, + () -> client.send(List.of("display-message", "-p", "unanswerable"), Duration.ofMillis(500))); assertEquals( - OperationOutcome.UNKNOWN, - reply.outcome(), + DispatchOutcome.UNKNOWN, + failure.outcome(), "tmux may well have run it; nothing came back to say so"); - assertEquals(List.of(), reply.lines()); } finally { signal("-CONT", pid); } @@ -245,12 +288,18 @@ private static void signal(String signal, String pid) throws Exception { assertTrue(kill.waitFor(20, TimeUnit.SECONDS) && kill.exitValue() == 0, "could not " + signal + " tmux"); } - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { + private static boolean awaitOutput(EventSubscription output, String expected) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + Duration remaining = Duration.ofNanos(Math.max(0L, deadline - System.nanoTime())); + var next = output.next(remaining); + if (next.isEmpty()) { + return false; + } + if (next.orElseThrow().data().contains(expected)) { return true; } - Thread.sleep(50); } return false; } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlWatchIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlWatchIntegrationTest.java index ebbd165..84f588a 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ControlWatchIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlWatchIntegrationTest.java @@ -1,16 +1,16 @@ package io.github.libtmux.it; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.Server; import io.github.libtmux.Session; import io.github.libtmux.control.ControlClient; import io.github.libtmux.control.ControlEvent; +import io.github.libtmux.control.EventSubscription; import io.github.libtmux.junit5.TmuxExtension; -import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.function.BooleanSupplier; +import java.time.Duration; +import java.util.function.Predicate; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -27,34 +27,33 @@ final class ControlWatchIntegrationTest { @Test void aWindowAppearingIsAnnouncedWithoutAnythingAsking(Server server) throws Exception { Session session = server.sessions().get(0); - List seen = new CopyOnWriteArrayList<>(); - try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - client.onEvent(seen::add); + try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription events = client.subscribeEvents(32)) { session.newWindow("appeared"); assertTrue( - await(() -> seen.stream().anyMatch(event -> event.kind().equals("window-add"))), - "tmux tells an attached control client about a new window: " + kinds(seen)); + awaitEvent(events, event -> event.kind().equals("window-add")), + "tmux did not tell the attached control client about the new window"); } } @Test void aRenameIsAnnouncedWithTheNameItWasGiven(Server server) throws Exception { Session session = server.sessions().get(0); - List seen = new CopyOnWriteArrayList<>(); - try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - client.onEvent(seen::add); + try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription events = client.subscribeEvents(32)) { session.windows().get(0).rename("renamed-now"); assertTrue( - await(() -> seen.stream() - .anyMatch(event -> event.kind().equals("window-renamed") - && event.fields().contains("renamed-now"))), - String.valueOf(kinds(seen))); + awaitEvent( + events, + event -> event.kind().equals("window-renamed") + && event.fields().contains("renamed-now")), + "tmux did not report the renamed window"); } } @@ -65,18 +64,19 @@ void aRenameIsAnnouncedWithTheNameItWasGiven(Server server) throws Exception { @Test void aWatchedFormatIsReportedWhenItsValueChanges(Server server) throws Exception { Session session = server.sessions().get(0); - List seen = new CopyOnWriteArrayList<>(); - try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - client.onEvent(seen::add); + try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription events = client.subscribeEvents(32)) { client.watch("windows", "", "#{session_windows}"); - assertTrue(await(() -> valuesOf(seen, "windows").contains("1")), "the first value is reported once"); + assertTrue( + awaitEvent(events, event -> hasSubscriptionValue(event, "windows", "1")), + "the first value is reported once"); session.newWindow("another"); assertTrue( - await(() -> valuesOf(seen, "windows").contains("2")), - "and the change is reported without being asked for: " + valuesOf(seen, "windows")); + awaitEvent(events, event -> hasSubscriptionValue(event, "windows", "2")), + "the change is reported without being asked for"); } } @@ -84,64 +84,75 @@ void aWatchedFormatIsReportedWhenItsValueChanges(Server server) throws Exception @Test void aWatchOverEveryWindowNamesTheWindowEachValueIsFor(Server server) throws Exception { Session session = server.sessions().get(0); - List seen = new CopyOnWriteArrayList<>(); - try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - client.onEvent(seen::add); + try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription events = client.subscribeEvents(32)) { client.watch("names", "@*", "#{window_name}"); var made = session.newWindow("distinctly-named"); assertTrue( - await(() -> seen.stream() - .anyMatch(event -> + awaitEvent( + events, + event -> event.subscription().filter("names"::equals).isPresent() && event.value() .filter("distinctly-named"::equals) .isPresent() && event.windowId() .filter(made.id().value()::equals) - .isPresent())), - "each value carries its own target: " + seen); + .isPresent()), + "the watched value did not carry its target window"); } } @Test void aWatchThatIsRemovedStopsBeingReported(Server server) throws Exception { Session session = server.sessions().get(0); - List seen = new CopyOnWriteArrayList<>(); - try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - client.onEvent(seen::add); + try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription events = client.subscribeEvents(32)) { client.watch("windows", "", "#{session_windows}"); - assertTrue(await(() -> !valuesOf(seen, "windows").isEmpty())); + assertTrue(awaitEvent( + events, + event -> event.subscription().filter("windows"::equals).isPresent())); client.unwatch("windows"); - seen.clear(); + while (events.next(Duration.ZERO).isPresent()) {} session.newWindow("after-unwatching"); - Thread.sleep(2500); - assertEquals(List.of(), valuesOf(seen, "windows"), "nothing is reported for a watch that was removed"); + assertFalse( + awaitEvent( + events, + event -> event.subscription() + .filter("windows"::equals) + .isPresent(), + Duration.ofMillis(2500)), + "nothing is reported for a watch that was removed"); } } - private static List valuesOf(List events, String name) { - return events.stream() - .filter(event -> event.subscription().filter(name::equals).isPresent()) - .flatMap(event -> event.value().stream()) - .toList(); + private static boolean hasSubscriptionValue(ControlEvent event, String name, String value) { + return event.subscription().filter(name::equals).isPresent() + && event.value().filter(value::equals).isPresent(); } - private static List kinds(List events) { - return events.stream().map(ControlEvent::kind).distinct().toList(); + private static boolean awaitEvent(EventSubscription events, Predicate match) + throws InterruptedException { + return awaitEvent(events, match, Duration.ofSeconds(10)); } - /** tmux checks a subscription about once a second, so waiting has to outlast that. */ - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { + private static boolean awaitEvent( + EventSubscription events, Predicate match, Duration timeout) + throws InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadline) { + var event = events.next(Duration.ofNanos(Math.max(0L, deadline - System.nanoTime()))); + if (event.isEmpty()) { + return false; + } + if (match.test(event.orElseThrow())) { return true; } - Thread.sleep(100); } return false; } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ExamplesTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ExamplesTest.java index 313f6ac..cc0e76f 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ExamplesTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ExamplesTest.java @@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import io.github.libtmux.ExecutionMode; import io.github.libtmux.Pane; import io.github.libtmux.Pane_; import io.github.libtmux.Server; @@ -19,14 +18,15 @@ import io.github.libtmux.Window_; import io.github.libtmux.batch.BatchResult; import io.github.libtmux.control.ControlClient; +import io.github.libtmux.control.EventSubscription; import io.github.libtmux.control.PaneOutput; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.query.Selections; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.List; import java.util.Optional; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -64,46 +64,6 @@ void quickstart(@TempDir Path directory) throws Exception { } } - /** Guide: choosing how commands reach tmux, and the fallback until a session exists. */ - @Test - void choosingAnExecutionMode(@TempDir Path directory) throws Exception { - Path socket = directory.resolve("s"); - - ServerConfig config = ServerConfig.builder() - .endpoint(ServerEndpoint.socketPath(socket)) - .mode(ExecutionMode.CONTROL) - .build(); - - try (Server server = Server.open(config)) { - Session first = server.newSession("work"); - first.newWindow(w -> w.named("logs")); - - assertEquals("work", first.name()); - assertTrue( - first.refresh().windows().stream().anyMatch(window -> "logs".equals(window.name())), - "the window made under the control carrier is not there"); - server.killServer(); - } - } - - /** Guide: the carrier that waits on a virtual thread rather than on the caller's own. */ - @Test - void waitingOnAVirtualThread(@TempDir Path directory) throws Exception { - Path socket = directory.resolve("s"); - - ServerConfig config = ServerConfig.builder() - .endpoint(ServerEndpoint.socketPath(socket)) - .mode(ExecutionMode.VIRTUAL) - .build(); - - try (Server server = Server.open(config)) { - Session session = server.newSession("work"); - - assertEquals("work", session.name(), "a carrier changes the waiting and not the answer"); - server.killServer(); - } - } - /** Guide: sessions, windows and panes are described the same way. */ @Test void describingWhatYouCreate(Server server) throws Exception { @@ -283,14 +243,12 @@ void chaining(Server server) { void streaming(Server server) throws Exception { Session session = server.sessions().get(0); - try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - List seen = new CopyOnWriteArrayList<>(); - client.onOutput(seen::add); + try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription output = client.subscribeOutput(32)) { client.send("send-keys", "-t", session.name(), "echo streamed", "Enter"); - assertTrue( - await(() -> seen.stream().anyMatch(output -> output.data().contains("streamed")))); + assertTrue(awaitOutput(output, "streamed")); } } @@ -335,4 +293,19 @@ private static boolean await(BooleanSupplier condition) throws InterruptedExcept } return false; } + + private static boolean awaitOutput(EventSubscription output, String expected) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + var next = output.next(Duration.ofNanos(Math.max(0L, deadline - System.nanoTime()))); + if (next.isEmpty()) { + return false; + } + if (next.orElseThrow().data().contains(expected)) { + return true; + } + } + return false; + } } diff --git a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java index 5dae3a4..44de960 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java @@ -5,22 +5,22 @@ import io.github.libtmux.ServerConfig; import io.github.libtmux.SessionId; import io.github.libtmux.batch.OperationOutcome; -import java.io.BufferedReader; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.TmuxTimeoutException; +import io.github.libtmux.transport.TmuxTransportException; import java.io.BufferedWriter; import java.io.IOException; -import java.io.InputStreamReader; +import java.io.InputStream; +import java.io.OutputStream; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.Objects; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicBoolean; /** * A tmux client that stays attached and answers one command at a time. @@ -30,12 +30,14 @@ * the request number that produced it, so a failure discards nothing behind it and attribution is * tmux's own. * - *

Replies arrive in request order, so a request waiting for its reply is matched by position. A - * caller that gives up waiting leaves its request in place rather than removing it, because - * removing it would match the next reply to the wrong request. + *

Replies arrive in request order, so the writer sends one request at a time and matches its + * reply by position. A deadline before the writer picks a request writes nothing; a deadline after + * that point ends the client because the next reply could no longer be attributed safely. * - *

The reader is a platform thread. A library does not own the virtual-thread scheduler, and a - * reader that cannot be scheduled is a client that stops answering. + *

The reader and writer are platform threads. A library does not own the virtual-thread + * scheduler, and either one unable to run stops the client from making progress. The reader only + * resolves replies and fills bounded subscription buffers; subscriber code runs on the thread that + * pulls a value. */ public final class ControlClient implements AutoCloseable { @@ -43,19 +45,30 @@ public final class ControlClient implements AutoCloseable { private static final long EXIT_MILLIS = 5_000; private final Process process; - private final BufferedWriter requests; + private final InputStream standardOutput; + private final InputStream standardError; + private final ControlWriter writer; private final Thread reader; - private final Queue awaiting = new ConcurrentLinkedQueue<>(); - private final List> listeners = new CopyOnWriteArrayList<>(); - private final List> events = new CopyOnWriteArrayList<>(); - private final ReentrantLock sending = new ReentrantLock(); - private volatile boolean closed; + private final Thread errorReader; + private final ControlProtocol protocol = new ControlProtocol(); + private final List> outputSubscriptions = new CopyOnWriteArrayList<>(); + private final List> eventSubscriptions = new CopyOnWriteArrayList<>(); + private final AtomicBoolean closed = new AtomicBoolean(); + private volatile boolean failed; + private volatile boolean subscriptionsClosed; private ControlClient(Process process) { this.process = process; - this.requests = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + this.standardOutput = process.getInputStream(); + this.standardError = process.getErrorStream(); + BufferedWriter requests = + new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + this.writer = new ControlWriter(requests, ControlWriter.DEFAULT_CAPACITY, ignored -> terminate()); this.reader = new Thread(this::read, "libtmux-control"); this.reader.setDaemon(false); + this.errorReader = new Thread(this::drainErrors, "libtmux-control-stderr"); + this.errorReader.setDaemon(false); + this.errorReader.start(); } /** @@ -68,6 +81,21 @@ private ControlClient(Process process) { * @param session the session to attach to */ public static ControlClient attach(ServerConfig config, SessionId session) { + return attach(config, session, DEFAULT_TIMEOUT); + } + + /** + * Attaches a control client and waits up to the supplied deadline for its opening reply. + * + * @param config which tmux and which server + * @param session the session to attach to + * @param timeout how long to wait for the client to become ready + */ + public static ControlClient attach(ServerConfig config, SessionId session, Duration timeout) { + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("timeout is not positive"); + } List command = new ArrayList<>(config.endpointCommand()); command.addAll(List.of("-C", "attach-session", "-t", session.value())); Process process; @@ -79,13 +107,23 @@ public static ControlClient attach(ServerConfig config, SessionId session) { ControlClient client = new ControlClient(process); // Attaching produces a reply of its own. It is awaited like any other, which is also what // proves the client is up before the first command is written. - Pending attached = new Pending(); - client.awaiting.add(attached); + ControlWriter.Request attached = client.writer.expectInitial(timeout); client.reader.start(); - if (!attached.await(DEFAULT_TIMEOUT)) { + ControlReply reply; + try { + reply = client.writer.await(attached); + } catch (TmuxTimeoutException e) { + client.close(); + throw e; + } catch (TmuxTransportException e) { + client.close(); + throw new LibTmuxException("could not attach the control client", e); + } + if (reply.outcome() != OperationOutcome.COMPLETE) { client.close(); - throw new LibTmuxException("the control client did not become ready"); + throw new LibTmuxException("the control client did not become ready: " + reply.lines()); } + client.writer.start(); return client; } @@ -104,37 +142,29 @@ public ControlReply send(List argv) { * * @param argv the command, its arguments already separate elements * @param timeout how long to wait for tmux to answer - * @return the reply, whose outcome is {@code UNKNOWN} if no answer arrived in time or the client - * ended before answering + * @return tmux's reply + * @throws TmuxTransportException if the request cannot complete; its {@link + * TmuxTransportException#outcome() outcome} is {@link DispatchOutcome#NOT_DISPATCHED} until + * the writer picks the request and {@link DispatchOutcome#UNKNOWN} afterwards */ public ControlReply send(List argv, Duration timeout) { if (argv.isEmpty()) { throw new IllegalArgumentException("a command has no words"); } + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("timeout is not positive"); + } if (isCommandGroup(argv)) { // Refused before anything is written, so the stream stays in step and the caller can // send the commands one at a time — which is what this carrier is for. throw new IllegalArgumentException( "a control-mode request is one command, and this argv is several: " + argv); } - if (closed) { - throw new IllegalStateException("control client is closed"); - } - Pending pending = new Pending(); - sending.lock(); - try { - // Enqueued and written under one lock, so the queue order is the write order. - awaiting.add(pending); - requests.write(line(argv)); - requests.newLine(); - requests.flush(); - } catch (IOException e) { - throw new LibTmuxException("could not write to the control client", e); - } finally { - sending.unlock(); + if (closed.get() || failed) { + throw new IllegalStateException("control client is not usable"); } - pending.await(timeout); - return new ControlReply(pending.outcome, pending.lines); + return writer.exchange(line(argv), timeout); } /** @@ -145,28 +175,31 @@ public ControlReply send(List argv, Duration timeout) { * to send commands to", which are the same exception until this is asked. */ public boolean isAlive() { - return process.isAlive(); + return !closed.get() && !failed && process.isAlive(); } /** * Subscribes to terminal output tmux pushes. * - *

Listeners run on the reader thread, which is also the only thread that resolves replies, so - * a slow listener delays every answer. One that throws does not end it: the failure goes to the - * thread's uncaught-exception handler and the remaining listeners are still told. + * @param capacity how many values this subscriber can retain before its oldest value is dropped + * @return a pull subscription owned by the caller + * @throws IllegalArgumentException if {@code capacity} is not positive + * @throws IllegalStateException if the client has ended */ - public void onOutput(Consumer listener) { - listeners.add(listener); + public EventSubscription subscribeOutput(int capacity) { + return subscribe(outputSubscriptions, capacity); } /** - * Subscribes to everything else tmux volunteers: windows appearing, sessions renamed, layouts - * moving, and the values of any {@link #watch} registered here. + * Subscribes to state changes tmux volunteers. * - *

Same threading contract as {@link #onOutput}. + * @param capacity how many values this subscriber can retain before its oldest value is dropped + * @return a pull subscription owned by the caller + * @throws IllegalArgumentException if {@code capacity} is not positive + * @throws IllegalStateException if the client has ended */ - public void onEvent(Consumer listener) { - events.add(listener); + public EventSubscription subscribeEvents(int capacity) { + return subscribe(eventSubscriptions, capacity); } /** @@ -191,29 +224,34 @@ public ControlReply unwatch(String name) { return send("refresh-client", "-B", name); } - /** Ends the client. Every request still waiting is resolved as {@code UNKNOWN}. */ + /** Ends the client, rejecting queued requests and resolving picked requests as uncertain. */ @Override public void close() { - if (closed) { + if (!closed.compareAndSet(false, true)) { return; } - closed = true; - try { - requests.close(); - } catch (IOException e) { - // Closing the request stream is how the client is asked to exit; a failure here means - // it is already gone. + List descendants = process.descendants().toList(); + writer.close(); + closeSubscriptions(); + AtomicBoolean interrupted = new AtomicBoolean(Thread.interrupted()); + stop(descendants, interrupted); + process.destroy(); + if (!awaitExit(process, EXIT_MILLIS, interrupted)) { + process.destroyForcibly(); + awaitExit(process, EXIT_MILLIS, interrupted); } - try { - if (!process.waitFor(EXIT_MILLIS, TimeUnit.MILLISECONDS)) { - process.destroyForcibly(); - process.waitFor(EXIT_MILLIS, TimeUnit.MILLISECONDS); - } - reader.join(EXIT_MILLIS); - } catch (InterruptedException e) { + close(standardOutput); + close(standardError); + if (!Thread.currentThread().equals(reader)) { + join(reader, EXIT_MILLIS, interrupted); + } + if (!Thread.currentThread().equals(errorReader)) { + join(errorReader, EXIT_MILLIS, interrupted); + } + join(writer, EXIT_MILLIS, interrupted); + if (interrupted.get()) { Thread.currentThread().interrupt(); } - release(); } // -------------------------------------------------------------------------------- protocol @@ -250,64 +288,57 @@ public static boolean isCommandGroup(List argv) { * disagree about what the argument was. */ static String line(List argv) { - StringBuilder text = new StringBuilder(); - for (String argument : argv) { - if (text.length() > 0) { - text.append(' '); - } - String literal = argument.endsWith("\\;") ? argument.substring(0, argument.length() - 2) + ';' : argument; - text.append('\'').append(literal.replace("'", "'\\''")).append('\''); - } - return text.toString(); + return ControlProtocol.line(argv); } private void read() { - List block = new ArrayList<>(); - boolean inBlock = false; - try (var lines = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - String line; + try (var lines = new ControlLineReader(standardOutput, ControlProtocol.DEFAULT_MAX_REPLY_BYTES)) { + ControlLineReader.Line line; while ((line = lines.readLine()) != null) { - if (line.startsWith("%begin")) { - inBlock = true; - block = new ArrayList<>(); - } else if (line.startsWith("%end")) { - inBlock = false; - complete(OperationOutcome.COMPLETE, block); - } else if (line.startsWith("%error")) { - inBlock = false; - complete(OperationOutcome.FAILED, block); - } else if (inBlock) { - block.add(line); - } else if (line.startsWith("%output ")) { - publish(line); - } else if (line.startsWith("%")) { - // Everything else tmux volunteers about its own state. A snapshot is still how - // state is read; this only says when reading it again would be worth the trouble. - ControlEvent.parse(line).ifPresent(this::announce); + ControlProtocol.Result result = protocol.accept(line.text(), line.encodedBytes()); + if (result instanceof ControlProtocol.Reply reply) { + complete(reply.outcome(), reply.lines()); + } else if (result instanceof ControlProtocol.Notification notification) { + handleNotification(notification.line()); } } - } catch (IOException e) { + } catch (IOException | ControlProtocol.LimitExceeded e) { // The client ended. Everything still waiting is resolved below. } finally { - release(); + writer.readerEnded(); + terminate(); } } - private void complete(OperationOutcome outcome, List block) { - Pending pending = awaiting.poll(); - if (pending != null) { - pending.settle(outcome, block); + private void drainErrors() { + try (standardError) { + standardError.transferTo(OutputStream.nullOutputStream()); + } catch (IOException e) { + // Closing or ending the client closes this channel too. } } - /** A request with no reply is not a failure; it is an unanswered question. */ - private void release() { - Pending pending; - while ((pending = awaiting.poll()) != null) { - pending.settle(OperationOutcome.UNKNOWN, List.of()); + private void handleNotification(String line) { + if (line.startsWith("%output ")) { + publish(line); + } else if (line.startsWith("%")) { + // Everything else tmux volunteers about its own state. A snapshot is still how state + // is read; this only says when reading it again would be worth the trouble. + ControlEvent.parse(line).ifPresent(this::announce); } } + private void complete(OperationOutcome outcome, List block) { + writer.complete(outcome, block); + } + + private void terminate() { + failed = true; + closeSubscriptions(); + process.destroyForcibly(); + close(standardError); + } + private void publish(String line) { int paneEnd = line.indexOf(' ', "%output ".length()); if (paneEnd < 0) { @@ -315,25 +346,120 @@ private void publish(String line) { } PaneOutput output = new PaneOutput( new PaneId(line.substring("%output ".length(), paneEnd)), unescape(line.substring(paneEnd + 1))); - tell(listeners, output); + offer(outputSubscriptions, output); + } + + private EventSubscription subscribe(List> subscriptions, int capacity) { + if (subscriptionsClosed) { + throw new IllegalStateException("control client has ended"); + } + EventSubscription subscription = new EventSubscription<>(capacity, subscriptions::remove); + subscriptions.add(subscription); + if (subscriptionsClosed) { + subscription.close(); + throw new IllegalStateException("control client has ended"); + } + return subscription; + } + + private static void offer(List> subscriptions, T value) { + for (EventSubscription subscription : subscriptions) { + subscription.offer(value); + } + } + + private void closeSubscriptions() { + subscriptionsClosed = true; + for (EventSubscription subscription : outputSubscriptions) { + subscription.close(); + } + for (EventSubscription subscription : eventSubscriptions) { + subscription.close(); + } } private void announce(ControlEvent event) { - tell(events, event); + offer(eventSubscriptions, event); } - /** - * This thread also resolves every reply, so one listener's failure must not end it. Reported - * rather than swallowed, and through the thread's own handler rather than a logger, because a - * dependency-free core has nowhere else to say it. - */ - private static void tell(List> listeners, T value) { - for (Consumer listener : listeners) { + private static boolean awaitExit(Process process, long millis, AtomicBoolean interrupted) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + while (process.isAlive()) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + return false; + } + try { + process.waitFor(Math.max(1, TimeUnit.NANOSECONDS.toMillis(left)), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + interrupted.set(true); + } + } + return true; + } + + private static void stop(List descendants, AtomicBoolean interrupted) { + descendants.forEach(ProcessHandle::destroy); + if (awaitExit(descendants, 500, interrupted)) { + return; + } + descendants.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly); + awaitExit(descendants, 500, interrupted); + } + + private static boolean awaitExit(List processes, long millis, AtomicBoolean interrupted) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + while (processes.stream().anyMatch(ProcessHandle::isAlive)) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + return false; + } + try { + Thread.sleep(Math.max(1, Math.min(10, TimeUnit.NANOSECONDS.toMillis(left)))); + } catch (InterruptedException e) { + interrupted.set(true); + } + } + return true; + } + + private static void close(InputStream stream) { + try { + stream.close(); + } catch (IOException ignored) { + // Closing is best effort; process termination is the ownership boundary. + } + } + + private static void join(Thread thread, long millis, AtomicBoolean interrupted) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + while (thread.isAlive()) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + return; + } + try { + thread.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(left))); + } catch (InterruptedException e) { + interrupted.set(true); + } + } + } + + private static void join(ControlWriter writer, long millis, AtomicBoolean interrupted) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + while (true) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + return; + } try { - listener.accept(value); - } catch (RuntimeException e) { - Thread current = Thread.currentThread(); - current.getUncaughtExceptionHandler().uncaughtException(current, e); + writer.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(left))); + return; + } catch (InterruptedException e) { + interrupted.set(true); + } catch (IllegalStateException e) { + return; } } } @@ -359,26 +485,4 @@ static String unescape(String data) { } return text.toString(); } - - private static final class Pending { - - private final CountDownLatch answered = new CountDownLatch(1); - private volatile OperationOutcome outcome = OperationOutcome.UNKNOWN; - private volatile List lines = List.of(); - - void settle(OperationOutcome outcome, List lines) { - this.outcome = outcome; - this.lines = List.copyOf(lines); - answered.countDown(); - } - - boolean await(Duration timeout) { - try { - return answered.await(timeout.toMillis(), TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } - } - } } diff --git a/libtmux/src/main/java/io/github/libtmux/control/ControlLineReader.java b/libtmux/src/main/java/io/github/libtmux/control/ControlLineReader.java new file mode 100644 index 0000000..bffa49e --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlLineReader.java @@ -0,0 +1,59 @@ +package io.github.libtmux.control; + +import io.github.libtmux.internal.Utf8; +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import org.jspecify.annotations.Nullable; + +/** Reads bounded physical lines from a control client's byte stream. */ +final class ControlLineReader implements Closeable { + + private final BufferedInputStream source; + private final int maxBytes; + + ControlLineReader(InputStream source, int maxBytes) { + if (maxBytes < 1) { + throw new IllegalArgumentException("maxBytes is not positive"); + } + this.source = new BufferedInputStream(source); + this.maxBytes = maxBytes; + } + + record Line(String text, int encodedBytes) {} + + @Nullable + Line readLine() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(Math.min(maxBytes, 256)); + while (true) { + int next = source.read(); + if (next < 0) { + return bytes.size() == 0 ? null : line(bytes); + } + if (next == '\n') { + return line(bytes); + } + if (bytes.size() == maxBytes) { + throw new IOException("control line exceeded the " + maxBytes + " byte limit"); + } + bytes.write(next); + } + } + + private static Line line(ByteArrayOutputStream bytes) { + byte[] encoded = bytes.toByteArray(); + int textLength = encoded.length; + if (textLength > 0 && encoded[textLength - 1] == '\r') { + textLength--; + } + return new Line(Utf8.backslashReplace(Arrays.copyOf(encoded, textLength)), encoded.length); + } + + @Override + public void close() throws IOException { + source.close(); + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/control/ControlProtocol.java b/libtmux/src/main/java/io/github/libtmux/control/ControlProtocol.java new file mode 100644 index 0000000..bc60fc9 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlProtocol.java @@ -0,0 +1,115 @@ +package io.github.libtmux.control; + +import io.github.libtmux.batch.OperationOutcome; +import io.github.libtmux.internal.CommandStrings; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Frames control-mode replies and encodes requests for tmux's command parser. */ +final class ControlProtocol { + + static final int DEFAULT_MAX_REPLY_BYTES = 16 * 1024 * 1024; + private static final Pattern GUARD = Pattern.compile("^%(begin|end|error) (-?\\d+) (\\d+) (-?\\d+)$"); + + private final int maxReplyBytes; + private @Nullable Guard opening; + private List lines = List.of(); + private long replyBytes; + + ControlProtocol() { + this(DEFAULT_MAX_REPLY_BYTES); + } + + ControlProtocol(int maxReplyBytes) { + if (maxReplyBytes < 1) { + throw new IllegalArgumentException("maxReplyBytes is not positive"); + } + this.maxReplyBytes = maxReplyBytes; + } + + sealed interface Result permits Awaiting, Notification, Reply {} + + enum Awaiting implements Result { + INSTANCE + } + + record Notification(String line) implements Result {} + + record Reply(OperationOutcome outcome, List lines) implements Result { + + Reply { + lines = List.copyOf(lines); + } + } + + Result accept(String line) { + return accept(line, line.getBytes(StandardCharsets.UTF_8).length); + } + + Result accept(String line, int encodedBytes) { + if (encodedBytes < 0) { + throw new IllegalArgumentException("encodedBytes is negative"); + } + Matcher matcher = GUARD.matcher(line); + if (opening == null) { + if (matcher.matches() && matcher.group(1).equals("begin")) { + opening = Guard.from(matcher); + lines = new ArrayList<>(); + replyBytes = 0; + return Awaiting.INSTANCE; + } + return new Notification(line); + } + + if (matcher.matches() && opening.matches(matcher)) { + OperationOutcome outcome = + switch (matcher.group(1)) { + case "end" -> OperationOutcome.COMPLETE; + case "error" -> OperationOutcome.FAILED; + default -> null; + }; + if (outcome != null) { + Reply reply = new Reply(outcome, lines); + opening = null; + lines = List.of(); + replyBytes = 0; + return reply; + } + } + + long added = (long) encodedBytes + 1; + if (replyBytes > maxReplyBytes - added) { + throw new LimitExceeded(maxReplyBytes); + } + replyBytes += added; + lines.add(line); + return Awaiting.INSTANCE; + } + + static String line(List argv) { + return CommandStrings.stringify(argv); + } + + private record Guard(String time, String number, String flags) { + + static Guard from(Matcher matcher) { + return new Guard(matcher.group(2), matcher.group(3), matcher.group(4)); + } + + boolean matches(Matcher matcher) { + return time.equals(matcher.group(2)) && number.equals(matcher.group(3)) && flags.equals(matcher.group(4)); + } + } + + static final class LimitExceeded extends RuntimeException { + private static final long serialVersionUID = 1L; + + LimitExceeded(int limit) { + super("control reply exceeded the " + limit + " byte limit"); + } + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/control/ControlWriter.java b/libtmux/src/main/java/io/github/libtmux/control/ControlWriter.java new file mode 100644 index 0000000..a46b9c8 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlWriter.java @@ -0,0 +1,353 @@ +package io.github.libtmux.control; + +import io.github.libtmux.batch.OperationOutcome; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.TmuxTimeoutException; +import io.github.libtmux.transport.TmuxTransportException; +import java.io.BufferedWriter; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + +/** Owns the process-pipe write and the FIFO that attributes replies to requests. */ +final class ControlWriter { + + static final int DEFAULT_CAPACITY = 64; + + private final BufferedWriter output; + private final ArrayBlockingQueue waiting; + private final AtomicReference<@Nullable Request> active = new AtomicReference<>(); + private final AtomicBoolean accepting = new AtomicBoolean(true); + private final Consumer failed; + private final Thread thread; + + ControlWriter(BufferedWriter output, int capacity, Consumer failed) { + if (capacity < 1) { + throw new IllegalArgumentException("control writer capacity is not positive"); + } + this.output = output; + this.waiting = new ArrayBlockingQueue<>(capacity, true); + this.failed = failed; + this.thread = new Thread(this::run, "libtmux-control-writer"); + this.thread.setDaemon(false); + } + + /** Records the attach request dispatched by the process invocation. */ + Request expectInitial(Duration timeout) { + Request initial = new Request("", timeout, Request.State.PICKED); + if (!accepting.get() || !active.compareAndSet(null, initial)) { + initial.fail(unknown("control client ended before becoming ready", null)); + } + return initial; + } + + void start() { + thread.start(); + } + + ControlReply exchange(String line, Duration timeout) { + Request request = new Request(line, timeout, Request.State.QUEUED); + if (!accepting.get()) { + throw notDispatched("control client is not accepting requests", null); + } + try { + if (!waiting.offer(request, request.remainingNanos(), TimeUnit.NANOSECONDS)) { + request.cancel(timeout("control request admission timed out", DispatchOutcome.NOT_DISPATCHED, null)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + request.cancel(notDispatched("interrupted before control request dispatch", e)); + } + if (!accepting.get() && request.cancel(notDispatched("control client closed before dispatch", null))) { + waiting.remove(request); + } + return await(request); + } + + void complete(OperationOutcome outcome, List lines) { + Request request = active.getAndSet(null); + if (request != null) { + request.complete(new ControlReply(outcome, lines)); + } + } + + void readerEnded() { + stop(unknown("control client ended before answering", null), true); + } + + void close() { + stop(unknown("control client closed", null), false); + } + + void join(long timeoutMillis) throws InterruptedException { + if (thread.getState() == Thread.State.NEW) { + closeOutput(); + return; + } + thread.join(timeoutMillis); + if (thread.isAlive()) { + throw new IllegalStateException("control writer did not stop"); + } + } + + ControlReply await(Request request) { + try { + if (!request.await()) { + expire( + request, + timeout("control request timed out", DispatchOutcome.NOT_DISPATCHED, null), + timeout("control request timed out", DispatchOutcome.UNKNOWN, null)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + expire( + request, + notDispatched("interrupted before control request dispatch", e), + unknown("interrupted while awaiting a control reply", e)); + } + return request.answer(); + } + + private void expire(Request request, TmuxTransportException beforeDispatch, TmuxTransportException afterDispatch) { + if (request.cancel(beforeDispatch)) { + waiting.remove(request); + return; + } + if (request.claimFailure()) { + try { + stop(afterDispatch, true); + } finally { + request.publishFailure(afterDispatch); + } + } + request.awaitTerminal(); + } + + private void run() { + try { + while (accepting.get()) { + Request request; + try { + request = waiting.take(); + } catch (InterruptedException e) { + return; + } + if (!accepting.get()) { + request.cancel(notDispatched("control client closed before dispatch", null)); + continue; + } + if (request.remainingNanos() == 0) { + request.cancel( + timeout("control request admission timed out", DispatchOutcome.NOT_DISPATCHED, null)); + continue; + } + if (!request.pick()) { + continue; + } + if (!active.compareAndSet(null, request)) { + halt(request, unknown("control writer already has an active request", null)); + return; + } + if (!accepting.get()) { + active.compareAndSet(request, null); + request.fail(unknown("control client closed after dispatch", null)); + return; + } + try { + output.write(request.line); + output.newLine(); + output.flush(); + } catch (IOException e) { + halt(request, unknown("could not write to the control client", e)); + return; + } + try { + if (!request.await()) { + expire( + request, + timeout("control request timed out", DispatchOutcome.NOT_DISPATCHED, null), + timeout("control request timed out", DispatchOutcome.UNKNOWN, null)); + return; + } + } catch (InterruptedException e) { + if (!accepting.get()) { + return; + } + Thread.currentThread().interrupt(); + halt(request, unknown("control writer interrupted after dispatch", e)); + return; + } + } + } finally { + closeOutput(); + } + } + + private void halt(Request request, TmuxTransportException failure) { + if (request.claimFailure()) { + try { + stop(failure, true); + } finally { + request.publishFailure(failure); + } + } + } + + private void stop(TmuxTransportException activeFailure, boolean notifyFailure) { + if (!accepting.compareAndSet(true, false)) { + return; + } + List queuedRequests = new ArrayList<>(); + Request queued; + while ((queued = waiting.poll()) != null) { + queuedRequests.add(queued); + } + Request dispatched = active.getAndSet(null); + if (dispatched != null) { + dispatched.claimFailure(); + } + thread.interrupt(); + try { + if (notifyFailure) { + failed.accept(activeFailure); + } + } finally { + for (Request request : queuedRequests) { + request.cancel(notDispatched("control client closed before dispatch", null)); + } + if (dispatched != null) { + dispatched.publishFailure(activeFailure); + } + } + } + + private void closeOutput() { + try { + output.close(); + } catch (IOException e) { + // Ending the process normally closes this stream first. + } + } + + private static TmuxTransportException notDispatched(String message, @Nullable Throwable cause) { + return new TmuxTransportException(message, DispatchOutcome.NOT_DISPATCHED, cause); + } + + private static TmuxTransportException unknown(String message, @Nullable Throwable cause) { + return new TmuxTransportException(message, DispatchOutcome.UNKNOWN, cause); + } + + private static TmuxTimeoutException timeout(String message, DispatchOutcome outcome, @Nullable Throwable cause) { + return new TmuxTimeoutException(message, outcome, cause); + } + + static final class Request { + + private enum State { + QUEUED, + PICKED, + FAILING, + DONE + } + + private final String line; + private final long started = System.nanoTime(); + private final long timeoutNanos; + private final AtomicReference state; + private final CountDownLatch answered = new CountDownLatch(1); + private volatile @Nullable Object answer; + + private Request(String line, Duration timeout, State state) { + this.line = line; + this.timeoutNanos = timeoutNanos(timeout); + this.state = new AtomicReference<>(state); + } + + boolean pick() { + return state.compareAndSet(State.QUEUED, State.PICKED); + } + + boolean cancel(TmuxTransportException reason) { + return finish(State.QUEUED, reason); + } + + void complete(ControlReply reply) { + finish(State.PICKED, reply); + } + + boolean fail(TmuxTransportException reason) { + if (!claimFailure()) { + return false; + } + publishFailure(reason); + return true; + } + + boolean claimFailure() { + return state.compareAndSet(State.PICKED, State.FAILING); + } + + void publishFailure(TmuxTransportException reason) { + finish(State.FAILING, reason); + } + + private boolean finish(State expected, Object result) { + if (!state.compareAndSet(expected, State.DONE)) { + return false; + } + answer = result; + answered.countDown(); + return true; + } + + long remainingNanos() { + long elapsed = System.nanoTime() - started; + return Math.max(0, timeoutNanos - Math.max(0, elapsed)); + } + + boolean await() throws InterruptedException { + return answered.await(remainingNanos(), TimeUnit.NANOSECONDS); + } + + ControlReply answer() { + Object result = answer; + if (result instanceof TmuxTransportException failure) { + throw failure; + } + if (result instanceof ControlReply reply) { + return reply; + } + throw new IllegalStateException("control request has no answer"); + } + + void awaitTerminal() { + boolean interrupted = Thread.interrupted(); + while (answered.getCount() > 0) { + try { + answered.await(); + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + private static long timeoutNanos(Duration timeout) { + try { + return timeout.toNanos(); + } catch (ArithmeticException e) { + return Long.MAX_VALUE; + } + } + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java b/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java new file mode 100644 index 0000000..c4b6d5c --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/control/EventSubscription.java @@ -0,0 +1,152 @@ +package io.github.libtmux.control; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Optional; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +/** + * Pulls events volunteered by one control client. + * + *

Each subscription owns a fixed-capacity buffer. When that buffer is full, the next event + * replaces its oldest event and increments {@link #droppedCount()}; another subscription has its + * own buffer and loss count. The control protocol reader only offers values to these buffers. It + * never runs subscriber code. Public operations are thread-safe; concurrent readers compete for + * the same sequence, and each event is returned at most once. + * + *

Closing is terminal: it discards buffered events, wakes threads blocked in {@link #next()}, + * and makes every later read return empty. Events discarded by close are not overflow and do not + * increment the loss count. + * + * @param the event type + */ +public final class EventSubscription implements AutoCloseable { + + private final int capacity; + private final ArrayDeque events = new ArrayDeque<>(); + private final ReentrantLock lock = new ReentrantLock(); + private final Condition available = lock.newCondition(); + private final Consumer> onClose; + private long dropped; + private boolean closed; + + EventSubscription(int capacity, Consumer> onClose) { + if (capacity <= 0) { + throw new IllegalArgumentException("capacity must be positive"); + } + this.capacity = capacity; + this.onClose = onClose; + } + + void offer(T event) { + lock.lock(); + try { + if (closed) { + return; + } + if (events.size() == capacity) { + events.removeFirst(); + dropped++; + } + events.addLast(event); + available.signal(); + } finally { + lock.unlock(); + } + } + + /** + * Returns the exact number of events discarded because this subscription's buffer was full. + * + * @return the monotonic loss count + */ + public long droppedCount() { + lock.lock(); + try { + return dropped; + } finally { + lock.unlock(); + } + } + + /** + * Waits until the next event arrives or this subscription closes. + * + * @return the oldest buffered event, or empty when the subscription closed + * @throws InterruptedException if the waiting thread is interrupted + */ + public Optional next() throws InterruptedException { + lock.lockInterruptibly(); + try { + while (events.isEmpty() && !closed) { + available.await(); + } + return Optional.ofNullable(events.pollFirst()); + } finally { + lock.unlock(); + } + } + + /** + * Waits up to a deadline for the next event. + * + * @param timeout how long to wait, zero to inspect the buffer without waiting + * @return the oldest buffered event, or empty when none arrived before the deadline + * @throws IllegalArgumentException if {@code timeout} is negative + * @throws InterruptedException if the waiting thread is interrupted + */ + public Optional next(Duration timeout) throws InterruptedException { + if (timeout.isNegative()) { + throw new IllegalArgumentException("timeout must not be negative"); + } + long remaining; + try { + remaining = timeout.toNanos(); + } catch (ArithmeticException overflow) { + remaining = Long.MAX_VALUE; + } + lock.lockInterruptibly(); + try { + while (events.isEmpty() && !closed && remaining > 0) { + remaining = available.awaitNanos(remaining); + } + return Optional.ofNullable(events.pollFirst()); + } finally { + lock.unlock(); + } + } + + /** + * Whether this subscription has reached its terminal state. + * + *

This distinguishes a timed read that expired from one that returned empty because the + * subscription ended. + */ + public boolean isClosed() { + lock.lock(); + try { + return closed; + } finally { + lock.unlock(); + } + } + + /** Discards buffered events, removes this subscriber, and wakes every waiting reader. */ + @Override + public void close() { + lock.lock(); + try { + if (closed) { + return; + } + closed = true; + events.clear(); + available.signalAll(); + onClose.accept(this); + } finally { + lock.unlock(); + } + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java b/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java new file mode 100644 index 0000000..fd67751 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java @@ -0,0 +1,37 @@ +package io.github.libtmux.internal; + +import java.util.List; + +/** Lossless conversion from a tmux argv to the command string its parser accepts. */ +public final class CommandStrings { + + private CommandStrings() {} + + public static String stringify(List argv) { + StringBuilder text = new StringBuilder(); + for (String argument : argv) { + if (text.length() > 0) { + text.append(' '); + } + String literal = argument.endsWith("\\;") ? argument.substring(0, argument.length() - 2) + ';' : argument; + appendArgument(text, literal); + } + return text.toString(); + } + + private static void appendArgument(StringBuilder text, String argument) { + text.append('\''); + for (int index = 0; index < argument.length(); index++) { + switch (argument.charAt(index)) { + case '\0' -> throw new IllegalArgumentException("a tmux argument cannot contain NUL"); + case '\n' -> + text.append('\'').append('"').append("\\n").append('"').append('\''); + case '\r' -> + text.append('\'').append('"').append("\\r").append('"').append('\''); + case '\'' -> text.append("'\\''"); + default -> text.append(argument.charAt(index)); + } + } + text.append('\''); + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/internal/Utf8.java b/libtmux/src/main/java/io/github/libtmux/internal/Utf8.java new file mode 100644 index 0000000..2e7a84b --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/Utf8.java @@ -0,0 +1,51 @@ +package io.github.libtmux.internal; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +/** Byte-preserving UTF-8 decoding shared by process and control transports. */ +public final class Utf8 { + + private Utf8() {} + + /** Decodes valid UTF-8 and writes each malformed byte as a recoverable {@code \xNN} escape. */ + public static String backslashReplace(byte[] bytes) { + CharsetDecoder decoder = StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + ByteBuffer in = ByteBuffer.wrap(bytes); + CharBuffer out = CharBuffer.allocate(bytes.length); + StringBuilder text = new StringBuilder(bytes.length); + while (true) { + CoderResult result = decoder.decode(in, out, true); + drainInto(out, text); + if (result.isUnderflow()) { + break; + } + for (int offset = 0; offset < result.length(); offset++) { + escape(text, in.get(in.position() + offset)); + } + in.position(in.position() + result.length()); + } + decoder.flush(out); + drainInto(out, text); + return text.toString(); + } + + private static void escape(StringBuilder text, byte value) { + text.append("\\x") + .append(Character.forDigit((value >> 4) & 0xf, 16)) + .append(Character.forDigit(value & 0xf, 16)); + } + + private static void drainInto(CharBuffer out, StringBuilder text) { + out.flip(); + text.append(out); + out.clear(); + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/transport/OutputDecoder.java b/libtmux/src/main/java/io/github/libtmux/transport/OutputDecoder.java index f2d3d19..74686d0 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/OutputDecoder.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/OutputDecoder.java @@ -1,11 +1,6 @@ package io.github.libtmux.transport; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CoderResult; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; +import io.github.libtmux.internal.Utf8; import java.util.ArrayList; import java.util.List; @@ -45,47 +40,12 @@ static List stderrLines(byte[] bytes) { /** UTF-8 with {@code backslashreplace}, then universal newlines. */ private static String decode(byte[] bytes) { - CharsetDecoder decoder = StandardCharsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); - ByteBuffer in = ByteBuffer.wrap(bytes); - // UTF-8 never decodes to more chars than it has bytes, so this buffer cannot overflow and - // an escape can never straddle it. - CharBuffer out = CharBuffer.allocate(bytes.length); - StringBuilder text = new StringBuilder(bytes.length); - // Decode runs at least once even for empty input: flush() rejects a decoder still in RESET. - while (true) { - CoderResult result = decoder.decode(in, out, true); - drainInto(out, text); - if (result.isUnderflow()) { - break; - } - for (int offset = 0; offset < result.length(); offset++) { - escape(text, in.get(in.position() + offset)); - } - in.position(in.position() + result.length()); - } - decoder.flush(out); - drainInto(out, text); - String decoded = text.toString(); + String decoded = Utf8.backslashReplace(bytes); return decoded.indexOf('\r') < 0 ? decoded : decoded.replace("\r\n", "\n").replace('\r', '\n'); } - private static void escape(StringBuilder text, byte value) { - text.append("\\x") - .append(Character.forDigit((value >> 4) & 0xf, 16)) - .append(Character.forDigit(value & 0xf, 16)); - } - - private static void drainInto(CharBuffer out, StringBuilder text) { - out.flip(); - text.append(out); - out.clear(); - } - private static List split(String text) { List lines = new ArrayList<>(); int start = 0; diff --git a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java index 33ac183..52dbbde 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java @@ -1,12 +1,15 @@ package io.github.libtmux.transport; +import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.time.Duration; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -22,6 +25,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BooleanSupplier; +import java.util.function.LongSupplier; import org.jspecify.annotations.Nullable; /** @@ -45,37 +50,61 @@ public final class ProcessTransport implements TmuxTransport { private static final int DEFAULT_BOUND = 4; + private static final int DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; private static final long GRACEFUL_MILLIS = 250; private static final long FORCIBLE_MILLIS = 5_000; - private static final long DRAIN_FLOOR_MILLIS = 5_000; private static final long RECLAIM_MILLIS = 5_000; private static final long TERMINATION_SECONDS = 60; - private static final Duration QUIESCE = Duration.ofSeconds(30); + private static final ProcessStarter SYSTEM_STARTER = command -> new ProcessBuilder(command).start(); private final Semaphore admission; private final ThreadPoolExecutor pumps; + private final int maxOutputBytes; + private final ProcessStarter starter; + private final LongSupplier nanoTime; private final Set live = ConcurrentHashMap.newKeySet(); private final Set killedByClose = ConcurrentHashMap.newKeySet(); private final ReentrantLock gate = new ReentrantLock(); private final Condition quiesced = gate.newCondition(); + private final Condition closeCompleted = gate.newCondition(); private boolean closed; + private boolean closeComplete; + private @Nullable ResourceNotReclaimed closeFailure; private int launching; /** A transport allowing four concurrent tmux processes. */ public ProcessTransport() { - this(DEFAULT_BOUND); + this(DEFAULT_BOUND, DEFAULT_MAX_OUTPUT_BYTES); } /** * @param maxConcurrentProcesses how many tmux processes may run at once */ public ProcessTransport(int maxConcurrentProcesses) { + this(maxConcurrentProcesses, DEFAULT_MAX_OUTPUT_BYTES); + } + + /** + * @param maxConcurrentProcesses how many tmux processes may run at once + * @param maxOutputBytes maximum bytes accepted from each output channel of one process + */ + public ProcessTransport(int maxConcurrentProcesses, int maxOutputBytes) { + this(maxConcurrentProcesses, maxOutputBytes, SYSTEM_STARTER, System::nanoTime); + } + + ProcessTransport(int maxConcurrentProcesses, int maxOutputBytes, ProcessStarter starter, LongSupplier nanoTime) { if (maxConcurrentProcesses < 1) { throw new IllegalArgumentException("maxConcurrentProcesses is not positive"); } + if (maxOutputBytes < 1) { + throw new IllegalArgumentException("maxOutputBytes is not positive"); + } this.admission = new Semaphore(maxConcurrentProcesses); this.pumps = (ThreadPoolExecutor) Executors.newFixedThreadPool(2 * maxConcurrentProcesses, factory()); + this.maxOutputBytes = maxOutputBytes; + this.starter = Objects.requireNonNull(starter, "starter"); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); this.pumps.prestartAllCoreThreads(); } @@ -83,11 +112,11 @@ public ProcessTransport(int maxConcurrentProcesses) { public CommandResult execute(CommandRequest request) { requireOpen(); requireDispatchable(request.argv()); - long deadline = System.nanoTime() + request.timeout().toNanos(); + long deadline = deadlineAfter(request.timeout()); admit(deadline); Process process; try { - process = launch(request); + process = launch(request, deadline); } catch (RuntimeException e) { admission.release(); throw e; @@ -110,38 +139,75 @@ public CommandResult execute(CommandRequest request) { @Override public void close() { + AtomicBoolean interrupted = new AtomicBoolean(Thread.interrupted()); + boolean closeOwner; + @Nullable ResourceNotReclaimed observedFailure; gate.lock(); try { if (closed) { - return; + awaitWhile(closeCompleted, () -> !closeComplete, interrupted); + closeOwner = false; + observedFailure = closeFailure; + } else { + closed = true; + awaitWhile(quiesced, () -> launching > 0, interrupted); + closeOwner = true; + observedFailure = null; } - closed = true; - long remaining = QUIESCE.toNanos(); - while (launching > 0 && remaining > 0) { - remaining = quiesced.awaitNanos(remaining); + } finally { + gate.unlock(); + } + + if (!closeOwner) { + restoreInterrupt(interrupted); + if (observedFailure != null) { + throw observedFailure; } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + return; + } + + @Nullable ResourceNotReclaimed failure; + try { + failure = closeResources(interrupted); + } catch (RuntimeException e) { + failure = recordFailure(null, "unexpected failure while closing transport", e); + } + gate.lock(); + try { + closeFailure = failure; + closeComplete = true; + closeCompleted.signalAll(); } finally { gate.unlock(); } - AtomicBoolean interrupted = new AtomicBoolean(); + restoreInterrupt(interrupted); + if (failure != null) { + throw failure; + } + } + + private @Nullable ResourceNotReclaimed closeResources(AtomicBoolean interrupted) { + @Nullable ResourceNotReclaimed failure = null; for (Process process : live) { // Published before the kill, so the caller parked in waitFor can tell our signal from tmux's. killedByClose.add(process); - destroyAndAwait(process, interrupted); + try { + if (!destroyAndAwait(process, interrupted)) { + failure = recordFailure(failure, "tmux survived forcible destruction", null); + } + } catch (RuntimeException e) { + failure = recordFailure(failure, "could not destroy tmux", e); + } } - pumps.shutdownNow(); try { - if (!pumps.awaitTermination(TERMINATION_SECONDS, TimeUnit.SECONDS)) { - throw new ResourceNotReclaimed("pump workers did not terminate"); - } - } catch (InterruptedException e) { - interrupted.set(true); + pumps.shutdownNow(); + } catch (RuntimeException e) { + failure = recordFailure(failure, "could not stop pump workers", e); } - if (interrupted.get()) { - Thread.currentThread().interrupt(); + if (!awaitTermination(pumps, TERMINATION_SECONDS, interrupted)) { + failure = recordFailure(failure, "pump workers did not terminate", null); } + return failure; } // ---------------------------------------------------------------------------- admission @@ -167,29 +233,40 @@ private static void requireDispatchable(List argv) { } private void admit(long deadline) { + long remaining = remainingNanos(deadline); + if (remaining == 0) { + throw admissionTimeout(); + } try { - if (!admission.tryAcquire(deadline - System.nanoTime(), TimeUnit.NANOSECONDS)) { - throw new TmuxTransportException("admission timed out", DispatchOutcome.NOT_DISPATCHED, null); + if (!admission.tryAcquire(remaining, TimeUnit.NANOSECONDS)) { + throw admissionTimeout(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new TmuxTransportException("interrupted before dispatch", DispatchOutcome.NOT_DISPATCHED, e); } + if (remainingNanos(deadline) == 0) { + admission.release(); + throw admissionTimeout(); + } } /** Starts and registers the child atomically with respect to {@link #close()}. */ - private Process launch(CommandRequest request) { + private Process launch(CommandRequest request, long deadline) { gate.lock(); try { if (closed) { throw new IllegalStateException("transport is closed"); } + if (remainingNanos(deadline) == 0) { + throw admissionTimeout(); + } launching++; } finally { gate.unlock(); } try { - Process process = new ProcessBuilder(request.commandLine()).start(); + Process process = starter.start(request.commandLine()); live.add(process); return process; } catch (IOException e) { @@ -210,27 +287,20 @@ private Process launch(CommandRequest request) { private Drains submit(Process process) { CountDownLatch finished = new CountDownLatch(2); + CompletableFuture failure = new CompletableFuture<>(); try { return new Drains( - pumps.submit(new Pump(process.getInputStream(), finished)), - pumps.submit(new Pump(process.getErrorStream(), finished)), - finished); + pumps.submit(new Pump(process.getInputStream(), process, maxOutputBytes, finished, failure)), + pumps.submit(new Pump(process.getErrorStream(), process, maxOutputBytes, finished, failure)), + finished, + failure); } catch (RejectedExecutionException e) { throw terminate(process, "transport closed before draining", e); } } private CommandResult complete(Process process, Drains drains, long deadline) { - boolean exited; - try { - exited = process.waitFor(remaining(deadline), TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw terminate(process, "interrupted while awaiting tmux", e); - } - if (!exited) { - throw terminate(process, "tmux exceeded its deadline", null); - } + awaitExitOrFailure(process, drains, deadline); if (killedByClose.contains(process)) { // This exit status is ours, not tmux's; returning it would read as tmux dying on a signal. throw new TmuxTransportException("transport closed while tmux was running", DispatchOutcome.UNKNOWN, null); @@ -240,34 +310,76 @@ private CommandResult complete(Process process, Drains drains, long deadline) { return new CommandResult(process.exitValue(), OutputDecoder.stdoutLines(out), OutputDecoder.stderrLines(err)); } + private void awaitExitOrFailure(Process process, Drains drains, long deadline) { + try { + CompletableFuture.anyOf(process.onExit(), drains.failure()) + .get(remainingNanos(deadline), TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw terminate(process, "interrupted while awaiting tmux", e); + } catch (TimeoutException e) { + throw timeout(process, "tmux exceeded its deadline", e); + } catch (ExecutionException e) { + throw terminate(process, "could not await tmux", e.getCause()); + } + Throwable failure = drains.failure().getNow(null); + if (failure != null) { + String message = + failure instanceof OutputLimitExceeded exceeded ? exceeded.description() : "could not drain tmux"; + throw terminate(process, message, failure); + } + } + private byte[] collect(Future drain, Process process, long deadline) { try { - return drain.get(Math.max(DRAIN_FLOOR_MILLIS, remaining(deadline)), TimeUnit.MILLISECONDS); + return drain.get(remainingNanos(deadline), TimeUnit.NANOSECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw terminate(process, "interrupted while draining tmux", e); } catch (TimeoutException e) { - throw terminate(process, "draining tmux exceeded its deadline", e); + throw timeout(process, "draining tmux exceeded its deadline", e); } catch (ExecutionException e) { - throw terminate(process, "could not drain tmux", e.getCause()); + Throwable cause = e.getCause(); + String message = + cause instanceof OutputLimitExceeded exceeded ? exceeded.description() : "could not drain tmux"; + throw terminate(process, message, cause); } } - private static long remaining(long deadline) { - return Math.max(0, TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime())); + private long deadlineAfter(Duration timeout) { + long timeoutNanos; + try { + timeoutNanos = timeout.toNanos(); + } catch (ArithmeticException e) { + timeoutNanos = Long.MAX_VALUE; + } + return nanoTime.getAsLong() + timeoutNanos; + } + + private long remainingNanos(long deadline) { + return Math.max(0, deadline - nanoTime.getAsLong()); + } + + private static TmuxTimeoutException admissionTimeout() { + return new TmuxTimeoutException("admission timed out", DispatchOutcome.NOT_DISPATCHED, null); } // --------------------------------------------------------------------------- destruction /** Drains are deliberately not cancelled: killing the child is what actually ends the read. */ private TmuxTransportException terminate(Process process, String message, @Nullable Throwable cause) { + return reclaim(process, new TmuxTransportException(message, DispatchOutcome.UNKNOWN, cause)); + } + + private TmuxTimeoutException timeout(Process process, String message, @Nullable Throwable cause) { + return reclaim(process, new TmuxTimeoutException(message, cause)); + } + + private T reclaim(Process process, T failure) { AtomicBoolean interrupted = new AtomicBoolean(Thread.interrupted()); - TmuxTransportException failure = new TmuxTransportException(message, DispatchOutcome.UNKNOWN, cause); if (!destroyAndAwait(process, interrupted)) { failure.addSuppressed(new ResourceNotReclaimed("tmux survived forcible destruction")); } - closeQuietly(process.getInputStream(), failure); - closeQuietly(process.getErrorStream(), failure); if (interrupted.get()) { Thread.currentThread().interrupt(); } @@ -280,29 +392,126 @@ private TmuxTransportException terminate(Process process, String message, @Nulla * request's own cleanup then drops the last handle to it. */ private static boolean destroyAndAwait(Process process, AtomicBoolean interrupted) { + List descendants = descendants(process); process.destroy(); - if (awaitExit(process, GRACEFUL_MILLIS, interrupted)) { + destroy(descendants, false); + if (awaitExit(process, descendants, GRACEFUL_MILLIS, interrupted)) { return true; } + descendants = union(descendants, descendants(process)); process.destroyForcibly(); - return awaitExit(process, FORCIBLE_MILLIS, interrupted); + destroy(descendants, true); + return awaitExit(process, descendants, FORCIBLE_MILLIS, interrupted); } - private static boolean awaitExit(Process process, long millis, AtomicBoolean interrupted) { + private static boolean awaitExit( + Process process, List descendants, long millis, AtomicBoolean interrupted) { long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + while (process.isAlive()) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + return false; + } + try { + process.waitFor(Math.max(1, TimeUnit.NANOSECONDS.toMillis(left)), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + interrupted.set(true); + } + } + for (ProcessHandle descendant : descendants) { + while (descendant.isAlive()) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + return false; + } + try { + descendant.onExit().get(left, TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + interrupted.set(true); + } catch (ExecutionException e) { + return !descendant.isAlive(); + } catch (TimeoutException e) { + return false; + } + } + } + return true; + } + + private static List descendants(Process process) { + try { + return process.descendants().toList(); + } catch (UnsupportedOperationException | SecurityException e) { + return List.of(); + } + } + + private static List union(List first, List second) { + Set all = ConcurrentHashMap.newKeySet(); + all.addAll(first); + all.addAll(second); + return List.copyOf(all); + } + + private static void destroy(List descendants, boolean forcibly) { + for (int index = descendants.size() - 1; index >= 0; index--) { + ProcessHandle descendant = descendants.get(index); + try { + if (forcibly) { + descendant.destroyForcibly(); + } else { + descendant.destroy(); + } + } catch (RuntimeException e) { + // The bounded wait below decides whether reclamation actually succeeded. + } + } + } + + private static void awaitWhile(Condition condition, BooleanSupplier waiting, AtomicBoolean interrupted) { + while (waiting.getAsBoolean()) { + try { + condition.await(); + } catch (InterruptedException e) { + interrupted.set(true); + } + } + } + + private static boolean awaitTermination(ThreadPoolExecutor executor, long seconds, AtomicBoolean interrupted) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds); while (true) { long left = deadline - System.nanoTime(); if (left <= 0) { - return !process.isAlive(); + return executor.isTerminated(); } try { - return process.waitFor(Math.max(1, TimeUnit.NANOSECONDS.toMillis(left)), TimeUnit.MILLISECONDS); + return executor.awaitTermination(left, TimeUnit.NANOSECONDS); } catch (InterruptedException e) { interrupted.set(true); } } } + private static ResourceNotReclaimed recordFailure( + @Nullable ResourceNotReclaimed failure, String message, @Nullable Throwable cause) { + ResourceNotReclaimed recorded = new ResourceNotReclaimed(message); + if (cause != null) { + recorded.addSuppressed(cause); + } + if (failure == null) { + return recorded; + } + failure.addSuppressed(recorded); + return failure; + } + + private static void restoreInterrupt(AtomicBoolean interrupted) { + if (interrupted.get()) { + Thread.currentThread().interrupt(); + } + } + private static void closeQuietly(Closeable stream, @Nullable TmuxTransportException failure) { try { stream.close(); @@ -323,28 +532,88 @@ private static ThreadFactory factory() { }; } - private record Drains(Future stdout, Future stderr, CountDownLatch finished) { + private record Drains( + Future stdout, + Future stderr, + CountDownLatch finished, + CompletableFuture failure) { boolean reclaimed() { + boolean interrupted = Thread.interrupted(); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RECLAIM_MILLIS); try { - return finished.await(RECLAIM_MILLIS, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return finished.getCount() == 0; + while (finished.getCount() > 0) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + return false; + } + try { + if (finished.await(left, TimeUnit.NANOSECONDS)) { + return true; + } + } catch (InterruptedException e) { + interrupted = true; + } + } + return true; + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } } } } - private record Pump(InputStream source, CountDownLatch finished) implements Callable { + @FunctionalInterface + interface ProcessStarter { + Process start(List command) throws IOException; + } + + private record Pump( + InputStream source, + Process process, + int limit, + CountDownLatch finished, + CompletableFuture failure) + implements Callable { @Override public byte[] call() throws IOException { - try { - return source.readAllBytes(); + try (source) { + ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(limit, 8_192)); + byte[] buffer = new byte[8_192]; + int total = 0; + int read; + while ((read = source.read(buffer)) >= 0) { + if (read > limit - total) { + process.destroy(); + throw new OutputLimitExceeded(limit); + } + output.write(buffer, 0, read); + total += read; + } + return output.toByteArray(); + } catch (IOException | RuntimeException e) { + failure.complete(e); + throw e; } finally { finished.countDown(); } } } + private static final class OutputLimitExceeded extends IOException { + private static final long serialVersionUID = 1L; + private final int limit; + + OutputLimitExceeded(int limit) { + super("tmux output exceeded the " + limit + " byte channel limit"); + this.limit = limit; + } + + String description() { + return "tmux output exceeded the " + limit + " byte channel limit"; + } + } + /** * A worker could not be recovered. Distinct from {@link IllegalStateException}, which this * transport reserves for use after close. diff --git a/libtmux/src/main/java/io/github/libtmux/transport/TmuxTimeoutException.java b/libtmux/src/main/java/io/github/libtmux/transport/TmuxTimeoutException.java new file mode 100644 index 0000000..48c24f7 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/transport/TmuxTimeoutException.java @@ -0,0 +1,18 @@ +package io.github.libtmux.transport; + +import org.jspecify.annotations.Nullable; + +/** A request crossed the process boundary but did not finish before its deadline. */ +public final class TmuxTimeoutException extends TmuxTransportException { + + private static final long serialVersionUID = 1L; + + public TmuxTimeoutException(String message, @Nullable Throwable cause) { + this(message, DispatchOutcome.UNKNOWN, cause); + } + + /** Creates a deadline failure with the request's dispatch certainty. */ + public TmuxTimeoutException(String message, DispatchOutcome outcome, @Nullable Throwable cause) { + super(message, outcome, cause); + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransportException.java b/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransportException.java index 8671c9b..f954f50 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransportException.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransportException.java @@ -11,7 +11,7 @@ * opposite recovery, and a caller that cannot tell them apart has to treat every failure as the * dangerous one. */ -public final class TmuxTransportException extends LibTmuxException { +public class TmuxTransportException extends LibTmuxException { private static final long serialVersionUID = 1L; diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java index 3b83c5f..bb561fd 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java @@ -2,20 +2,65 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.github.libtmux.LibTmuxException; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.SessionId; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.TmuxTimeoutException; +import io.github.libtmux.transport.TmuxTransportException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.time.Duration; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; /** * Where one tmux command ends and the next begins, and what a control-mode line makes of that. * *

Every expectation here was measured against tmux rather than derived from this code. The - * measurements are in {@code docs/spikes/21-command-group-boundaries.md}; {@code - * ExecutionModeConformanceTest} is what keeps the carriers agreeing about them. + * measurements are in {@code docs/spikes/21-command-group-boundaries.md}. */ final class ControlClientTest { + /** A pipe write on the calling virtual thread pins the only carrier and starves the sentinel. */ + @Test + @Tag("carrier") + void aBlockedControlWriteDoesNotOccupyTheCallersCarrier(@TempDir Path directory) throws Exception { + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + sleep 5 + """); + + try (ControlClient client = ControlClient.attach(config, new SessionId("$0"))) { + Thread blocked = Thread.ofVirtual().start(() -> { + try { + client.send(List.of("display-message", "x".repeat(1_048_576)), Duration.ofSeconds(4)); + } catch (RuntimeException expected) { + // The fake never answers. Only where the caller blocks matters here. + } + }); + Thread.sleep(250); + + CountDownLatch sentinel = new CountDownLatch(1); + Thread.ofVirtual().start(sentinel::countDown); + + assertTrue( + sentinel.await(1, TimeUnit.SECONDS), + "the blocked pipe write occupied the only virtual-thread carrier"); + blocked.join(TimeUnit.SECONDS.toMillis(10)); + } + } + @Test void aSemicolonEndingAnArgumentEndsTheCommand() { assertTrue(ControlClient.isCommandGroup(List.of("kill-window;", "list-windows"))); @@ -56,4 +101,179 @@ void everyOtherArgumentReachesTmuxExactlyAsGiven() { "'display-message' '-p' 'it'\\''s quoted'", ControlClient.line(List.of("display-message", "-p", "it's quoted"))); } + + @Test + void aTimedOutReplyMakesTheStreamUnavailableForLaterRequests(@TempDir Path directory) throws Exception { + Path fakeTmux = directory.resolve("tmux"); + Files.writeString(fakeTmux, """ + #!/bin/sh + printf '%%begin 100 1 0\n%%end 100 1 0\n' + IFS= read -r request + sleep 1 + """); + Files.setPosixFilePermissions(fakeTmux, PosixFilePermissions.fromString("rwx------")); + ServerConfig config = ServerConfig.builder().binary(fakeTmux.toString()).build(); + + try (ControlClient client = ControlClient.attach(config, new SessionId("$0")); + EventSubscription events = client.subscribeEvents(1)) { + TmuxTimeoutException failure = assertThrows( + TmuxTimeoutException.class, () -> client.send(List.of("list-windows"), Duration.ofMillis(100))); + + assertEquals(DispatchOutcome.UNKNOWN, failure.outcome()); + assertFalse(client.isAlive(), "a missing reply leaves command attribution uncertain"); + assertTrue(events.isClosed(), "a subscriber cannot wait forever on an unusable client"); + assertThrows(IllegalStateException.class, () -> client.send("list-panes")); + } + } + + @Test + void aNonPositiveTimeoutIsRejectedBeforeDispatch(@TempDir Path directory) throws Exception { + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + while IFS= read -r request; do + printf '%%begin 101 1 0\n%%end 101 1 0\n' + done + """); + + try (ControlClient client = ControlClient.attach(config, new SessionId("$0"))) { + assertThrows(IllegalArgumentException.class, () -> client.send(List.of("list-windows"), Duration.ZERO)); + assertTrue(client.send("list-panes").succeeded(), "rejection wrote nothing to the stream"); + } + } + + @Test + void aRejectedNulDoesNotStealTheNextRequestsReply(@TempDir Path directory) throws Exception { + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + while IFS= read -r request; do + printf '%%begin 101 1 0\nstill in step\n%%end 101 1 0\n' + done + """); + + try (ControlClient client = ControlClient.attach(config, new SessionId("$0"))) { + assertThrows( + IllegalArgumentException.class, + () -> client.send(List.of("display-message", "contains\0nul"), Duration.ofMillis(200))); + + assertEquals( + List.of("still in step"), + client.send(List.of("display-message", "valid"), Duration.ofMillis(200)) + .lines()); + } + } + + @Test + void interruptionIsNotReportedAsADeadlineExpiry(@TempDir Path directory) throws Exception { + Path dispatched = directory.resolve("dispatched"); + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + IFS= read -r request + : > "${0%/*}/dispatched" + IFS= read -r never + """); + + try (ControlClient client = ControlClient.attach(config, new SessionId("$0"))) { + CountDownLatch entered = new CountDownLatch(1); + FutureTask waiting = new FutureTask<>(() -> { + entered.countDown(); + try { + client.send(List.of("list-windows"), Duration.ofSeconds(30)); + throw new AssertionError("the interrupted request unexpectedly completed"); + } catch (TmuxTransportException failure) { + return new InterruptedFailure( + failure, Thread.currentThread().isInterrupted()); + } + }); + Thread caller = Thread.ofVirtual().start(waiting); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertTrue(awaitFile(dispatched), "the writer never dispatched the request"); + + caller.interrupt(); + + InterruptedFailure result = waiting.get(5, TimeUnit.SECONDS); + assertFalse(result.failure() instanceof TmuxTimeoutException); + assertInstanceOf(TmuxTransportException.class, result.failure()); + assertEquals(DispatchOutcome.UNKNOWN, result.failure().outcome()); + assertTrue(result.interrupted(), "the caller's interrupt status was lost"); + } + } + + @Test + void anAttachErrorIsNotAcceptedAsAReadyClient(@TempDir Path directory) throws Exception { + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\nattach refused\n%%error 100 1 0\n' + """); + + assertThrows(LibTmuxException.class, () -> ControlClient.attach(config, new SessionId("$0"))); + } + + @Test + void anAttachDeadlineKeepsItsTimeoutType(@TempDir Path directory) throws Exception { + ServerConfig config = fakeTmux(directory, "sleep 5\n"); + + TmuxTimeoutException timeout = assertThrows( + TmuxTimeoutException.class, + () -> ControlClient.attach(config, new SessionId("$0"), Duration.ofMillis(100))); + + assertEquals(DispatchOutcome.UNKNOWN, timeout.outcome()); + } + + @Test + void stderrCannotBlockTheOpeningControlReply(@TempDir Path directory) throws Exception { + ServerConfig config = fakeTmux(directory, """ + i=0 + while [ "$i" -lt 4096 ]; do + printf 'control-stderr-flood-0123456789\\n' >&2 + i=$((i + 1)) + done + printf '%%begin 100 1 0\n%%end 100 1 0\n' + while IFS= read -r request; do :; done + """); + + try (ControlClient client = ControlClient.attach(config, new SessionId("$0"), Duration.ofSeconds(2))) { + assertTrue(client.isAlive()); + } + } + + @Test + void closingAControlClientReclaimsDescendantsThatInheritedItsPipes(@TempDir Path directory) throws Exception { + Path childFile = directory.resolve("child-pid"); + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + sleep 30 & + printf '%s\n' "$!" > "${0%/*}/child-pid" + while IFS= read -r request; do :; done + """); + long child = -1; + try { + ControlClient client = ControlClient.attach(config, new SessionId("$0")); + assertTrue(awaitFile(childFile), "the fake control client never started its descendant"); + child = Long.parseLong(Files.readString(childFile).trim()); + + client.close(); + + assertFalse(ProcessHandle.of(child).map(ProcessHandle::isAlive).orElse(false)); + } finally { + if (child > 0) { + ProcessHandle.of(child).ifPresent(ProcessHandle::destroyForcibly); + } + } + } + + private static ServerConfig fakeTmux(Path directory, String body) throws Exception { + Path fakeTmux = directory.resolve("tmux"); + Files.writeString(fakeTmux, "#!/bin/sh\n" + body); + Files.setPosixFilePermissions(fakeTmux, PosixFilePermissions.fromString("rwx------")); + return ServerConfig.builder().binary(fakeTmux.toString()).build(); + } + + private static boolean awaitFile(Path file) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!Files.exists(file) && System.nanoTime() < deadline) { + Thread.sleep(10); + } + return Files.exists(file); + } + + private record InterruptedFailure(TmuxTransportException failure, boolean interrupted) {} } diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlLineReaderTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlLineReaderTest.java new file mode 100644 index 0000000..418b9bb --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlLineReaderTest.java @@ -0,0 +1,39 @@ +package io.github.libtmux.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +final class ControlLineReaderTest { + + @Test + void aLineCannotGrowPastItsByteLimit() throws Exception { + byte[] input = "12345\n".getBytes(StandardCharsets.UTF_8); + try (var lines = new ControlLineReader(new ByteArrayInputStream(input), 4)) { + assertThrows(IOException.class, lines::readLine); + } + } + + @Test + void lineEndingsAreRemovedButCounted() throws Exception { + byte[] input = "one\r\ntwo".getBytes(StandardCharsets.UTF_8); + try (var lines = new ControlLineReader(new ByteArrayInputStream(input), 8)) { + assertEquals(new ControlLineReader.Line("one", 4), lines.readLine()); + assertEquals(new ControlLineReader.Line("two", 3), lines.readLine()); + assertEquals(null, lines.readLine()); + } + } + + @Test + void malformedUtf8RemainsRecoverableAsItsOriginalByte() throws Exception { + byte[] input = {'a', (byte) 0xff, '\n'}; + + try (var lines = new ControlLineReader(new ByteArrayInputStream(input), 8)) { + assertEquals(new ControlLineReader.Line("a\\xff", 2), lines.readLine()); + } + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlProtocolTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlProtocolTest.java new file mode 100644 index 0000000..a3c9c7f --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlProtocolTest.java @@ -0,0 +1,58 @@ +package io.github.libtmux.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.libtmux.batch.OperationOutcome; +import java.util.List; +import org.junit.jupiter.api.Test; + +final class ControlProtocolTest { + + @Test + void onlyTheFullOpeningGuardCanCloseAReply() { + ControlProtocol protocol = new ControlProtocol(); + + assertInstanceOf(ControlProtocol.Awaiting.class, protocol.accept("%begin 100 7 1")); + assertInstanceOf(ControlProtocol.Awaiting.class, protocol.accept("%end 1 2 3")); + assertInstanceOf(ControlProtocol.Awaiting.class, protocol.accept("%begin 9 9 9")); + ControlProtocol.Reply reply = assertInstanceOf(ControlProtocol.Reply.class, protocol.accept("%end 100 7 1")); + + assertEquals(OperationOutcome.COMPLETE, reply.outcome()); + assertEquals(List.of("%end 1 2 3", "%begin 9 9 9"), reply.lines()); + } + + @Test + void matchingErrorGuardsFailTheReplyAndNotificationsStayOutsideIt() { + ControlProtocol protocol = new ControlProtocol(); + + ControlProtocol.Notification notification = + assertInstanceOf(ControlProtocol.Notification.class, protocol.accept("%window-add @1")); + assertEquals("%window-add @1", notification.line()); + protocol.accept("%begin 100 8 1"); + protocol.accept("no such window"); + ControlProtocol.Reply reply = assertInstanceOf(ControlProtocol.Reply.class, protocol.accept("%error 100 8 1")); + + assertEquals(OperationOutcome.FAILED, reply.outcome()); + assertEquals(List.of("no such window"), reply.lines()); + } + + @Test + void requestEncodingKeepsLineBreaksInsideOneArgument() { + assertEquals( + "'set-buffer' 'first'\"\\n\"'second'\"\\r\"'third'", + ControlProtocol.line(List.of("set-buffer", "first\nsecond\rthird"))); + assertThrows( + IllegalArgumentException.class, () -> ControlProtocol.line(List.of("set-buffer", "before\0after"))); + } + + @Test + void aReplyCannotGrowPastItsAggregateByteLimit() { + ControlProtocol protocol = new ControlProtocol(8); + protocol.accept("%begin 100 7 1"); + protocol.accept("1234", 4); + + assertThrows(ControlProtocol.LimitExceeded.class, () -> protocol.accept("5678", 4)); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlWriterTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlWriterTest.java new file mode 100644 index 0000000..833c035 --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlWriterTest.java @@ -0,0 +1,275 @@ +package io.github.libtmux.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.batch.OperationOutcome; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.TmuxTransportException; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.Writer; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** Admission, attribution, and shutdown at the control writer's concurrency boundary. */ +final class ControlWriterTest { + + private static final Duration PATIENCE = Duration.ofSeconds(5); + + @Test + void aFullQueueTimesOutBeforeDispatch() throws Exception { + BlockingWriter output = new BlockingWriter(); + ControlWriter writer = writer(output, 1, ignored -> {}); + writer.start(); + Thread active = exchange(writer, "active", PATIENCE, new AtomicReference<>()); + assertTrue(output.entered.await(1, TimeUnit.SECONDS)); + AtomicReference queuedFailure = new AtomicReference<>(); + Thread queued = exchange(writer, "queued", PATIENCE, queuedFailure); + Thread.sleep(100); + + TmuxTransportException refused = + assertThrows(TmuxTransportException.class, () -> writer.exchange("refused", Duration.ofMillis(100))); + + assertEquals(DispatchOutcome.NOT_DISPATCHED, refused.outcome()); + writer.close(); + output.release.countDown(); + writer.join(1_000); + active.join(1_000); + queued.join(1_000); + assertEquals(DispatchOutcome.NOT_DISPATCHED, queuedFailure.get().outcome()); + } + + @Test + void aWriteFailureIsUncertainAndEndsTheActor() throws Exception { + AtomicReference actorFailure = new AtomicReference<>(); + ControlWriter writer = writer(new FailingWriter(), 1, actorFailure::set); + writer.start(); + + TmuxTransportException failure = + assertThrows(TmuxTransportException.class, () -> writer.exchange("command", PATIENCE)); + + assertEquals(DispatchOutcome.UNKNOWN, failure.outcome()); + writer.join(1_000); + assertEquals(DispatchOutcome.UNKNOWN, actorFailure.get().outcome()); + } + + @Test + void oneDeadlineIncludesAWriteThatNeverReturns() throws Exception { + BlockingWriter output = new BlockingWriter(); + ControlWriter writer = writer(output, 1, ignored -> {}); + writer.start(); + long started = System.nanoTime(); + + TmuxTransportException failure = + assertThrows(TmuxTransportException.class, () -> writer.exchange("active", Duration.ofMillis(100))); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + + assertEquals(DispatchOutcome.UNKNOWN, failure.outcome()); + assertTrue(elapsedMillis < 1_000, "a write added a second timeout: " + elapsedMillis + " ms"); + writer.join(1_000); + } + + @Test + void closeDistinguishesPickedFromQueuedRequests() throws Exception { + BlockingWriter output = new BlockingWriter(); + ControlWriter writer = writer(output, 1, ignored -> {}); + writer.start(); + AtomicReference activeFailure = new AtomicReference<>(); + AtomicReference queuedFailure = new AtomicReference<>(); + Thread active = exchange(writer, "active", PATIENCE, activeFailure); + assertTrue(output.entered.await(1, TimeUnit.SECONDS)); + Thread queued = exchange(writer, "queued", PATIENCE, queuedFailure); + Thread.sleep(100); + + writer.close(); + output.release.countDown(); + writer.join(1_000); + active.join(1_000); + queued.join(1_000); + + assertEquals(DispatchOutcome.UNKNOWN, activeFailure.get().outcome()); + assertEquals(DispatchOutcome.NOT_DISPATCHED, queuedFailure.get().outcome()); + assertEquals( + DispatchOutcome.NOT_DISPATCHED, + assertThrows(TmuxTransportException.class, () -> writer.exchange("late", PATIENCE)) + .outcome()); + } + + @Test + void readerDeathDistinguishesPickedFromQueuedRequests() throws Exception { + BlockingWriter output = new BlockingWriter(); + ControlWriter writer = writer(output, 1, ignored -> {}); + writer.start(); + AtomicReference activeFailure = new AtomicReference<>(); + AtomicReference queuedFailure = new AtomicReference<>(); + Thread active = exchange(writer, "active", PATIENCE, activeFailure); + assertTrue(output.entered.await(1, TimeUnit.SECONDS)); + Thread queued = exchange(writer, "queued", PATIENCE, queuedFailure); + Thread.sleep(100); + + writer.readerEnded(); + output.release.countDown(); + writer.join(1_000); + active.join(1_000); + queued.join(1_000); + + assertEquals(DispatchOutcome.UNKNOWN, activeFailure.get().outcome()); + assertEquals(DispatchOutcome.NOT_DISPATCHED, queuedFailure.get().outcome()); + } + + @Test + void concurrentRequestsKeepAdmissionOrder() throws Exception { + AtomicReference holder = new AtomicReference<>(); + List lines = new ArrayList<>(); + GatedReplyingWriter output = new GatedReplyingWriter(line -> { + synchronized (lines) { + lines.add(line); + } + holder.get().complete(OperationOutcome.COMPLETE, List.of(line)); + }); + ControlWriter writer = writer(output, 3, ignored -> {}); + holder.set(writer); + writer.start(); + AtomicReference firstFailure = new AtomicReference<>(); + AtomicReference secondFailure = new AtomicReference<>(); + AtomicReference thirdFailure = new AtomicReference<>(); + Thread first = exchange(writer, "first", PATIENCE, firstFailure); + assertTrue(output.firstEntered.await(1, TimeUnit.SECONDS)); + Thread second = exchange(writer, "second", PATIENCE, secondFailure); + Thread.sleep(100); + Thread third = exchange(writer, "third", PATIENCE, thirdFailure); + Thread.sleep(100); + output.releaseFirst.countDown(); + + first.join(1_000); + second.join(1_000); + third.join(1_000); + writer.close(); + writer.join(1_000); + + assertEquals(List.of("first", "second", "third"), lines); + assertFalse(firstFailure.get() != null || secondFailure.get() != null || thirdFailure.get() != null); + } + + @Test + void interruptionPreservesCertaintyAndTheInterruptFlag() throws Exception { + BlockingWriter output = new BlockingWriter(); + ControlWriter writer = writer(output, 1, ignored -> {}); + writer.start(); + AtomicReference failure = new AtomicReference<>(); + AtomicReference interrupted = new AtomicReference<>(false); + Thread request = Thread.ofVirtual().start(() -> { + try { + writer.exchange("active", PATIENCE); + } catch (TmuxTransportException e) { + failure.set(e); + interrupted.set(Thread.currentThread().isInterrupted()); + } + }); + assertTrue(output.entered.await(1, TimeUnit.SECONDS)); + + request.interrupt(); + request.join(1_000); + output.release.countDown(); + writer.join(1_000); + + assertEquals(DispatchOutcome.UNKNOWN, failure.get().outcome()); + assertTrue(interrupted.get()); + } + + private static ControlWriter writer(Writer output, int capacity, Consumer failed) { + return new ControlWriter(new BufferedWriter(output), capacity, failed); + } + + private static Thread exchange( + ControlWriter writer, String line, Duration timeout, AtomicReference failure) { + return Thread.ofVirtual().start(() -> { + try { + writer.exchange(line, timeout); + } catch (TmuxTransportException e) { + failure.set(e); + } + }); + } + + private static class BlockingWriter extends Writer { + + final CountDownLatch entered = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + + @Override + public void write(char[] data, int offset, int length) throws IOException { + entered.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted", e); + } + } + + @Override + public void flush() {} + + @Override + public void close() { + release.countDown(); + } + } + + private static final class FailingWriter extends Writer { + + @Override + public void write(char[] data, int offset, int length) throws IOException { + throw new IOException("broken pipe"); + } + + @Override + public void flush() {} + + @Override + public void close() {} + } + + private static final class GatedReplyingWriter extends Writer { + + private final Consumer written; + private final CountDownLatch firstEntered = new CountDownLatch(1); + private final CountDownLatch releaseFirst = new CountDownLatch(1); + private boolean first = true; + + GatedReplyingWriter(Consumer written) { + this.written = written; + } + + @Override + public void write(char[] data, int offset, int length) throws IOException { + if (first) { + first = false; + firstEntered.countDown(); + try { + releaseFirst.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted", e); + } + } + written.accept(new String(data, offset, length).stripTrailing()); + } + + @Override + public void flush() {} + + @Override + public void close() {} + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/control/EventSubscriptionTest.java b/libtmux/src/test/java/io/github/libtmux/control/EventSubscriptionTest.java new file mode 100644 index 0000000..83d21cc --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/control/EventSubscriptionTest.java @@ -0,0 +1,175 @@ +package io.github.libtmux.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +final class EventSubscriptionTest { + + @Test + void capacityMustLeaveRoomForOneValue() { + assertThrows(IllegalArgumentException.class, () -> new EventSubscription(0, ignored -> {})); + assertThrows(IllegalArgumentException.class, () -> new EventSubscription(-1, ignored -> {})); + } + + @Test + void aNegativeWaitIsRejected() { + try (var subscription = new EventSubscription(1, ignored -> {})) { + assertThrows(IllegalArgumentException.class, () -> subscription.next(Duration.ofNanos(-1))); + } + } + + @Test + void aLargeValidWaitDoesNotOverflow() throws Exception { + try (var subscription = new EventSubscription(1, ignored -> {})) { + subscription.offer("ready"); + + assertEquals(Optional.of("ready"), subscription.next(Duration.ofSeconds(Long.MAX_VALUE))); + } + } + + @Test + void nextReturnsBufferedValuesInArrivalOrder() throws Exception { + try (var subscription = new EventSubscription(2, ignored -> {})) { + subscription.offer("first"); + subscription.offer("second"); + + assertEquals(Optional.of("first"), subscription.next(Duration.ZERO)); + assertEquals(Optional.of("second"), subscription.next(Duration.ZERO)); + } + } + + @Test + void aFullBufferDropsTheOldestValueAndCountsTheLoss() throws Exception { + try (var subscription = new EventSubscription(2, ignored -> {})) { + subscription.offer("first"); + subscription.offer("second"); + subscription.offer("third"); + + assertEquals(1, subscription.droppedCount()); + assertEquals(Optional.of("second"), subscription.next(Duration.ZERO)); + assertEquals(Optional.of("third"), subscription.next(Duration.ZERO)); + } + } + + @Test + void nextWaitsUntilAValueArrives() throws Exception { + try (var subscription = new EventSubscription(1, ignored -> {})) { + CountDownLatch entered = new CountDownLatch(1); + FutureTask> waiting = new FutureTask<>(() -> { + entered.countDown(); + return subscription.next(); + }); + Thread consumer = Thread.ofVirtual().start(waiting); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + + assertThrows(TimeoutException.class, () -> waiting.get(100, TimeUnit.MILLISECONDS)); + assertFalse(waiting.isDone()); + + subscription.offer("arrived"); + assertEquals(Optional.of("arrived"), waiting.get(5, TimeUnit.SECONDS)); + consumer.join(); + } + } + + @Test + void closeIsTerminalAndDiscardsBufferedValues() throws Exception { + var subscription = new EventSubscription(2, ignored -> {}); + subscription.offer("buffered"); + + subscription.close(); + subscription.offer("after-close"); + + assertTrue(subscription.isClosed()); + assertEquals(Optional.empty(), subscription.next(Duration.ZERO)); + assertEquals(Optional.empty(), subscription.next()); + } + + @Test + void closeWakesAWaitingConsumer() throws Exception { + var subscription = new EventSubscription(1, ignored -> {}); + CountDownLatch entered = new CountDownLatch(1); + FutureTask> waiting = new FutureTask<>(() -> { + entered.countDown(); + return subscription.next(); + }); + Thread consumer = Thread.ofVirtual().start(waiting); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> waiting.get(100, TimeUnit.MILLISECONDS)); + + subscription.close(); + + assertEquals(Optional.empty(), waiting.get(1, TimeUnit.SECONDS)); + } finally { + waiting.cancel(true); + consumer.join(); + subscription.close(); + } + } + + @Test + void closeRemovesTheSubscriberExactlyOnce() { + AtomicInteger removals = new AtomicInteger(); + var subscription = new EventSubscription(1, ignored -> removals.incrementAndGet()); + + subscription.close(); + subscription.close(); + + assertEquals(1, removals.get()); + } + + @Test + void everyCloseWaitsForDeterministicRemoval() throws Exception { + CountDownLatch removalStarted = new CountDownLatch(1); + CountDownLatch allowRemoval = new CountDownLatch(1); + var subscription = new EventSubscription(1, ignored -> { + removalStarted.countDown(); + try { + allowRemoval.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + }); + FutureTask firstClose = closeTask(subscription); + FutureTask secondClose = closeTask(subscription); + Thread first = Thread.ofVirtual().start(firstClose); + Thread second = null; + try { + assertTrue(removalStarted.await(5, TimeUnit.SECONDS)); + second = Thread.ofVirtual().start(secondClose); + + assertThrows(TimeoutException.class, () -> secondClose.get(100, TimeUnit.MILLISECONDS)); + + allowRemoval.countDown(); + assertEquals(null, firstClose.get(1, TimeUnit.SECONDS)); + assertEquals(null, secondClose.get(1, TimeUnit.SECONDS)); + } finally { + allowRemoval.countDown(); + firstClose.cancel(true); + secondClose.cancel(true); + first.join(); + if (second != null) { + second.join(); + } + } + } + + private static FutureTask closeTask(EventSubscription subscription) { + return new FutureTask<>(() -> { + subscription.close(); + return null; + }); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java index 9708e7b..2aafcca 100644 --- a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java +++ b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java @@ -4,9 +4,17 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -14,12 +22,19 @@ import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; /** * The contract every caller depends on, driven with an ordinary child rather than tmux. @@ -37,6 +52,10 @@ private static CommandRequest shell(String script, Duration timeout) { return new CommandRequest(List.of("/bin/sh"), List.of("-c", script), timeout); } + private static CommandRequest bash(String script, Duration timeout) { + return new CommandRequest(List.of("/bin/bash"), List.of("-c", script), timeout); + } + // ------------------------------------------------------------------ channels and exit status @Test @@ -134,8 +153,8 @@ void anExecutableThatDoesNotExistIsNotDispatched() { @Test void aChildThatOutlivesItsDeadlineIsKilledAndReportedUnknown() { try (ProcessTransport transport = new ProcessTransport()) { - TmuxTransportException failure = assertThrows( - TmuxTransportException.class, () -> transport.execute(shell("sleep 30", Duration.ofMillis(250)))); + TmuxTimeoutException failure = assertThrows( + TmuxTimeoutException.class, () -> transport.execute(shell("sleep 30", Duration.ofMillis(250)))); assertEquals( DispatchOutcome.UNKNOWN, @@ -144,6 +163,67 @@ void aChildThatOutlivesItsDeadlineIsKilledAndReportedUnknown() { } } + @Test + void aHugePositiveTimeoutDoesNotOverflowTheDeadline() { + try (ProcessTransport transport = new ProcessTransport()) { + CommandResult result = transport.execute(shell("echo saturated", Duration.ofSeconds(Long.MAX_VALUE))); + + assertEquals(List.of("saturated"), result.stdout()); + } + } + + @Test + void aRequestExpiredBeforeAdmissionNeverCrossesTheProcessBoundary() { + AtomicInteger clockReads = new AtomicInteger(); + AtomicInteger starts = new AtomicInteger(); + ProcessTransport.ProcessStarter starter = command -> { + starts.incrementAndGet(); + return new ProcessBuilder(command).start(); + }; + try (ProcessTransport transport = + new ProcessTransport(1, 1_024, starter, () -> clockReads.getAndIncrement() == 0 ? 10L : 12L)) { + TmuxTimeoutException failure = assertThrows( + TmuxTimeoutException.class, + () -> transport.execute(shell("echo must-not-run", Duration.ofNanos(1)))); + + assertEquals(DispatchOutcome.NOT_DISPATCHED, failure.outcome()); + assertEquals(0, starts.get(), "an already-expired request reached Process.start"); + } + } + + @Test + void outputBeyondTheConfiguredChannelLimitEndsTheChildAndReclaimsTheTransport() { + try (ProcessTransport transport = new ProcessTransport(1, 1_024)) { + TmuxTransportException failure = assertThrows( + TmuxTransportException.class, + () -> transport.execute(shell("head -c 4096 /dev/zero | tr '\\0' x", GENEROUS))); + + assertEquals(DispatchOutcome.UNKNOWN, failure.outcome()); + assertTrue(String.valueOf(failure.getMessage()).contains("1024 byte channel limit")); + assertEquals( + List.of("reclaimed"), + transport.execute(shell("echo reclaimed", GENEROUS)).stdout()); + } + } + + @Test + void outputOverflowIsReportedBeforeATermIgnoringChildsDeadline() { + try (ProcessTransport transport = new ProcessTransport(1, 1_024)) { + long started = System.nanoTime(); + + TmuxTransportException failure = assertThrows( + TmuxTransportException.class, + () -> transport.execute( + bash("trap '' TERM; while :; do printf 1234567890; done", Duration.ofSeconds(5)))); + + assertFalse(failure instanceof TmuxTimeoutException, "the pump observed overflow before the deadline"); + assertTrue(String.valueOf(failure.getMessage()).contains("1024 byte channel limit")); + assertTrue( + Duration.ofNanos(System.nanoTime() - started).compareTo(Duration.ofSeconds(2)) < 0, + "overflow was not acted on promptly"); + } + } + @Test void anInterruptedCallerReportsUnknownAndKeepsItsInterrupt() throws InterruptedException { try (ProcessTransport transport = new ProcessTransport()) { @@ -217,6 +297,125 @@ void closeKillingARunningChildReportsUnknownRatherThanASignalExit() throws Inter assertEquals(DispatchOutcome.UNKNOWN, failure.outcome()); } + @Test + void concurrentCloseWaitsForTheFirstCloseToFinish() throws Exception { + GatedInputStream stdout = new GatedInputStream(); + StubProcess process = new StubProcess(stdout); + ProcessTransport transport = new ProcessTransport(1, 1_024, command -> process, System::nanoTime); + + try (ExecutorService callers = Executors.newVirtualThreadPerTaskExecutor()) { + Future request = callers.submit(() -> transport.execute(shell("ignored", GENEROUS))); + assertTrue(stdout.readStarted.await(5, TimeUnit.SECONDS), "the request never reached its pipe read"); + + Future firstClose = callers.submit(transport::close); + assertTrue(process.destroyed.await(5, TimeUnit.SECONDS), "the first close never began cleanup"); + Future secondClose = callers.submit(transport::close); + + boolean returnedBeforeCleanup; + try { + secondClose.get(100, TimeUnit.MILLISECONDS); + returnedBeforeCleanup = true; + } catch (TimeoutException expected) { + returnedBeforeCleanup = false; + } finally { + stdout.release(); + } + + firstClose.get(10, TimeUnit.SECONDS); + secondClose.get(10, TimeUnit.SECONDS); + assertThrows(ExecutionException.class, () -> request.get(10, TimeUnit.SECONDS)); + assertFalse(returnedBeforeCleanup, "a concurrent close returned while cleanup was still running"); + } finally { + stdout.release(); + transport.close(); + } + } + + @Test + void interruptedCloseCannotReturnBeforeAnAdmittedLaunchIsPublished() throws Exception { + CountDownLatch launchEntered = new CountDownLatch(1); + CountDownLatch releaseLaunch = new CountDownLatch(1); + StubProcess process = new StubProcess(new ByteArrayInputStream(new byte[0])); + ProcessTransport.ProcessStarter starter = command -> { + launchEntered.countDown(); + try { + releaseLaunch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("launch interrupted", e); + } + return process; + }; + ProcessTransport transport = new ProcessTransport(1, 1_024, starter, System::nanoTime); + FutureTask request = new FutureTask<>(() -> transport.execute(shell("ignored", GENEROUS))); + Thread caller = Thread.ofVirtual().start(request); + BlockingQueue closeOutcome = new ArrayBlockingQueue<>(1); + + try { + assertTrue(launchEntered.await(5, TimeUnit.SECONDS), "the launch seam was never entered"); + Thread closer = Thread.ofVirtual().start(() -> { + try { + transport.close(); + closeOutcome.add("closed"); + } catch (RuntimeException e) { + closeOutcome.add(e); + } + }); + assertTrue(awaitClosed(transport), "close never barred new requests"); + + closer.interrupt(); + Object earlyOutcome = closeOutcome.poll(100, TimeUnit.MILLISECONDS); + releaseLaunch.countDown(); + closer.join(TimeUnit.SECONDS.toMillis(10)); + caller.join(TimeUnit.SECONDS.toMillis(10)); + + assertNull(earlyOutcome, "close returned before the admitted launch was published"); + assertEquals("closed", closeOutcome.poll(10, TimeUnit.SECONDS)); + assertThrows(ExecutionException.class, () -> request.get(10, TimeUnit.SECONDS)); + } finally { + releaseLaunch.countDown(); + caller.join(TimeUnit.SECONDS.toMillis(10)); + transport.close(); + } + } + + @Test + void closeIsBoundedWhenADescendantInheritsTheChildPipes(@TempDir Path directory) throws Exception { + Path descendantPid = directory.resolve("descendant.pid"); + String script = "trap 'exit 0' TERM; " + + "(trap '' HUP TERM; echo \"$BASHPID\" > \"$1.tmp\"; " + + "mv \"$1.tmp\" \"$1\"; exec sleep 30) & wait"; + CommandRequest request = new CommandRequest( + List.of("/bin/bash"), List.of("-c", script, "probe", descendantPid.toString()), GENEROUS); + ProcessTransport transport = new ProcessTransport(); + + try (ExecutorService callers = Executors.newVirtualThreadPerTaskExecutor()) { + Future running = callers.submit(() -> transport.execute(request)); + assertTrue(awaitFile(descendantPid), "the pipe-inheriting descendant never started"); + ProcessHandle descendant = ProcessHandle.of( + Long.parseLong(Files.readString(descendantPid).trim())) + .orElseThrow(); + Future closing = callers.submit(transport::close); + + boolean bounded; + try { + closing.get(2, TimeUnit.SECONDS); + bounded = true; + } catch (TimeoutException expected) { + bounded = false; + } finally { + descendant.destroyForcibly(); + descendant.onExit().get(10, TimeUnit.SECONDS); + } + + closing.get(10, TimeUnit.SECONDS); + assertThrows(ExecutionException.class, () -> running.get(10, TimeUnit.SECONDS)); + assertTrue(bounded, "close waited for an inherited pipe writer instead of closing its read ends"); + } finally { + transport.close(); + } + } + // ------------------------------------------------------------------------------- concurrency @Test @@ -247,6 +446,74 @@ void moreCallersThanPermitsAllFinishWithBothPipesFloodedPastCapacity() throws Ex } } + @Test + void admissionTimeoutIsTypedAndKnownNotDispatched(@TempDir Path directory) throws Exception { + ProcessTransport transport = new ProcessTransport(1); + try { + Path started = directory.resolve("started"); + CommandRequest occupying = new CommandRequest( + List.of("/bin/sh"), + List.of("-c", "touch \"$1\"; while :; do :; done", "probe", started.toString()), + GENEROUS); + FutureTask first = new FutureTask<>(() -> transport.execute(occupying)); + Thread caller = Thread.ofVirtual().start(first); + try { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (!Files.exists(started) && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertTrue(Files.exists(started), "the first request never occupied admission"); + + TmuxTimeoutException failure = assertThrows( + TmuxTimeoutException.class, + () -> transport.execute(shell("echo never-started", Duration.ofMillis(50)))); + + assertEquals(DispatchOutcome.NOT_DISPATCHED, failure.outcome()); + } finally { + transport.close(); + caller.join(); + assertThrows(java.util.concurrent.ExecutionException.class, first::get); + } + } finally { + transport.close(); + } + } + + @Test + void interruptedReclamationReturnsItsAdmissionPermit() throws Exception { + GatedInputStream stdout = new GatedInputStream(); + StubProcess firstProcess = new StubProcess(stdout); + AtomicInteger starts = new AtomicInteger(); + ProcessTransport.ProcessStarter starter = + command -> starts.getAndIncrement() == 0 ? firstProcess : new ProcessBuilder(command).start(); + ProcessTransport transport = new ProcessTransport(1, 1_024, starter, System::nanoTime); + FutureTask first = new FutureTask<>(() -> transport.execute(shell("ignored", GENEROUS))); + Thread caller = Thread.ofVirtual().start(first); + + try { + assertTrue(stdout.readStarted.await(5, TimeUnit.SECONDS), "the first request never began draining"); + caller.interrupt(); + assertTrue( + firstProcess.destroyed.await(5, TimeUnit.SECONDS), + "the interrupted caller never began process cleanup"); + assertTrue(awaitReclamation(first, caller), "the interrupted caller never reached drain reclamation"); + stdout.release(); + caller.join(TimeUnit.SECONDS.toMillis(10)); + + assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS)); + assertEquals( + List.of("reclaimed"), + transport + .execute(shell("echo reclaimed", Duration.ofSeconds(2))) + .stdout(), + "the interrupted request permanently consumed the only permit"); + } finally { + stdout.release(); + caller.join(TimeUnit.SECONDS.toMillis(10)); + transport.close(); + } + } + // ---------------------------------------------------------------------------- process hygiene /** @@ -300,6 +567,148 @@ private static Optional marked(String marker) { .findAny(); } + private static boolean awaitFile(Path file) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!Files.exists(file) && System.nanoTime() < deadline) { + Thread.sleep(10); + } + return Files.exists(file); + } + + private static boolean awaitClosed(ProcessTransport transport) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + try { + transport.execute(shell("true", Duration.ofMillis(1))); + } catch (IllegalStateException expected) { + return true; + } catch (TmuxTransportException expected) { + assertEquals(DispatchOutcome.NOT_DISPATCHED, expected.outcome()); + } + Thread.sleep(1); + } + return false; + } + + private static boolean awaitReclamation(Future request, Thread caller) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + if (request.isDone() || caller.getState() == Thread.State.TIMED_WAITING) { + return true; + } + Thread.sleep(1); + } + return false; + } + + private static final class GatedInputStream extends InputStream { + private final CountDownLatch readStarted = new CountDownLatch(1); + private final CountDownLatch released = new CountDownLatch(1); + + @Override + public int read() { + readStarted.countDown(); + boolean interrupted = false; + while (released.getCount() > 0) { + try { + released.await(); + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + return -1; + } + + @Override + public int read(byte[] bytes, int offset, int length) { + return read(); + } + + void release() { + released.countDown(); + } + } + + private static final class StubProcess extends Process { + private final OutputStream stdin = new ByteArrayOutputStream(); + private final InputStream stdout; + private final InputStream stderr = new ByteArrayInputStream(new byte[0]); + private final AtomicBoolean alive = new AtomicBoolean(true); + private final CountDownLatch exited = new CountDownLatch(1); + private final CountDownLatch destroyed = new CountDownLatch(1); + private final CompletableFuture exit = new CompletableFuture<>(); + + StubProcess(InputStream stdout) { + this.stdout = stdout; + } + + @Override + public OutputStream getOutputStream() { + return stdin; + } + + @Override + public InputStream getInputStream() { + return stdout; + } + + @Override + public InputStream getErrorStream() { + return stderr; + } + + @Override + public int waitFor() throws InterruptedException { + exited.await(); + return 0; + } + + @Override + public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException { + return exited.await(timeout, unit); + } + + @Override + public int exitValue() { + if (alive.get()) { + throw new IllegalThreadStateException(); + } + return 0; + } + + @Override + public void destroy() { + finish(); + } + + @Override + public Process destroyForcibly() { + finish(); + return this; + } + + @Override + public boolean isAlive() { + return alive.get(); + } + + @Override + public CompletableFuture onExit() { + return exit; + } + + private void finish() { + destroyed.countDown(); + if (alive.compareAndSet(true, false)) { + exited.countDown(); + exit.complete(this); + } + } + } + @Test void aBoundBelowOneIsRejected() { assertThrows(IllegalArgumentException.class, () -> new ProcessTransport(0)); From 56cd5c25c0bec46262b7fada38176c043f0c0e2c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:27:52 -0500 Subject: [PATCH 02/77] Query(fix[wire]): Make filters closed values why: Public filter nodes could change after construction, valid relations were missing from built-in models, and malformed wire documents escaped the schema error boundary. what: - Give built-in fields stable handles and immutable value semantics - Complete cyclic relation models and operator coverage - Fail closed on malformed JSON and document Java regex semantics --- docs/guide/filtering.md | 23 +- docs/spikes/04-query-metamodel.md | 10 +- .../libtmux/it/FilteringIntegrationTest.java | 10 +- libtmux-jackson/README.md | 38 +-- libtmux-jackson/build.gradle.kts | 2 +- .../io/github/libtmux/jackson/FilterJson.java | 153 ++++++++---- .../github/libtmux/jackson/FilterModel.java | 112 +++++++-- .../github/libtmux/jackson/LibTmuxModels.java | 13 +- .../github/libtmux/jackson/package-info.java | 7 +- .../jackson/filter-expr-v1.schema.json | 2 +- .../libtmux/jackson/FilterJsonTest.java | 232 ++++++++++++++++-- .../main/java/io/github/libtmux/Client_.java | 10 +- .../main/java/io/github/libtmux/Pane_.java | 17 +- .../main/java/io/github/libtmux/Session_.java | 21 +- .../main/java/io/github/libtmux/Window_.java | 28 ++- .../github/libtmux/query/EntityMetamodel.java | 38 --- .../github/libtmux/query/FieldProvenance.java | 54 ---- .../io/github/libtmux/query/FieldRef.java | 42 ++-- .../java/io/github/libtmux/query/Fields.java | 94 +++++-- .../io/github/libtmux/query/FilterExpr.java | 62 ++++- .../io/github/libtmux/query/Operator.java | 34 +++ .../github/libtmux/query/CompileFailTest.java | 57 ----- .../libtmux/query/FieldProvenanceTest.java | 48 ---- .../github/libtmux/query/FilterExprTest.java | 98 ++++++++ .../libtmux/query/MetamodelConformance.java | 38 +-- .../query/MetamodelConformanceTest.java | 37 ++- .../java/io/github/libtmux/query/Model.java | 24 +- 27 files changed, 852 insertions(+), 452 deletions(-) delete mode 100644 libtmux/src/main/java/io/github/libtmux/query/EntityMetamodel.java delete mode 100644 libtmux/src/main/java/io/github/libtmux/query/FieldProvenance.java delete mode 100644 libtmux/src/test/java/io/github/libtmux/query/FieldProvenanceTest.java diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index 83ef446..678d36e 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -74,26 +74,29 @@ This snippet is exercised by `FilterJsonTest` rather than `ExamplesTest`, since the core suite does not depend on Jackson: ```java -String json = FilterJson.writeString(Pane_.command().startsWith("nv"), "pane"); +String json = FilterJson.writeString( + Pane_.command().startsWith("nv"), LibTmuxModels.pane()); FilterExpr restored = FilterJson.readString(json, LibTmuxModels.pane()); restored.describe(); // → pane_current_command starts-with nv ``` -Only expressions built from a metamodel can be written. A field built from a -lambda has a caller-chosen name and an accessor nobody else can resolve, so it -has no wire identity, and refusing it is what makes this a format rather than a -hope. +Only exact handles declared by the supplied model can be written. A field may +borrow a declared name while carrying a different accessor, so the name alone +has no wire identity. Refusing it is what makes this a format rather than a hope. -Reading is validated against a model: a document claiming `pane` cannot be read -as a `FilterExpr`. Unknown schema versions, models, fields, relations, -operators and node shapes all fail closed. +Writing and reading are validated against a model: a document claiming `pane` +cannot be read as a `FilterExpr`, and an expression cannot borrow a field +or relation name its model did not declare. Unknown schema versions, models, +fields, relations, operators, properties and node shapes all fail closed. ## Who the wire form is actually for Field and operator identifiers are tmux's own format names — `pane_current_command`, -not anything Java calls a field. So the document means the same thing to every -port of libtmux, and to a caller that is not a Java program at all. +not anything Java calls a field. That makes most of the document independent of +Java names. The `matches` operand is the exception: its syntax and numeric flags +are those of `java.util.regex.Pattern`. A non-Java consumer must reproduce those +semantics or reject that operator. `libtmux-mcp` is the worked example. Its `tmux_list_panes` tool takes an optional `filter`, which is one of these documents: diff --git a/docs/spikes/04-query-metamodel.md b/docs/spikes/04-query-metamodel.md index 53b7292..ea4bb37 100644 --- a/docs/spikes/04-query-metamodel.md +++ b/docs/spikes/04-query-metamodel.md @@ -2,11 +2,11 @@ ## Status -Complete. The pushdown architecture gate, the JSON form, and the -handle-generation decision are recorded below. - -Settled: the expression semantics, the compile-time safety argument, the edge -parser, the JSON form, the field model, and how the typed handles get written. +Historical evidence. The expression semantics and pushdown measurements remain +useful, but the provenance and wire-authority design below was superseded. +Current code uses opaque identity-based handles and consumer-owned exact-handle +catalogs; JSON writing requires a `FilterModel`, and caller-owned model ids are +namespaced. See the [filtering guide](../guide/filtering.md) for the supported API. ## What is settled diff --git a/integration-tests/src/test/java/io/github/libtmux/it/FilteringIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/FilteringIntegrationTest.java index 328d1be..796f31d 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/FilteringIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/FilteringIntegrationTest.java @@ -1,7 +1,6 @@ package io.github.libtmux.it; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -131,15 +130,15 @@ void anExpressionSaysWhatItIs(Server server) { FilterExpr expression = Window_.name().startsWith("edit").and(Window_.active().isTrue()); - String rendered = expression.toString(); + String rendered = expression.describe(); assertTrue(rendered.contains("window_name"), "a lambda could not say this: " + rendered); assertTrue(rendered.contains("window_active"), rendered); } /** - * A caller's accessor may carry a canonical field's name and answer a different question, so - * lowering it by that name would change the answer and not just where it was computed. + * A caller's accessor may carry a built-in field's name and answer a different question, so + * local evaluation must use the accessor rather than infer meaning from the name. */ @Test void aCallerBuiltFieldIsAnsweredHereRatherThanByTmux(Server server) { @@ -147,7 +146,6 @@ void aCallerBuiltFieldIsAnsweredHereRatherThanByTmux(Server server) { Fields.TextField prefixed = Fields.text("pane_current_command", (Pane pane) -> "shell-" + pane.currentCommand()); - assertFalse(prefixed.ref().provenance().lowerable()); assertEquals( 1, server.panes().stream().filter(prefixed.is("shell-" + running)).count()); @@ -156,6 +154,6 @@ void aCallerBuiltFieldIsAnsweredHereRatherThanByTmux(Server server) { server.panes().stream() .filter(Pane_.command().is("shell-" + running)) .count(), - "the canonical field of the same name answers differently over these rows"); + "the built-in field of the same name answers differently over these rows"); } } diff --git a/libtmux-jackson/README.md b/libtmux-jackson/README.md index c1f16b4..0e8ad8a 100644 --- a/libtmux-jackson/README.md +++ b/libtmux-jackson/README.md @@ -24,7 +24,8 @@ dependencies { ## Write one ```java -String json = FilterJson.writeString(Pane_.command().startsWith("nvim"), "pane"); +String json = FilterJson.writeString( + Pane_.command().startsWith("nvim"), LibTmuxModels.pane()); json.contains("libtmux.filter/1"); // → true json.contains("pane_current_command"); // → true @@ -33,7 +34,8 @@ json.contains("pane_current_command"); // → true ## Read one back ```java -String json = FilterJson.writeString(Pane_.command().startsWith("nvim"), "pane"); +String json = FilterJson.writeString( + Pane_.command().startsWith("nvim"), LibTmuxModels.pane()); FilterExpr restored = FilterJson.readString(json, LibTmuxModels.pane()); @@ -46,7 +48,7 @@ It is a `Predicate`, so it drops straight into a stream over a capture you alrea hold — reading it from JSON changes nothing about how it is applied: ```java -String json = FilterJson.writeString(Pane_.active().isTrue(), "pane"); +String json = FilterJson.writeString(Pane_.active().isTrue(), LibTmuxModels.pane()); FilterExpr active = FilterJson.readString(json, LibTmuxModels.pane()); server.panes().stream().filter(active).toList().size(); // → 1 @@ -71,32 +73,34 @@ The document those calls produce: **Field and operator ids are tmux's own format names.** `pane_current_command`, not `command`; `session_name`, not `name`. Java class names and record component -names are deliberately *not* wire identifiers. That is what lets the same document -mean the same thing to every port of libtmux — and to a model, which is how -[`libtmux-mcp`](../libtmux-mcp/) accepts filters. +names are deliberately *not* wire identifiers. The exception is `matches`: its +pattern syntax and numeric flags are those of `java.util.regex.Pattern`, so a +non-Java consumer must reproduce those semantics or reject that operator. +[`libtmux-mcp`](../libtmux-mcp/) accepts these documents directly. ## What it refuses, and why -**A field built from a lambda cannot be written.** Only expressions built from a -metamodel have wire identity: +**An undeclared field cannot be written.** Only the exact handles declared by the +supplied model have wire identity: ```java FilterExpr mine = Fields.text("session_name", (Session s) -> s.name().toLowerCase()) .is("demo"); -FilterJson.writeString(mine, "session"); +FilterJson.writeString(mine, LibTmuxModels.session()); ``` -That field has a caller-chosen name and an accessor nobody else can resolve. -Writing it would produce a document that *looks* like a filter on -`#{session_name}` and answers a different question. Refusing it is what makes this -a format rather than a hope. +That field has the name of a declared field and a different accessor. Writing it +would produce a document that *looks* like a filter on `#{session_name}` and +answers a different question. Refusing it is what makes this a format rather than +a hope. -**Reading is validated against a model.** A document claiming `pane` cannot be -read as a `FilterExpr`. Unknown schema versions, models, fields, -relations, operators and node shapes all fail closed, with a `SchemaException` -naming what was wrong. +**Writing and reading are validated against a model.** A document claiming `pane` +cannot be read as a `FilterExpr`, and an expression cannot borrow the name +of a field or relation its model did not declare. Unknown schema versions, models, +fields, relations, operators, properties and node shapes all fail closed, with a +`SchemaException` naming what was wrong. ## The schema diff --git a/libtmux-jackson/build.gradle.kts b/libtmux-jackson/build.gradle.kts index 041fe20..e92420e 100644 --- a/libtmux-jackson/build.gradle.kts +++ b/libtmux-jackson/build.gradle.kts @@ -2,7 +2,7 @@ plugins { id("libtmux.published-library") } dependencies { api(project(":libtmux")) - implementation(libs.jackson.databind) + api(libs.jackson.databind) } tasks.jar { manifest { attributes("Automatic-Module-Name" to "io.github.libtmux.jackson") } } diff --git a/libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterJson.java b/libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterJson.java index 2d341cd..2759498 100644 --- a/libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterJson.java +++ b/libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterJson.java @@ -1,25 +1,27 @@ package io.github.libtmux.jackson; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.github.libtmux.query.FieldKind; -import io.github.libtmux.query.FieldProvenance; import io.github.libtmux.query.FieldRef; import io.github.libtmux.query.FilterExpr; import io.github.libtmux.query.Operator; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.regex.Pattern; /** * The versioned wire form of a filter expression. * - *

Writing needs no model: the expression already carries the ids. Reading does, because an - * expression holds accessors and navigators that no document can carry, and because a document - * claiming one model must not be read as another. + *

Writing and reading both require a model. Expressions hold executable accessors and navigators, + * while documents carry only their ids; the model is the authority that binds one to the other. + * Regex operands retain {@link java.util.regex.Pattern} syntax and flag bits. * *

Everything unrecognised fails. An expression read wrongly does not announce itself — it * silently matches the wrong things, and a caller who wanted a filter gets one, just not theirs. @@ -29,27 +31,29 @@ public final class FilterJson { /** The schema this version reads and writes. Immutable once published. */ public static final String SCHEMA = "libtmux.filter/1"; - private static final ObjectMapper JSON = new ObjectMapper(); + private static final JsonMapper JSON = JsonMapper.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); private FilterJson() {} /** - * Writes an expression as a document naming the model it filters. + * Writes an expression as a document for the model that declared all of its fields and relations. * - * @throws SchemaException if the expression uses a field built from a lambda, which has a - * caller-chosen name and an accessor nobody else can resolve, so it has no wire identity + * @throws SchemaException if any field or relation is not the exact handle declared by the model */ - public static ObjectNode write(FilterExpr expression, String modelId) { + public static ObjectNode write(FilterExpr expression, FilterModel model) { ObjectNode document = JSON.createObjectNode(); document.put("schema", SCHEMA); - document.put("model", modelId); - document.set("expr", node(expression)); + document.put("model", model.id()); + document.set("expr", node(expression, model)); return document; } /** Writes an expression as compact JSON text. */ - public static String writeString(FilterExpr expression, String modelId) { - return write(expression, modelId).toString(); + public static String writeString(FilterExpr expression, FilterModel model) { + return write(expression, model).toString(); } /** @@ -60,6 +64,7 @@ public static String writeString(FilterExpr expression, String modelId) { */ public static FilterExpr read(JsonNode document, FilterModel model) { require(document.isObject(), "the document is not an object"); + requireOnly(document, "document", "schema", "model", "expr"); String schema = text(document, "schema"); if (!SCHEMA.equals(schema)) { throw new SchemaException("unknown filter schema '" + schema + "', this reads " + SCHEMA); @@ -74,7 +79,11 @@ public static FilterExpr read(JsonNode document, FilterModel model) { /** Reads a document from JSON text. */ public static FilterExpr readString(String json, FilterModel model) { try { - return read(JSON.readTree(json), model); + JsonNode document = JSON.readTree(json); + if (document == null) { + throw new SchemaException("the document is empty"); + } + return read(document, model); } catch (com.fasterxml.jackson.core.JacksonException e) { throw new SchemaException("the document is not readable JSON: " + e.getOriginalMessage()); } @@ -82,53 +91,68 @@ public static FilterExpr readString(String json, FilterModel model) { // ------------------------------------------------------------------------------------ write - private static ObjectNode node(FilterExpr expression) { + private static ObjectNode node(FilterExpr expression, FilterModel model) { ObjectNode node = JSON.createObjectNode(); switch (expression) { case FilterExpr.And and -> { node.put("node", "and"); - node.set("operands", operands(and.operands())); + node.set("operands", operands(and.operands(), model)); } case FilterExpr.Or or -> { node.put("node", "or"); - node.set("operands", operands(or.operands())); + node.set("operands", operands(or.operands(), model)); } case FilterExpr.Not not -> { node.put("node", "not"); - node.set("operand", node(not.operand())); + node.set("operand", node(not.operand(), model)); } case FilterExpr.Compare compare -> { FieldRef field = compare.field(); - if (!(field.provenance() instanceof FieldProvenance.Canonical)) { - throw new SchemaException( - "field '" + field.id() + "' was built from a lambda and has no wire identity"); - } + requireSame(field, model.field(field.id()), "field", field.id(), model); node.put("node", "compare"); node.put("field", field.id()); node.put("op", wire(compare.operator())); node.set("value", operand(compare.operand())); } case FilterExpr.ToMany toMany -> { + var handle = toMany.relation(); + FilterModel.Relation relation = model.toMany(handle.id()); + requireSame(handle, relation.toMany(), "to-many relation", handle.id(), model); node.put("node", "to_many"); - node.put("relation", toMany.relation()); + node.put("relation", handle.id()); node.put("quantifier", toMany.quantifier().name().toLowerCase(Locale.ROOT)); - node.set("predicate", node(toMany.predicate())); + node.set("predicate", node(toMany.predicate(), relation.target())); } case FilterExpr.ToOne toOne -> { + var handle = toOne.relation(); + FilterModel.Relation relation = model.toOne(handle.id()); + requireSame(handle, relation.toOne(), "to-one relation", handle.id(), model); node.put("node", "to_one"); - node.put("relation", toOne.relation()); - node.set("predicate", node(toOne.predicate())); + node.put("relation", handle.id()); + node.set("predicate", node(toOne.predicate(), relation.target())); } } return node; } - private static ArrayNode operands(List> expressions) { + private static ArrayNode operands(List> expressions, FilterModel model) { ArrayNode array = JSON.createArrayNode(); - expressions.forEach(operand -> array.add(node(operand))); + expressions.forEach(operand -> array.add(node(operand, model))); return array; } + @SuppressWarnings("ReferenceEquality") + private static void requireSame( + Object actual, + @org.jspecify.annotations.Nullable Object declared, + String kind, + String id, + FilterModel model) { + if (actual != declared) { + throw new SchemaException(kind + " '" + id + "' is not the handle declared by model '" + model.id() + "'"); + } + } + private static JsonNode operand(Object value) { return switch (value) { case String text -> JSON.getNodeFactory().textNode(text); @@ -164,12 +188,30 @@ private static FilterExpr expression( } String kind = text(node, "node"); return switch (kind) { - case "and" -> FilterExpr.and(branches(node, model)); - case "or" -> FilterExpr.or(branches(node, model)); - case "not" -> new FilterExpr.Not<>(expression(node.get("operand"), model)); - case "compare" -> compare(node, model); - case "to_many" -> toMany(node, model); - case "to_one" -> toOne(node, model); + case "and" -> { + requireOnly(node, "and node", "node", "operands"); + yield FilterExpr.and(branches(node, model)); + } + case "or" -> { + requireOnly(node, "or node", "node", "operands"); + yield FilterExpr.or(branches(node, model)); + } + case "not" -> { + requireOnly(node, "not node", "node", "operand"); + yield new FilterExpr.Not<>(expression(node.get("operand"), model)); + } + case "compare" -> { + requireOnly(node, "comparison node", "node", "field", "op", "value"); + yield compare(node, model); + } + case "to_many" -> { + requireOnly(node, "to-many node", "node", "relation", "quantifier", "predicate"); + yield toMany(node, model); + } + case "to_one" -> { + requireOnly(node, "to-one node", "node", "relation", "predicate"); + yield toOne(node, model); + } default -> throw new SchemaException("unknown node kind '" + kind + "'"); }; } @@ -191,28 +233,30 @@ private static FilterExpr compare(JsonNode node, FilterModel model) { if (value == null) { throw new SchemaException("a comparison has no value"); } - return new FilterExpr.Compare<>(field, operator, value(value, field.kind(), operator)); + try { + return new FilterExpr.Compare<>(field, operator, value(value, field.kind(), operator)); + } catch (IllegalArgumentException e) { + throw new SchemaException("invalid comparison: " + e.getMessage()); + } } private static FilterExpr toMany(JsonNode node, FilterModel model) { FilterModel.Relation relation = cast(model.toMany(text(node, "relation"))); - var navigate = relation.toMany(); - if (navigate == null) { - throw new SchemaException("a to-many relation has no navigator"); + var handle = relation.toMany(); + if (handle == null) { + throw new SchemaException("a to-many relation has no handle"); } FilterExpr.Quantifier quantifier = quantifier(text(node, "quantifier")); - return new FilterExpr.ToMany<>( - text(node, "relation"), navigate, quantifier, expression(node.get("predicate"), relation.target())); + return new FilterExpr.ToMany<>(handle, quantifier, expression(node.get("predicate"), relation.target())); } private static FilterExpr toOne(JsonNode node, FilterModel model) { FilterModel.Relation relation = cast(model.toOne(text(node, "relation"))); - var navigate = relation.toOne(); - if (navigate == null) { - throw new SchemaException("a to-one relation has no navigator"); + var handle = relation.toOne(); + if (handle == null) { + throw new SchemaException("a to-one relation has no handle"); } - return new FilterExpr.ToOne<>( - text(node, "relation"), navigate, expression(node.get("predicate"), relation.target())); + return new FilterExpr.ToOne<>(handle, expression(node.get("predicate"), relation.target())); } /** @@ -227,7 +271,13 @@ private static Object value(JsonNode value, FieldKind kind, Operator operator) { return List.copyOf(values); } if (operator == Operator.MATCHES) { - require(value.isObject() && value.hasNonNull("pattern"), "a regex comparison needs a pattern"); + require( + value.isObject() + && value.hasNonNull("pattern") + && value.get("pattern").isTextual(), + "a regex comparison needs a string pattern"); + requireOnly(value, "regex operand", "pattern", "flags"); + require(!value.has("flags") || value.get("flags").isInt(), "regex flags must be an integer"); return Pattern.compile( value.get("pattern").asText(), value.path("flags").asInt(0)); } @@ -277,6 +327,15 @@ private static String text(JsonNode node, String field) { return value.asText(); } + private static void requireOnly(JsonNode node, String what, String... allowedNames) { + Set allowed = Set.of(allowedNames); + node.fieldNames().forEachRemaining(name -> { + if (!allowed.contains(name)) { + throw new SchemaException(what + " has unknown property '" + name + "'"); + } + }); + } + @SuppressWarnings("unchecked") private static FilterModel.Relation cast(FilterModel.Relation relation) { return (FilterModel.Relation) relation; diff --git a/libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterModel.java b/libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterModel.java index d4fdb88..500b52c 100644 --- a/libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterModel.java +++ b/libtmux-jackson/src/main/java/io/github/libtmux/jackson/FilterModel.java @@ -5,25 +5,24 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; -import java.util.List; import java.util.Map; -import java.util.Optional; +import java.util.Objects; import java.util.Set; -import java.util.function.Function; +import java.util.function.Supplier; import org.jspecify.annotations.Nullable; /** * What a document's field and relation ids mean. * - *

An expression carries accessors and navigators — plain functions — which no document can hold. - * Reading one back therefore needs somewhere to look those up, and that is this. It is also what - * makes a document typed: a document claiming model {@code pane} cannot be read as a - * {@code FilterExpr}, because the ids simply are not there. + *

Expressions carry executable field and relation handles, while documents carry only their ids. + * This model binds those forms and gives each nested relation its target model. * * @param the entity this model describes */ public final class FilterModel { + private static final Set BUILT_IN_IDS = Set.of("pane", "window", "session", "client"); + private final String id; private final Map> fields; private final Map> toMany; @@ -36,9 +35,31 @@ private FilterModel(Builder builder) { this.toOne = Collections.unmodifiableMap(new LinkedHashMap<>(builder.toOne)); } - /** Starts a model with the id documents will name it by. */ + /** Starts a caller-owned model. Its id must identify the caller's namespace. */ public static Builder named(String id) { - return new Builder<>(id); + String checked = requireId(id); + if (BUILT_IN_IDS.contains(checked)) { + throw new IllegalArgumentException("model id '" + checked + "' is reserved by libtmux"); + } + if (!isNamespaced(checked)) { + throw new IllegalArgumentException( + "custom model id '" + checked + "' must be namespaced, for example 'example/editor'"); + } + return new Builder<>(checked); + } + + /** Starts a caller-owned model without requiring a generic type witness. */ + public static Builder named(String id, Class entityType) { + Objects.requireNonNull(entityType, "entityType"); + return named(id); + } + + static Builder builtIn(String id) { + String checked = requireId(id); + if (!BUILT_IN_IDS.contains(checked)) { + throw new IllegalArgumentException("unknown libtmux model id '" + checked + "'"); + } + return new Builder<>(checked); } /** The id documents name this model by. */ @@ -46,18 +67,12 @@ public String id() { return id; } - /** - * Every field a document may compare on, in the order the model declared them. - * - *

Public because the useful thing to say about a field nobody recognises is which ones exist. - * A caller writing a document by hand — or a model being told why its last one was refused — - * cannot otherwise find out without reading this file. - */ + /** Every field a document may compare on, in declaration order. */ public Set fieldNames() { return fields.keySet(); } - /** Every relation a document may navigate, in the order the model declared them. */ + /** Every relation a document may navigate. */ public Set relationNames() { Set names = new LinkedHashSet<>(toOne.keySet()); names.addAll(toMany.keySet()); @@ -88,11 +103,22 @@ public Set relationNames() { return found; } - /** A relation's navigator paired with the model its far side is described by. */ + /** A relation handle paired with the model its far side is described by. */ record Relation( - @Nullable Function> toMany, @Nullable Function> toOne, FilterModel target) {} + Fields.@Nullable ToManyRef toMany, + Fields.@Nullable ToOneRef toOne, + Supplier> targetModel) { + + Relation { + Objects.requireNonNull(targetModel, "targetModel"); + } + + FilterModel target() { + return Objects.requireNonNull(targetModel.get(), "relation target model"); + } + } - /** Collects the ids a document may name. */ + /** Collects the exact handles and unique ids a document may name. */ public static final class Builder { private final String id; @@ -121,24 +147,66 @@ public Builder field(Fields.FlagField field) { /** Declares a to-many relation and the model describing what it reaches. */ public Builder toMany(Fields.ToManyRef relation, FilterModel target) { - toMany.put(relation.name(), new Relation<>(relation.navigate(), null, target)); + Objects.requireNonNull(target, "target"); + return toMany(relation, () -> target); + } + + /** Declares a to-many relation whose target closes a recursive model graph. */ + public Builder toMany(Fields.ToManyRef relation, Supplier> target) { + Objects.requireNonNull(relation, "relation"); + requireAvailable(relation.id()); + toMany.put(relation.id(), new Relation<>(relation, null, target)); return this; } /** Declares a to-one relation and the model describing what it reaches. */ public Builder toOne(Fields.ToOneRef relation, FilterModel target) { - toOne.put(relation.name(), new Relation<>(null, relation.navigate(), target)); + Objects.requireNonNull(target, "target"); + return toOne(relation, () -> target); + } + + /** Declares a to-one relation whose target closes a recursive model graph. */ + public Builder toOne(Fields.ToOneRef relation, Supplier> target) { + Objects.requireNonNull(relation, "relation"); + requireAvailable(relation.id()); + toOne.put(relation.id(), new Relation<>(null, relation, target)); return this; } private Builder add(FieldRef ref) { + Objects.requireNonNull(ref, "field"); + requireAvailable(ref.id()); fields.put(ref.id(), ref); return this; } + private void requireAvailable(String member) { + if (fields.containsKey(member) || toMany.containsKey(member) || toOne.containsKey(member)) { + throw new IllegalArgumentException("model '" + id + "' already declares '" + member + "'"); + } + } + /** Builds the model. */ public FilterModel build() { return new FilterModel<>(this); } } + + private static String requireId(String id) { + Objects.requireNonNull(id, "id"); + if (id.isBlank() || id.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("model id must not be blank or contain whitespace"); + } + return id; + } + + private static boolean isNamespaced(String id) { + for (char separator : new char[] {'/', '.', ':'}) { + int position = id.indexOf(separator); + if (position > 0 && position < id.length() - 1) { + return true; + } + } + return false; + } } diff --git a/libtmux-jackson/src/main/java/io/github/libtmux/jackson/LibTmuxModels.java b/libtmux-jackson/src/main/java/io/github/libtmux/jackson/LibTmuxModels.java index a874ac0..0bb0d73 100644 --- a/libtmux-jackson/src/main/java/io/github/libtmux/jackson/LibTmuxModels.java +++ b/libtmux-jackson/src/main/java/io/github/libtmux/jackson/LibTmuxModels.java @@ -17,36 +17,41 @@ */ public final class LibTmuxModels { - private static final FilterModel PANE = FilterModel.named("pane") + private static final FilterModel PANE = FilterModel.builtIn("pane") .field(Pane_.id()) .field(Pane_.command()) .field(Pane_.index()) .field(Pane_.active()) .build(); - private static final FilterModel WINDOW = FilterModel.named("window") + private static final FilterModel WINDOW = FilterModel.builtIn("window") .field(Window_.id()) .field(Window_.name()) .field(Window_.index()) .field(Window_.active()) .field(Window_.linked()) .toMany(Window_.panes(), PANE) + .toOne(Window_.session(), LibTmuxModels::sessionModel) .build(); - private static final FilterModel SESSION = FilterModel.named("session") + private static final FilterModel SESSION = FilterModel.builtIn("session") .field(Session_.id()) .field(Session_.name()) .field(Session_.attached()) .toMany(Session_.windows(), WINDOW) .build(); - private static final FilterModel CLIENT = FilterModel.named("client") + private static final FilterModel CLIENT = FilterModel.builtIn("client") .field(Client_.name()) .toOne(Client_.session(), SESSION) .build(); private LibTmuxModels() {} + private static FilterModel sessionModel() { + return SESSION; + } + /** The model documents name {@code pane}. */ public static FilterModel pane() { return PANE; diff --git a/libtmux-jackson/src/main/java/io/github/libtmux/jackson/package-info.java b/libtmux-jackson/src/main/java/io/github/libtmux/jackson/package-info.java index 2bd2a87..6936ebb 100644 --- a/libtmux-jackson/src/main/java/io/github/libtmux/jackson/package-info.java +++ b/libtmux-jackson/src/main/java/io/github/libtmux/jackson/package-info.java @@ -1,9 +1,10 @@ /** * Writing a filter expression down and reading it back. * - *

Only expressions built from a metamodel can be written. A field built from a lambda has a - * caller-chosen name and an accessor nobody else can resolve, so it has no wire identity; refusing - * it is the difference between a format and a hope. + *

Only exact field and relation handles declared by the supplied model can be written. A handle + * may borrow a declared name while carrying a different accessor; refusing it is the difference + * between a format and a hope. + * Caller-owned models use namespaced ids so they cannot impersonate libtmux's built-in models. * *

The wire format carries its own schema version and stable model, field and operator ids. Java * class names and record component names are deliberately not wire identifiers, so the AST can be diff --git a/libtmux-jackson/src/main/resources/io/github/libtmux/jackson/filter-expr-v1.schema.json b/libtmux-jackson/src/main/resources/io/github/libtmux/jackson/filter-expr-v1.schema.json index 53a82e7..fab8245 100644 --- a/libtmux-jackson/src/main/resources/io/github/libtmux/jackson/filter-expr-v1.schema.json +++ b/libtmux-jackson/src/main/resources/io/github/libtmux/jackson/filter-expr-v1.schema.json @@ -80,7 +80,7 @@ "type": "object", "required": ["pattern"], "additionalProperties": false, - "description": "Used only with the matches operator. Flags are java.util.regex flag bits.", + "description": "Used only with the matches operator. Pattern syntax and flags follow java.util.regex.Pattern.", "properties": { "pattern": { "type": "string" }, "flags": { "type": "integer", "default": 0 } diff --git a/libtmux-jackson/src/test/java/io/github/libtmux/jackson/FilterJsonTest.java b/libtmux-jackson/src/test/java/io/github/libtmux/jackson/FilterJsonTest.java index 75c74de..07e65c5 100644 --- a/libtmux-jackson/src/test/java/io/github/libtmux/jackson/FilterJsonTest.java +++ b/libtmux-jackson/src/test/java/io/github/libtmux/jackson/FilterJsonTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.github.libtmux.Client; import io.github.libtmux.Pane; import io.github.libtmux.Pane_; import io.github.libtmux.Session; @@ -12,7 +13,12 @@ import io.github.libtmux.Window_; import io.github.libtmux.query.Fields; import io.github.libtmux.query.FilterExpr; +import io.github.libtmux.query.Operator; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; import java.util.regex.Pattern; import org.junit.jupiter.api.Test; @@ -31,7 +37,7 @@ void aComparisonSurvivesTheRoundTrip() { FilterExpr original = Pane_.command().startsWith("nv"); FilterExpr restored = - FilterJson.readString(FilterJson.writeString(original, "pane"), LibTmuxModels.pane()); + FilterJson.readString(FilterJson.writeString(original, LibTmuxModels.pane()), LibTmuxModels.pane()); assertEquals(original.describe(), restored.describe()); } @@ -50,25 +56,60 @@ void everyNodeKindSurvivesTheRoundTrip() { for (FilterExpr original : panes) { FilterExpr restored = - FilterJson.readString(FilterJson.writeString(original, "pane"), LibTmuxModels.pane()); + FilterJson.readString(FilterJson.writeString(original, LibTmuxModels.pane()), LibTmuxModels.pane()); assertEquals(original.describe(), restored.describe(), "round trip changed " + original.describe()); } } + @Test + void comparisonOperandsAreImmutableAndRoundTripByValue() { + List mutable = new ArrayList<>(List.of("zsh")); + FilterExpr.Compare membership = + new FilterExpr.Compare<>(Pane_.command().ref(), Operator.IN, mutable); + mutable.add("bash"); + + assertEquals(List.of("zsh"), membership.operand()); + assertEquals( + membership, + FilterJson.readString(FilterJson.writeString(membership, LibTmuxModels.pane()), LibTmuxModels.pane())); + + FilterExpr.Compare setMembership = + new FilterExpr.Compare<>(Pane_.command().ref(), Operator.IN, Set.of("zsh", "bash")); + assertEquals( + setMembership, + FilterJson.readString( + FilterJson.writeString(setMembership, LibTmuxModels.pane()), LibTmuxModels.pane())); + + FilterExpr regex = Pane_.command().matches(Pattern.compile("^z", Pattern.CASE_INSENSITIVE)); + assertEquals( + regex, + FilterJson.readString(FilterJson.writeString(regex, LibTmuxModels.pane()), LibTmuxModels.pane())); + } + @Test void bothRelationKindsSurviveTheRoundTrip() { FilterExpr quantified = Window_.panes().none(Pane_.active().isTrue()); + FilterExpr parent = Window_.session().is(Session_.name().is("build")); FilterExpr nested = Session_.windows().any(Window_.panes().all(Pane_.index().atMost(3))); assertEquals( quantified.describe(), - FilterJson.readString(FilterJson.writeString(quantified, "window"), LibTmuxModels.window()) + FilterJson.readString( + FilterJson.writeString(quantified, LibTmuxModels.window()), LibTmuxModels.window()) + .describe()); + assertEquals( + parent.describe(), + FilterJson.readString(FilterJson.writeString(parent, LibTmuxModels.window()), LibTmuxModels.window()) .describe()); assertEquals( nested.describe(), - FilterJson.readString(FilterJson.writeString(nested, "session"), LibTmuxModels.session()) + FilterJson.readString(FilterJson.writeString(nested, LibTmuxModels.session()), LibTmuxModels.session()) .describe()); + + String json = FilterJson.writeString(nested, LibTmuxModels.session()); + FilterExpr restored = FilterJson.readString(json, LibTmuxModels.session()); + assertEquals(json, FilterJson.writeString(restored, LibTmuxModels.session())); } /** @@ -80,7 +121,8 @@ void aRestoredExpressionFiltersTheSameThings() { FilterExpr original = Editor_.NAME.startsWith("nv").and(Editor_.RANK.atLeast(2)).or(Editor_.PINNED.isTrue()); - FilterExpr restored = FilterJson.readString(FilterJson.writeString(original, "editor"), Editor_.MODEL); + FilterExpr restored = + FilterJson.readString(FilterJson.writeString(original, Editor_.MODEL), Editor_.MODEL); List values = List.of( new Editor("nvim", 3, false), @@ -101,12 +143,12 @@ void aRestoredExpressionFiltersTheSameThings() { record Editor(String name, int rank, boolean pinned) {} /** A metamodel small enough to reason about, minted the way a generated one is. */ - static final class Editor_ extends io.github.libtmux.query.EntityMetamodel { - static final Fields.TextField NAME = text("name", Editor::name); - static final Fields.NumberField RANK = number("rank", Editor::rank); - static final Fields.FlagField PINNED = flag("pinned", Editor::pinned); + static final class Editor_ { + static final Fields.TextField NAME = Fields.text("name", Editor::name); + static final Fields.NumberField RANK = Fields.number("rank", Editor::rank); + static final Fields.FlagField PINNED = Fields.flag("pinned", Editor::pinned); - static final FilterModel MODEL = FilterModel.named("editor") + static final FilterModel MODEL = FilterModel.named("example/editor", Editor.class) .field(NAME) .field(RANK) .field(PINNED) @@ -117,7 +159,7 @@ private Editor_() {} @Test void theDocumentNamesItsSchemaAndModel() { - String json = FilterJson.writeString(Pane_.active().isTrue(), "pane"); + String json = FilterJson.writeString(Pane_.active().isTrue(), LibTmuxModels.pane()); assertTrue(json.contains("\"schema\":\"libtmux.filter/1\""), json); assertTrue(json.contains("\"model\":\"pane\""), json); @@ -126,20 +168,112 @@ void theDocumentNamesItsSchemaAndModel() { // ---------------------------------------------------------------------------- failing closed - /** The point of the format: a filter built from a lambda has no identity anyone else can resolve. */ + /** The point of the format: only fields declared by the supplied model have wire identity. */ @Test - void anExpressionBuiltFromALambdaCannotBeWritten() { + void anUndeclaredFieldCannotBeWritten() { FilterExpr local = Fields.text("whatever", pane -> pane.currentCommand()).is("zsh"); - SchemaException refused = assertThrows(SchemaException.class, () -> FilterJson.writeString(local, "pane")); + SchemaException refused = + assertThrows(SchemaException.class, () -> FilterJson.writeString(local, LibTmuxModels.pane())); + + assertTrue( + String.valueOf(refused.getMessage()).contains("model 'pane'"), "the message must name the authority"); + } + + @Test + void aSameIdFieldFromAnotherMetamodelCannotBeWritten() { + FilterExpr forged = ForgedPane_.COMMAND.is("forged-zsh"); + + assertThrows(SchemaException.class, () -> FilterJson.writeString(forged, LibTmuxModels.pane())); + } + + @Test + void aNestedFieldOutsideTheModelGraphCannotBeWritten() { + FilterExpr forged = Window_.panes().any(ForgedPane_.COMMAND.is("forged-zsh")); + + assertThrows(SchemaException.class, () -> FilterJson.writeString(forged, LibTmuxModels.window())); + } + + @Test + void aSameIdRelationWithAnotherNavigatorCannotBeWritten() { + FilterExpr forged = Fields.toMany("panes", ignored -> List.of()) + .any(Pane_.active().isTrue()); + + assertThrows(SchemaException.class, () -> FilterJson.writeString(forged, LibTmuxModels.window())); + } + + @Test + void aDifferentRelationHandleCannotBorrowTheDeclaredNavigator() { + Function> navigate = Window::panes; + var declared = Fields.toMany("panes", navigate); + var forged = Fields.toMany("panes", navigate); + FilterModel model = FilterModel.named("example/window") + .toMany(declared, LibTmuxModels.pane()) + .build(); + + assertThrows( + SchemaException.class, + () -> FilterJson.writeString(forged.any(Pane_.active().isTrue()), model)); + } + + @Test + void aSameIdToOneRelationWithAnotherNavigatorCannotBeWritten() { + FilterExpr forged = Fields.toOne("session", ignored -> Optional.empty()) + .is(Session_.attached().isTrue()); + + assertThrows(SchemaException.class, () -> FilterJson.writeString(forged, LibTmuxModels.client())); + } + + @Test + void customModelsCannotClaimBuiltInIds() { + for (String id : List.of("pane", "window", "session", "client")) { + assertThrows( + IllegalArgumentException.class, + () -> FilterModel.named(id).field(Pane_.command()).build(), + id); + } + assertThrows( + IllegalArgumentException.class, + () -> FilterModel.named("editor").field(Editor_.NAME).build(), + "custom model ids must name their owner"); + } + + @Test + void blankAndDuplicateModelMembersAreRefused() { + var children = Fields.toMany("children", ignored -> List.of()); + var parent = Fields.toOne("children", ignored -> Optional.empty()); - assertTrue(String.valueOf(refused.getMessage()).contains("lambda"), "the message must say why"); + assertThrows(IllegalArgumentException.class, () -> FilterModel.named(" ")); + assertThrows(IllegalArgumentException.class, () -> Fields.text(" ", Editor::name)); + assertThrows(IllegalArgumentException.class, () -> Fields.toMany(" ", ignored -> List.of())); + assertThrows( + IllegalArgumentException.class, + () -> FilterModel.named("example/editor") + .field(Editor_.NAME) + .field(Editor_.NAME)); + assertThrows( + IllegalArgumentException.class, + () -> FilterModel.named("example/editor") + .toMany(children, Editor_.MODEL) + .toMany(children, Editor_.MODEL)); + assertThrows( + IllegalArgumentException.class, + () -> FilterModel.named("example/editor") + .toMany(children, Editor_.MODEL) + .toOne(parent, Editor_.MODEL)); + } + + static final class ForgedPane_ { + static final Fields.TextField COMMAND = + Fields.text("pane_current_command", pane -> "forged-" + pane.currentCommand()); + + private ForgedPane_() {} } @Test void anUnknownSchemaVersionIsRefused() { - String json = FilterJson.writeString(Pane_.active().isTrue(), "pane") + String json = FilterJson.writeString(Pane_.active().isTrue(), LibTmuxModels.pane()) .replace("libtmux.filter/1", "libtmux.filter/99"); assertThrows(SchemaException.class, () -> FilterJson.readString(json, LibTmuxModels.pane())); @@ -147,7 +281,7 @@ void anUnknownSchemaVersionIsRefused() { @Test void aDocumentForAnotherModelIsRefused() { - String json = FilterJson.writeString(Pane_.active().isTrue(), "pane"); + String json = FilterJson.writeString(Pane_.active().isTrue(), LibTmuxModels.pane()); assertThrows( SchemaException.class, @@ -187,6 +321,16 @@ void anOperandOfTheWrongTypeForItsFieldIsRefused() { "a number field compared against a string would evaluate to nothing useful"); } + @Test + void anOperatorIncompatibleWithItsFieldIsRefused() { + assertThrows( + SchemaException.class, + () -> FilterJson.readString( + "{\"schema\":\"libtmux.filter/1\",\"model\":\"pane\",\"expr\":" + + "{\"node\":\"compare\",\"field\":\"pane_index\",\"op\":\"contains\",\"value\":2}}", + LibTmuxModels.pane())); + } + @Test void aStructurallyBrokenDocumentIsRefused() { assertThrows(SchemaException.class, () -> FilterJson.readString("not json at all", LibTmuxModels.pane())); @@ -197,6 +341,60 @@ void aStructurallyBrokenDocumentIsRefused() { "{\"schema\":\"libtmux.filter/1\",\"model\":\"pane\"}", LibTmuxModels.pane())); } + @Test + void blankTrailingAndInvalidRegexDocumentsAreRefusedUniformly() { + String valid = FilterJson.writeString(Pane_.active().isTrue(), LibTmuxModels.pane()); + assertThrows(SchemaException.class, () -> FilterJson.readString("", LibTmuxModels.pane())); + assertThrows(SchemaException.class, () -> FilterJson.readString(valid + valid, LibTmuxModels.pane())); + assertThrows( + SchemaException.class, + () -> FilterJson.readString( + "{\"schema\":\"libtmux.filter/1\",\"model\":\"pane\",\"expr\":" + + "{\"node\":\"compare\",\"field\":\"pane_current_command\",\"op\":\"matches\"," + + "\"value\":{\"pattern\":\"[\",\"flags\":0}}}", + LibTmuxModels.pane())); + assertThrows( + SchemaException.class, + () -> FilterJson.readString( + "{\"schema\":\"libtmux.filter/1\",\"model\":\"pane\",\"expr\":" + + "{\"node\":\"compare\",\"field\":\"pane_current_command\",\"op\":\"matches\"," + + "\"value\":{\"pattern\":\"x\",\"flags\":2147483647}}}", + LibTmuxModels.pane())); + } + + @Test + void unknownDocumentAndNodePropertiesAreRefused() { + List documents = List.of( + "{\"schema\":\"libtmux.filter/1\",\"model\":\"pane\",\"extra\":true,\"expr\":" + + "{\"node\":\"compare\",\"field\":\"pane_active\",\"op\":\"equals\",\"value\":true}}", + "{\"schema\":\"libtmux.filter/1\",\"model\":\"pane\",\"expr\":" + + "{\"node\":\"compare\",\"field\":\"pane_active\",\"op\":\"equals\",\"value\":true,\"extra\":true}}", + "{\"schema\":\"libtmux.filter/1\",\"model\":\"pane\",\"expr\":" + + "{\"node\":\"compare\",\"field\":\"pane_current_command\",\"op\":\"matches\"," + + "\"value\":{\"pattern\":\"nv\",\"flags\":0,\"extra\":true}}}"); + + for (String document : documents) { + assertThrows(SchemaException.class, () -> FilterJson.readString(document, LibTmuxModels.pane())); + } + } + + @Test + void duplicatePropertiesAndNonTextRegexPatternsAreRefused() { + assertThrows( + SchemaException.class, + () -> FilterJson.readString( + "{\"schema\":\"libtmux.filter/1\",\"schema\":\"libtmux.filter/1\",\"model\":\"pane\",\"expr\":" + + "{\"node\":\"compare\",\"field\":\"pane_active\",\"op\":\"equals\",\"value\":true}}", + LibTmuxModels.pane())); + assertThrows( + SchemaException.class, + () -> FilterJson.readString( + "{\"schema\":\"libtmux.filter/1\",\"model\":\"pane\",\"expr\":" + + "{\"node\":\"compare\",\"field\":\"pane_current_command\",\"op\":\"matches\"," + + "\"value\":{\"pattern\":1}}}", + LibTmuxModels.pane())); + } + @Test void anUnknownRelationIsRefused() { assertThrows( diff --git a/libtmux/src/main/java/io/github/libtmux/Client_.java b/libtmux/src/main/java/io/github/libtmux/Client_.java index fa28dd6..220b4f6 100644 --- a/libtmux/src/main/java/io/github/libtmux/Client_.java +++ b/libtmux/src/main/java/io/github/libtmux/Client_.java @@ -1,20 +1,22 @@ package io.github.libtmux; -import io.github.libtmux.query.EntityMetamodel; import io.github.libtmux.query.Fields; /** Typed fields of {@link Client}. */ -public final class Client_ extends EntityMetamodel { +public final class Client_ { + + private static final Fields.TextField NAME = Fields.text("client_name", Client::name); + private static final Fields.ToOneRef SESSION = Fields.toOne("session", Client::session); private Client_() {} /** The client's terminal name, which is how tmux addresses it. */ public static Fields.TextField name() { - return text("client_name", Client::name); + return NAME; } /** The session this client was attached to, if any. */ public static Fields.ToOneRef session() { - return toOne("session", Client::session); + return SESSION; } } diff --git a/libtmux/src/main/java/io/github/libtmux/Pane_.java b/libtmux/src/main/java/io/github/libtmux/Pane_.java index 93690eb..96f8560 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane_.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane_.java @@ -1,30 +1,35 @@ package io.github.libtmux; -import io.github.libtmux.query.EntityMetamodel; import io.github.libtmux.query.Fields; /** Typed fields of {@link Pane}. */ -public final class Pane_ extends EntityMetamodel { +public final class Pane_ { + + private static final Fields.TextField ID = + Fields.text("pane_id", pane -> pane.id().value()); + private static final Fields.TextField COMMAND = Fields.text("pane_current_command", Pane::currentCommand); + private static final Fields.NumberField INDEX = Fields.number("pane_index", Pane::index); + private static final Fields.FlagField ACTIVE = Fields.flag("pane_active", Pane::active); private Pane_() {} /** The pane id. */ public static Fields.TextField id() { - return text("pane_id", pane -> pane.id().value()); + return ID; } /** The command tmux reported running in the pane. */ public static Fields.TextField command() { - return text("pane_current_command", Pane::currentCommand); + return COMMAND; } /** The pane's position in its window. */ public static Fields.NumberField index() { - return number("pane_index", Pane::index); + return INDEX; } /** Whether this was its window's active pane. */ public static Fields.FlagField active() { - return flag("pane_active", Pane::active); + return ACTIVE; } } diff --git a/libtmux/src/main/java/io/github/libtmux/Session_.java b/libtmux/src/main/java/io/github/libtmux/Session_.java index ede00d3..d9bc537 100644 --- a/libtmux/src/main/java/io/github/libtmux/Session_.java +++ b/libtmux/src/main/java/io/github/libtmux/Session_.java @@ -1,36 +1,41 @@ package io.github.libtmux; -import io.github.libtmux.query.EntityMetamodel; import io.github.libtmux.query.Fields; /** * Typed fields of {@link Session}, for building an expression that can be read as well as run. * *

Each field exposes only the operators its type supports, so asking a flag to start with a - * string does not compile. Every field is canonical: it names a tmux format, which is what lets a - * later backend translate the same expression into tmux's own {@code -f} filter. + * string does not compile. Every field names a tmux format, so a backend model can bind the handle + * before translating the expression into tmux's own {@code -f} filter. */ -public final class Session_ extends EntityMetamodel { +public final class Session_ { + + private static final Fields.TextField ID = + Fields.text("session_id", session -> session.id().value()); + private static final Fields.TextField NAME = Fields.text("session_name", Session::name); + private static final Fields.FlagField ATTACHED = Fields.flag("session_attached", Session::attached); + private static final Fields.ToManyRef WINDOWS = Fields.toMany("windows", Session::windows); private Session_() {} /** The session id, as text, so it can be compared and listed. */ public static Fields.TextField id() { - return text("session_id", session -> session.id().value()); + return ID; } /** The session name. */ public static Fields.TextField name() { - return text("session_name", Session::name); + return NAME; } /** Whether a client was attached when this was captured. */ public static Fields.FlagField attached() { - return flag("session_attached", Session::attached); + return ATTACHED; } /** This session's windows. */ public static Fields.ToManyRef windows() { - return toMany("windows", Session::windows); + return WINDOWS; } } diff --git a/libtmux/src/main/java/io/github/libtmux/Window_.java b/libtmux/src/main/java/io/github/libtmux/Window_.java index c34b53a..b7fbf14 100644 --- a/libtmux/src/main/java/io/github/libtmux/Window_.java +++ b/libtmux/src/main/java/io/github/libtmux/Window_.java @@ -1,46 +1,56 @@ package io.github.libtmux; -import io.github.libtmux.query.EntityMetamodel; import io.github.libtmux.query.Fields; import java.util.Optional; /** Typed fields of {@link Window}. */ -public final class Window_ extends EntityMetamodel { +public final class Window_ { + + private static final Fields.TextField ID = + Fields.text("window_id", window -> window.id().value()); + private static final Fields.TextField NAME = Fields.text("window_name", Window::name); + private static final Fields.NumberField INDEX = + Fields.number("window_index", window -> window.index().value()); + private static final Fields.FlagField ACTIVE = Fields.flag("window_active", Window::active); + private static final Fields.FlagField LINKED = Fields.flag("window_linked", Window::linked); + private static final Fields.ToManyRef PANES = Fields.toMany("panes", Window::panes); + private static final Fields.ToOneRef SESSION = + Fields.toOne("session", window -> Optional.of(window.session())); private Window_() {} /** The underlying window id, shared by every link to it. */ public static Fields.TextField id() { - return text("window_id", window -> window.id().value()); + return ID; } /** The window name. */ public static Fields.TextField name() { - return text("window_name", Window::name); + return NAME; } /** Where this link sits in its session. */ public static Fields.NumberField index() { - return number("window_index", window -> window.index().value()); + return INDEX; } /** Whether this was its session's active window. */ public static Fields.FlagField active() { - return flag("window_active", Window::active); + return ACTIVE; } /** Whether the underlying window is linked into more than one session. */ public static Fields.FlagField linked() { - return flag("window_linked", Window::linked); + return LINKED; } /** This link's panes. */ public static Fields.ToManyRef panes() { - return toMany("panes", Window::panes); + return PANES; } /** The session this link belongs to. */ public static Fields.ToOneRef session() { - return toOne("session", window -> Optional.of(window.session())); + return SESSION; } } diff --git a/libtmux/src/main/java/io/github/libtmux/query/EntityMetamodel.java b/libtmux/src/main/java/io/github/libtmux/query/EntityMetamodel.java deleted file mode 100644 index 9104c04..0000000 --- a/libtmux/src/main/java/io/github/libtmux/query/EntityMetamodel.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.github.libtmux.query; - -import java.util.List; -import java.util.Optional; -import java.util.function.Function; - -/** - * Base for a generated or hand-written entity metamodel. - * - *

Minting a {@linkplain FieldProvenance.Canonical canonical} field is what makes a field eligible - * for backend pushdown, so it is deliberately not something a caller can assert. The factories are - * {@code protected}: reaching them requires declaring the type a metamodel by extending this class, - * which is a visible act rather than a boolean somebody can set. - */ -public abstract class EntityMetamodel { - - protected EntityMetamodel() {} - - protected static Fields.TextField text(String id, Function accessor) { - return new Fields.TextField<>(FieldRef.canonical(id, FieldKind.TEXT, accessor)); - } - - protected static Fields.NumberField number(String id, Function accessor) { - return new Fields.NumberField<>(FieldRef.canonical(id, FieldKind.NUMBER, accessor)); - } - - protected static Fields.FlagField flag(String id, Function accessor) { - return new Fields.FlagField<>(FieldRef.canonical(id, FieldKind.FLAG, accessor)); - } - - protected static Fields.ToManyRef toMany(String id, Function> navigate) { - return Fields.toMany(id, navigate); - } - - protected static Fields.ToOneRef toOne(String id, Function> navigate) { - return Fields.toOne(id, navigate); - } -} diff --git a/libtmux/src/main/java/io/github/libtmux/query/FieldProvenance.java b/libtmux/src/main/java/io/github/libtmux/query/FieldProvenance.java deleted file mode 100644 index 8d07edc..0000000 --- a/libtmux/src/main/java/io/github/libtmux/query/FieldProvenance.java +++ /dev/null @@ -1,54 +0,0 @@ -package io.github.libtmux.query; - -/** - * Whether a field's accessor is the library's own canonical read of a backend field, or something a - * caller supplied. - * - *

Pushdown eligibility depends on this and it must not be caller-declarable. A derived accessor - * is an opaque lambda: {@code Fields.text("session_name", r -> r.text("session_name").toLowerCase())} - * lowers to a filter on {@code #{session_name}} that reads exact while answering a different - * question, and no compiler can inspect the lambda to notice. - * - *

{@link Canonical} is therefore unforgeable from outside this package — its constructor is - * private and its only instance is package-private — so a caller cannot assert canonical semantics - * for an accessor the library did not write. - */ -public sealed interface FieldProvenance permits FieldProvenance.Canonical, FieldProvenance.Derived { - - /** True when a lowering compiler may treat the field name as authoritative for its accessor. */ - boolean lowerable(); - - /** Minted only by the library's own metamodel. */ - final class Canonical implements FieldProvenance { - static final Canonical INSTANCE = new Canonical(); - - private Canonical() {} - - @Override - public boolean lowerable() { - return true; - } - - @Override - public String toString() { - return "canonical"; - } - } - - /** Anything a caller built. Always local-only. */ - final class Derived implements FieldProvenance { - public static final Derived INSTANCE = new Derived(); - - private Derived() {} - - @Override - public boolean lowerable() { - return false; - } - - @Override - public String toString() { - return "derived"; - } - } -} diff --git a/libtmux/src/main/java/io/github/libtmux/query/FieldRef.java b/libtmux/src/main/java/io/github/libtmux/query/FieldRef.java index e86850d..ba8eaca 100644 --- a/libtmux/src/main/java/io/github/libtmux/query/FieldRef.java +++ b/libtmux/src/main/java/io/github/libtmux/query/FieldRef.java @@ -6,31 +6,39 @@ /** * A named, kinded accessor. * - *

The name makes a built expression printable and serializable; the kind makes lowering - * independent of the operand's runtime class; the provenance decides whether a backend compiler may - * trust the name to describe what the accessor actually reads. + *

The name makes a built expression printable; the kind makes consumers independent of the + * operand's runtime class. A serializer or compiler must bind the exact handle against its own + * model before trusting the name to describe what the accessor reads. */ -public record FieldRef(String id, FieldKind kind, Function accessor, FieldProvenance provenance) { +public final class FieldRef { - public FieldRef { + private final String id; + private final FieldKind kind; + private final Function accessor; + + FieldRef(String id, FieldKind kind, Function accessor) { + this.id = requireId(id); + this.kind = Objects.requireNonNull(kind, "kind"); + this.accessor = Objects.requireNonNull(accessor, "accessor"); + } + + static String requireId(String id) { Objects.requireNonNull(id, "id"); - Objects.requireNonNull(kind, "kind"); - Objects.requireNonNull(accessor, "accessor"); - Objects.requireNonNull(provenance, "provenance"); + if (id.isBlank()) { + throw new IllegalArgumentException("id must not be blank"); + } + return id; } - /** A caller-supplied field. Never eligible for pushdown, because its accessor is opaque. */ - public static FieldRef derived(String id, FieldKind kind, Function accessor) { - return new FieldRef<>(id, kind, accessor, FieldProvenance.Derived.INSTANCE); + public String id() { + return id; } - /** A field whose accessor is the library's own canonical read of {@code id}. */ - static FieldRef canonical(String id, FieldKind kind, Function accessor) { - return new FieldRef<>(id, kind, accessor, FieldProvenance.Canonical.INSTANCE); + public FieldKind kind() { + return kind; } - /** Retained for diagnostics; the id is what a backend compiler may reference. */ - public String name() { - return id; + public Function accessor() { + return accessor; } } diff --git a/libtmux/src/main/java/io/github/libtmux/query/Fields.java b/libtmux/src/main/java/io/github/libtmux/query/Fields.java index ec47b3a..c9681c8 100644 --- a/libtmux/src/main/java/io/github/libtmux/query/Fields.java +++ b/libtmux/src/main/java/io/github/libtmux/query/Fields.java @@ -2,6 +2,7 @@ import java.util.Collection; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.function.Function; import java.util.regex.Pattern; @@ -9,9 +10,8 @@ /** * Typed field handles. * - *

Fields built here are {@linkplain FieldProvenance.Derived derived}: the caller supplied the - * accessor, so no backend may assume the name describes what it reads. Canonical fields are minted by - * {@link EntityMetamodel}. + *

A caller supplies each accessor, so no backend may trust the name without matching this exact + * handle against a model it owns. * *

Each kind exposes only the operators its type supports, so {@code Pane_.index().startsWith(..)} * is a compile error rather than a runtime class cast. This is the half of the metamodel that has to @@ -21,24 +21,24 @@ public final class Fields { private Fields() {} - public static TextField text(String name, Function accessor) { - return new TextField<>(FieldRef.derived(name, FieldKind.TEXT, accessor)); + public static TextField text(String id, Function accessor) { + return new TextField<>(new FieldRef<>(id, FieldKind.TEXT, accessor)); } - public static NumberField number(String name, Function accessor) { - return new NumberField<>(FieldRef.derived(name, FieldKind.NUMBER, accessor)); + public static NumberField number(String id, Function accessor) { + return new NumberField<>(new FieldRef<>(id, FieldKind.NUMBER, accessor)); } - public static FlagField flag(String name, Function accessor) { - return new FlagField<>(FieldRef.derived(name, FieldKind.FLAG, accessor)); + public static FlagField flag(String id, Function accessor) { + return new FlagField<>(new FieldRef<>(id, FieldKind.FLAG, accessor)); } - public static ToManyRef toMany(String name, Function> navigate) { - return new ToManyRef<>(name, navigate); + public static ToManyRef toMany(String id, Function> navigate) { + return new ToManyRef<>(id, navigate); } - public static ToOneRef toOne(String name, Function> navigate) { - return new ToOneRef<>(name, navigate); + public static ToOneRef toOne(String id, Function> navigate) { + return new ToOneRef<>(id, navigate); } /** String-valued field. */ @@ -80,6 +80,10 @@ public FilterExpr is(int value) { return new FilterExpr.Compare<>(ref, Operator.EQUALS, value); } + public FilterExpr isNot(int value) { + return new FilterExpr.Compare<>(ref, Operator.NOT_EQUALS, value); + } + public FilterExpr lessThan(int value) { return new FilterExpr.Compare<>(ref, Operator.LESS_THAN, value); } @@ -100,36 +104,76 @@ public FilterExpr atLeast(int value) { /** Boolean-valued field. */ public record FlagField(FieldRef ref) { + public FilterExpr is(boolean value) { + return new FilterExpr.Compare<>(ref, Operator.EQUALS, value); + } + + public FilterExpr isNot(boolean value) { + return new FilterExpr.Compare<>(ref, Operator.NOT_EQUALS, value); + } + public FilterExpr isTrue() { - return new FilterExpr.Compare<>(ref, Operator.EQUALS, true); + return is(true); } public FilterExpr isFalse() { - return new FilterExpr.Compare<>(ref, Operator.EQUALS, false); + return is(false); } } /** To-many relation. Quantifiers are the only way in, so an unquantified relation cannot compile. */ - public record ToManyRef(String name, Function> navigate) { + public static final class ToManyRef { + + private final String id; + private final Function> navigate; + + private ToManyRef(String id, Function> navigate) { + this.id = FieldRef.requireId(id); + this.navigate = Objects.requireNonNull(navigate, "navigate"); + } - public FilterExpr any(FilterExpr predicate) { - return new FilterExpr.ToMany<>(name, navigate, FilterExpr.Quantifier.ANY, predicate); + public String id() { + return id; } - public FilterExpr all(FilterExpr predicate) { - return new FilterExpr.ToMany<>(name, navigate, FilterExpr.Quantifier.ALL, predicate); + public Function> navigate() { + return navigate; } - public FilterExpr none(FilterExpr predicate) { - return new FilterExpr.ToMany<>(name, navigate, FilterExpr.Quantifier.NONE, predicate); + public FilterExpr.ToMany any(FilterExpr predicate) { + return new FilterExpr.ToMany<>(this, FilterExpr.Quantifier.ANY, predicate); + } + + public FilterExpr.ToMany all(FilterExpr predicate) { + return new FilterExpr.ToMany<>(this, FilterExpr.Quantifier.ALL, predicate); + } + + public FilterExpr.ToMany none(FilterExpr predicate) { + return new FilterExpr.ToMany<>(this, FilterExpr.Quantifier.NONE, predicate); } } /** To-one relation. */ - public record ToOneRef(String name, Function> navigate) { + public static final class ToOneRef { + + private final String id; + private final Function> navigate; + + private ToOneRef(String id, Function> navigate) { + this.id = FieldRef.requireId(id); + this.navigate = Objects.requireNonNull(navigate, "navigate"); + } + + public String id() { + return id; + } + + public Function> navigate() { + return navigate; + } - public FilterExpr is(FilterExpr predicate) { - return new FilterExpr.ToOne<>(name, navigate, predicate); + public FilterExpr.ToOne is(FilterExpr predicate) { + return new FilterExpr.ToOne<>(this, predicate); } } } diff --git a/libtmux/src/main/java/io/github/libtmux/query/FilterExpr.java b/libtmux/src/main/java/io/github/libtmux/query/FilterExpr.java index 2ae87c7..27fc052 100644 --- a/libtmux/src/main/java/io/github/libtmux/query/FilterExpr.java +++ b/libtmux/src/main/java/io/github/libtmux/query/FilterExpr.java @@ -1,10 +1,11 @@ package io.github.libtmux.query; +import java.util.Collection; import java.util.List; import java.util.Locale; -import java.util.Optional; -import java.util.function.Function; +import java.util.Objects; import java.util.function.Predicate; +import java.util.regex.Pattern; /** * A filter that is both runnable and readable. @@ -128,6 +129,15 @@ public String describe() { /** One scalar field compared to one operand. */ record Compare(FieldRef field, Operator operator, Object operand) implements FilterExpr { + public Compare { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(operator, "operator"); + operator.requireOperand(field.kind(), operand); + if (operand instanceof Collection values) { + operand = List.copyOf(values); + } + } + @Override public boolean test(T value) { return operator.matches(field.accessor().apply(value), operand); @@ -135,7 +145,29 @@ public boolean test(T value) { @Override public String describe() { - return field.name() + " " + operator.symbol() + " " + operand; + return field.id() + " " + operator.symbol() + " " + operand; + } + + @Override + public boolean equals(Object other) { + return other instanceof Compare that + && field.equals(that.field) + && operator == that.operator + && operandEquals(operand, that.operand); + } + + @Override + public int hashCode() { + return operand instanceof Pattern pattern + ? Objects.hash(field, operator, pattern.pattern(), pattern.flags()) + : Objects.hash(field, operator, operand); + } + + private static boolean operandEquals(Object left, Object right) { + if (left instanceof Pattern first && right instanceof Pattern second) { + return first.flags() == second.flags() && first.pattern().equals(second.pattern()); + } + return left.equals(right); } } @@ -145,12 +177,18 @@ public String describe() { *

{@code ALL} over an empty relation is true. That is the standard vacuous reading and it is * the one tmux users expect: a session with no windows does not fail "all windows are zoomed". */ - record ToMany(String relation, Function> navigate, Quantifier quantifier, FilterExpr predicate) + record ToMany(Fields.ToManyRef relation, Quantifier quantifier, FilterExpr predicate) implements FilterExpr { + public ToMany { + Objects.requireNonNull(relation, "relation"); + Objects.requireNonNull(quantifier, "quantifier"); + Objects.requireNonNull(predicate, "predicate"); + } + @Override public boolean test(T value) { - List related = navigate.apply(value); + List related = relation.navigate().apply(value); return switch (quantifier) { case ANY -> related.stream().anyMatch(predicate); case ALL -> related.stream().allMatch(predicate); @@ -160,22 +198,26 @@ public boolean test(T value) { @Override public String describe() { - return relation + " " + quantifier.name().toLowerCase(Locale.ROOT) + " (" + predicate.describe() + ")"; + return relation.id() + " " + quantifier.name().toLowerCase(Locale.ROOT) + " (" + predicate.describe() + ")"; } } /** A to-one relation. An absent target does not satisfy the filter. */ - record ToOne(String relation, Function> navigate, FilterExpr predicate) - implements FilterExpr { + record ToOne(Fields.ToOneRef relation, FilterExpr predicate) implements FilterExpr { + + public ToOne { + Objects.requireNonNull(relation, "relation"); + Objects.requireNonNull(predicate, "predicate"); + } @Override public boolean test(T value) { - return navigate.apply(value).filter(predicate).isPresent(); + return relation.navigate().apply(value).filter(predicate).isPresent(); } @Override public String describe() { - return relation + " is (" + predicate.describe() + ")"; + return relation.id() + " is (" + predicate.describe() + ")"; } } diff --git a/libtmux/src/main/java/io/github/libtmux/query/Operator.java b/libtmux/src/main/java/io/github/libtmux/query/Operator.java index e16052d..9648e4f 100644 --- a/libtmux/src/main/java/io/github/libtmux/query/Operator.java +++ b/libtmux/src/main/java/io/github/libtmux/query/Operator.java @@ -28,6 +28,40 @@ public String symbol() { return symbol; } + void requireOperand(FieldKind kind, Object operand) { + Objects.requireNonNull(kind, "kind"); + if (!supports(kind)) { + throw new IllegalArgumentException(name() + " does not support " + kind + " fields"); + } + boolean valid = + switch (this) { + case MATCHES -> operand instanceof Pattern; + case IN -> + operand instanceof Collection values + && values.stream().allMatch(value -> scalar(kind, value)); + default -> scalar(kind, operand); + }; + if (!valid) { + throw new IllegalArgumentException(name() + " has an invalid operand for a " + kind + " field"); + } + } + + private boolean supports(FieldKind kind) { + return switch (this) { + case EQUALS, NOT_EQUALS -> true; + case CONTAINS, STARTS_WITH, ENDS_WITH, MATCHES, IN -> kind == FieldKind.TEXT; + case LESS_THAN, AT_MOST, GREATER_THAN, AT_LEAST -> kind == FieldKind.NUMBER; + }; + } + + private static boolean scalar(FieldKind kind, Object operand) { + return switch (kind) { + case TEXT -> operand instanceof String; + case NUMBER -> operand instanceof Integer; + case FLAG -> operand instanceof Boolean; + }; + } + @SuppressWarnings("unchecked") boolean matches(Object actual, Object operand) { return switch (this) { diff --git a/libtmux/src/test/java/io/github/libtmux/query/CompileFailTest.java b/libtmux/src/test/java/io/github/libtmux/query/CompileFailTest.java index cbd2faf..33dc2b1 100644 --- a/libtmux/src/test/java/io/github/libtmux/query/CompileFailTest.java +++ b/libtmux/src/test/java/io/github/libtmux/query/CompileFailTest.java @@ -84,50 +84,6 @@ void aQuantifierRejectsAPredicateOverTheWrongType() { "a pane quantifier must not accept a window predicate"); } - // ------------------------------------------------------------------------------------------- - // Canonical provenance must be unforgeable, which is only observable from another package. - // ------------------------------------------------------------------------------------------- - - @Test - @Timeout(120) - void anOutsiderCanBuildADerivedField() { - assertTrue( - compilesOutside("Object f = io.github.libtmux.query.Fields.text(\"command\", (String s) -> s);"), - "the control must compile, or every rejection below proves nothing"); - } - - @Test - @Timeout(120) - void anOutsiderCannotMintCanonicalProvenance() { - assertFalse( - compilesOutside("Object p = io.github.libtmux.query.FieldProvenance.Canonical.INSTANCE;"), - "the canonical instance must not be reachable from outside the package"); - assertFalse( - compilesOutside("Object p = new io.github.libtmux.query.FieldProvenance.Canonical();"), - "the canonical constructor must be private"); - } - - @Test - @Timeout(120) - void anOutsiderCannotMintACanonicalField() { - assertFalse( - compilesOutside( - "Object f = io.github.libtmux.query.FieldRef.canonical(\"c\", io.github.libtmux.query.FieldKind.TEXT," - + " (String s) -> s);"), - "canonical field minting must be package-private"); - } - - @Test - @Timeout(120) - void anOutsiderCannotImplementTheProvenanceInterface() { - assertFalse( - compilesOutside("class Forged implements io.github.libtmux.query.FieldProvenance {" - + " public boolean lowerable() { return true; } }"), - "FieldProvenance is sealed, so no third implementation exists"); - } - - // ------------------------------------------------------------------------------------------- - /** Compiles one statement against the test classpath and reports whether javac accepted it. */ private static boolean compiles(String statement) { JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); @@ -196,19 +152,6 @@ private static void deleteTree(Path root) { } } - /** Same probe, but from a package that has no privileged access to {@code io.github.libtmux.query}. */ - private static boolean compilesOutside(String body) { - return compileSource("outsider", """ - package outsider; - - final class CompileProbe { - void probe() { - %s - } - } - """.formatted(body)); - } - private static final class InMemorySource extends SimpleJavaFileObject { private final String source; diff --git a/libtmux/src/test/java/io/github/libtmux/query/FieldProvenanceTest.java b/libtmux/src/test/java/io/github/libtmux/query/FieldProvenanceTest.java deleted file mode 100644 index 1df5ce8..0000000 --- a/libtmux/src/test/java/io/github/libtmux/query/FieldProvenanceTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.github.libtmux.query; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.libtmux.query.Model.Pane; -import io.github.libtmux.query.Model.Pane_; -import java.util.Locale; -import org.junit.jupiter.api.Test; - -/** - * Pushdown eligibility must not be caller-declarable. - * - *

The bakeoff measured the hole this closes: a caller-supplied accessor that lowers to a filter on - * its field name reads exact while answering a different question, and no compiler can inspect the - * - * lambda to notice. The fix is that only a declared metamodel can mint a canonical field. - */ -final class FieldProvenanceTest { - - @Test - void aMetamodelFieldIsCanonicalAndCarriesItsKind() { - assertTrue(Pane_.command().ref().provenance().lowerable()); - assertEquals(FieldKind.TEXT, Pane_.command().ref().kind()); - assertEquals(FieldKind.NUMBER, Pane_.index().ref().kind()); - assertEquals(FieldKind.FLAG, Pane_.active().ref().kind()); - } - - @Test - void aCallerBuiltFieldIsDerivedAndNotLowerable() { - // The exact shape the bakeoff caught: right name, different semantics. - var shouted = Fields.text("command", (Pane pane) -> pane.command().toUpperCase(Locale.ROOT)); - - assertFalse( - shouted.ref().provenance().lowerable(), - "a caller's accessor must never be trusted to match its field name"); - assertEquals(FieldKind.TEXT, shouted.ref().kind()); - } - - @Test - void derivedFieldsStillEvaluateLocally() { - var shouted = Fields.text("command", (Pane pane) -> pane.command().toUpperCase(Locale.ROOT)); - - assertTrue(shouted.is("NVIM").test(new Pane("%1", "nvim", 0, true))); - assertFalse(Pane_.command().is("NVIM").test(new Pane("%1", "nvim", 0, true))); - } -} diff --git a/libtmux/src/test/java/io/github/libtmux/query/FilterExprTest.java b/libtmux/src/test/java/io/github/libtmux/query/FilterExprTest.java index 2d791f0..ca0b2b8 100644 --- a/libtmux/src/test/java/io/github/libtmux/query/FilterExprTest.java +++ b/libtmux/src/test/java/io/github/libtmux/query/FilterExprTest.java @@ -1,7 +1,10 @@ package io.github.libtmux.query; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -11,6 +14,8 @@ import io.github.libtmux.query.Model.Window_; import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.function.Function; import java.util.function.Predicate; import java.util.regex.Pattern; import org.junit.jupiter.api.Test; @@ -137,6 +142,69 @@ void regexAndMembershipOperators() { assertEquals(List.of("%1", "%2"), matching(Pane_.command().in(List.of("nvim", "zsh")))); } + @Test + void directConstructionEnforcesTheTypedOperatorMatrix() { + for (Operator operator : Operator.values()) { + for (FieldKind kind : FieldKind.values()) { + Runnable construction = () -> new FilterExpr.Compare<>(field(kind), operator, operand(operator, kind)); + if (supported(operator).contains(kind)) { + assertDoesNotThrow(construction::run, operator + " should support " + kind); + } else { + assertThrows(IllegalArgumentException.class, construction::run, operator + " must reject " + kind); + } + } + } + } + + @Test + void inequalitySupportsEveryScalarKind() { + assertTrue(Pane_.command().isNot("zsh").test(NVIM)); + assertTrue(Pane_.index().isNot(1).test(NVIM)); + assertTrue(Pane_.active().isNot(false).test(NVIM)); + } + + @Test + void directConstructionRejectsOperandsThatCannotBeEvaluated() { + FieldRef text = + Fields.text("command", Pane::command).ref(); + FieldRef number = + Fields.number("index", Pane::index).ref(); + FieldRef flag = Fields.flag("active", Pane::active).ref(); + + assertThrows(IllegalArgumentException.class, () -> new FilterExpr.Compare<>(text, Operator.EQUALS, 1)); + assertThrows(IllegalArgumentException.class, () -> new FilterExpr.Compare<>(number, Operator.EQUALS, "1")); + assertThrows(IllegalArgumentException.class, () -> new FilterExpr.Compare<>(flag, Operator.EQUALS, 1)); + assertThrows(IllegalArgumentException.class, () -> new FilterExpr.Compare<>(text, Operator.MATCHES, "nv")); + assertThrows(IllegalArgumentException.class, () -> new FilterExpr.Compare<>(text, Operator.IN, "nvim")); + assertThrows( + IllegalArgumentException.class, () -> new FilterExpr.Compare<>(text, Operator.IN, List.of("nvim", 1))); + } + + @Test + void independentlyMintedHandlesHaveDistinctIdentity() { + Function fieldAccessor = Pane::command; + Function> toManyNavigator = Window::panes; + Function> toOneNavigator = Window::activePane; + + assertNotEquals( + Fields.text("command", fieldAccessor).ref(), + Fields.text("command", fieldAccessor).ref()); + assertNotEquals(Fields.toMany("panes", toManyNavigator), Fields.toMany("panes", toManyNavigator)); + assertNotEquals(Fields.toOne("activePane", toOneNavigator), Fields.toOne("activePane", toOneNavigator)); + } + + @Test + void relationNodesRetainTheExactHandle() { + Fields.ToManyRef panes = Window_.panes(); + Fields.ToOneRef activePane = Window_.activePane(); + + FilterExpr.ToMany quantified = panes.any(Pane_.active().isTrue()); + FilterExpr.ToOne traversed = activePane.is(Pane_.active().isTrue()); + + assertSame(panes, quantified.relation()); + assertSame(activePane, traversed.relation()); + } + @Test void cardinalityDistinguishesNoneFromSeveral() { assertEquals(SHELL, Selections.exactlyOne(matchingPanes(Pane_.command().is("zsh")))); @@ -164,6 +232,36 @@ private static List matching(FilterExpr expression) { return PANES.stream().filter(expression).map(Pane::id).toList(); } + private static Object operand(Operator operator, FieldKind kind) { + return switch (operator) { + case MATCHES -> Pattern.compile("nv"); + case IN -> List.of("nvim"); + case LESS_THAN, AT_MOST, GREATER_THAN, AT_LEAST -> 1; + case EQUALS, NOT_EQUALS, CONTAINS, STARTS_WITH, ENDS_WITH -> + switch (kind) { + case TEXT -> "nvim"; + case NUMBER -> 1; + case FLAG -> true; + }; + }; + } + + private static FieldRef field(FieldKind kind) { + return switch (kind) { + case TEXT -> Fields.text("command", Pane::command).ref(); + case NUMBER -> Fields.number("index", Pane::index).ref(); + case FLAG -> Fields.flag("active", Pane::active).ref(); + }; + } + + private static Set supported(Operator operator) { + return switch (operator) { + case EQUALS, NOT_EQUALS -> Set.of(FieldKind.TEXT, FieldKind.NUMBER, FieldKind.FLAG); + case CONTAINS, STARTS_WITH, ENDS_WITH, MATCHES, IN -> Set.of(FieldKind.TEXT); + case LESS_THAN, AT_MOST, GREATER_THAN, AT_LEAST -> Set.of(FieldKind.NUMBER); + }; + } + private static List matchingPanes(FilterExpr expression) { return PANES.stream().filter(expression).toList(); } diff --git a/libtmux/src/test/java/io/github/libtmux/query/MetamodelConformance.java b/libtmux/src/test/java/io/github/libtmux/query/MetamodelConformance.java index d3a2708..c63a8c9 100644 --- a/libtmux/src/test/java/io/github/libtmux/query/MetamodelConformance.java +++ b/libtmux/src/test/java/io/github/libtmux/query/MetamodelConformance.java @@ -1,7 +1,7 @@ package io.github.libtmux.query; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.fail; import java.lang.reflect.InvocationTargetException; @@ -18,10 +18,10 @@ * The guard that replaces a code generator. * *

Hand-written metamodels are small, explicit domain code and cost nothing at build time, but they - * can drift: a duplicated identifier, a field declared with the wrong kind, or an accessor that quietly - * bypasses canonical minting. Those are exactly the mistakes a generator would have made impossible, - * so they are asserted here instead — which attacks the real downside of handwriting without adding a - * compiler plugin, an incremental-build story, or generated sources to debug. + * can drift: a duplicated identifier or a field declared with the wrong kind. Those are exactly the + * mistakes a generator would have made impossible, so they are asserted here instead — which attacks + * the real downside of handwriting without adding a compiler plugin, an incremental-build story, or + * generated sources to debug. * *

Reflection is deliberate. A hand-maintained list of expected handles would drift in the same way * the metamodel does, so the check reads what the class actually declares. @@ -31,7 +31,7 @@ public final class MetamodelConformance { private MetamodelConformance() {} /** One declared handle: the method that exposes it and what it turned out to be. */ - public record Handle(String method, String fieldId, FieldKind kind, boolean canonical, int arity) {} + public record Handle(String method, String fieldId, FieldKind kind) {} /** * Asserts the metamodel is internally consistent and covers exactly {@code expectedFieldIds}. @@ -60,17 +60,14 @@ public static void assertConformant(Class metamodel, Set expectedFiel fail("scalar handle " + method.getName() + " must be a bare static, not take arguments"); } FieldRef ref = refOf(metamodel, method, returned); - scalars.add(new Handle( - method.getName(), ref.id(), ref.kind(), ref.provenance().lowerable(), 0)); + assertSame( + ref, refOf(metamodel, method, returned), "scalar handle " + method.getName() + " must be stable"); + scalars.add(new Handle(method.getName(), ref.id(), ref.kind())); assertEquals( expected, ref.kind(), "handle " + method.getName() + " returns a " + returned.getSimpleName() + " but its field is kinded " + ref.kind()); - assertTrue( - ref.provenance().lowerable(), - "handle " + method.getName() + " is not canonical; it must be minted through " - + "EntityMetamodel or no backend may trust its name"); } Map byId = new LinkedHashMap<>(); @@ -98,6 +95,10 @@ public static void assertConformant(Class metamodel, Set expectedFiel relation.getParameterCount(), "relation handle " + relation.getName() + " should be a bare static when the entity holds its relation"); + assertSame( + invoke(metamodel, relation), + invoke(metamodel, relation), + "relation handle " + relation.getName() + " must be stable"); } } } @@ -118,8 +119,7 @@ private static FieldKind kindOf(Class returned) { private static FieldRef refOf(Class metamodel, Method method, Class returned) { try { - method.setAccessible(true); - Object handle = method.invoke(null); + Object handle = invoke(metamodel, method); Method ref = returned.getMethod("ref"); return (FieldRef) ref.invoke(handle); } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { @@ -127,4 +127,14 @@ private static FieldKind kindOf(Class returned) { "could not read handle " + metamodel.getSimpleName() + "." + method.getName(), e); } } + + private static Object invoke(Class metamodel, Method method) { + try { + method.setAccessible(true); + return method.invoke(null); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException( + "could not read handle " + metamodel.getSimpleName() + "." + method.getName(), e); + } + } } diff --git a/libtmux/src/test/java/io/github/libtmux/query/MetamodelConformanceTest.java b/libtmux/src/test/java/io/github/libtmux/query/MetamodelConformanceTest.java index b91fdd2..55672f4 100644 --- a/libtmux/src/test/java/io/github/libtmux/query/MetamodelConformanceTest.java +++ b/libtmux/src/test/java/io/github/libtmux/query/MetamodelConformanceTest.java @@ -2,6 +2,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; +import io.github.libtmux.Client_; +import io.github.libtmux.Pane_; +import io.github.libtmux.Session_; +import io.github.libtmux.Window_; import io.github.libtmux.query.Model.Pane; import java.util.Set; import org.junit.jupiter.api.Test; @@ -13,39 +17,30 @@ final class MetamodelConformanceTest { void theHandWrittenMetamodelsConform() { MetamodelConformance.assertConformant(Model.Pane_.class, Set.of("command", "index", "active"), false); MetamodelConformance.assertConformant(Model.Window_.class, Set.of("name"), false); - } - - /** A metamodel that bypasses canonical minting is the drift a generator would have prevented. */ - static final class Drifted { - static Fields.TextField command() { - // The mistake: a plain builder, so the field is derived and silently not pushdown-eligible. - return Fields.text("command", Pane::command); - } - - private Drifted() {} + MetamodelConformance.assertConformant( + Pane_.class, Set.of("pane_id", "pane_current_command", "pane_index", "pane_active"), false); + MetamodelConformance.assertConformant( + Window_.class, + Set.of("window_id", "window_name", "window_index", "window_active", "window_linked"), + false); + MetamodelConformance.assertConformant( + Session_.class, Set.of("session_id", "session_name", "session_attached"), false); + MetamodelConformance.assertConformant(Client_.class, Set.of("client_name"), false); } /** Two handles, one identifier — the other mistake handwriting invites. */ - static final class Duplicated extends EntityMetamodel { + static final class Duplicated { static Fields.TextField command() { - return text("command", Pane::command); + return Fields.text("command", Pane::command); } static Fields.TextField alias() { - return text("command", Pane::command); + return Fields.text("command", Pane::command); } private Duplicated() {} } - @Test - void theGuardRejectsANonCanonicalHandle() { - assertThrows( - AssertionError.class, - () -> MetamodelConformance.assertConformant(Drifted.class, Set.of("command"), false), - "a derived field must not pass as a metamodel handle"); - } - @Test void theGuardRejectsADuplicateFieldId() { assertThrows( diff --git a/libtmux/src/test/java/io/github/libtmux/query/Model.java b/libtmux/src/test/java/io/github/libtmux/query/Model.java index 04f04ea..f29ddc4 100644 --- a/libtmux/src/test/java/io/github/libtmux/query/Model.java +++ b/libtmux/src/test/java/io/github/libtmux/query/Model.java @@ -13,33 +13,41 @@ record Window(String id, String name, boolean active, List panes, Optional private Model() {} /** The generated shape: static, typed, one handle per field and relation. */ - static final class Pane_ extends EntityMetamodel { + static final class Pane_ { + private static final Fields.TextField COMMAND = Fields.text("command", Pane::command); + private static final Fields.NumberField INDEX = Fields.number("index", Pane::index); + private static final Fields.FlagField ACTIVE = Fields.flag("active", Pane::active); + static Fields.TextField command() { - return text("command", Pane::command); + return COMMAND; } static Fields.NumberField index() { - return number("index", Pane::index); + return INDEX; } static Fields.FlagField active() { - return flag("active", Pane::active); + return ACTIVE; } private Pane_() {} } - static final class Window_ extends EntityMetamodel { + static final class Window_ { + private static final Fields.TextField NAME = Fields.text("name", Window::name); + private static final Fields.ToManyRef PANES = Fields.toMany("panes", Window::panes); + private static final Fields.ToOneRef ACTIVE_PANE = Fields.toOne("activePane", Window::activePane); + static Fields.TextField name() { - return text("name", Window::name); + return NAME; } static Fields.ToManyRef panes() { - return toMany("panes", Window::panes); + return PANES; } static Fields.ToOneRef activePane() { - return toOne("activePane", Window::activePane); + return ACTIVE_PANE; } private Window_() {} From 2deefc5cedcf43300b61c94bd83c20603d728424 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 09:38:40 -0500 Subject: [PATCH 03/77] Core(fix[state]): Reject ambiguous server state why: Handles could act on a replacement tmux process, linked windows lost their session context, and generic execution carriers could change command meaning after tmux alias expansion. what: - Bind snapshots and handles to the live tmux process identity - Validate hierarchy slots, targets, layouts, and hydration scalars - Remove execution modes and their benchmark surface --- .github/CONTRIBUTING.md | 14 - .github/WRITING.md | 6 +- README.md | 32 +- benchmarks/README.md | 22 -- benchmarks/build.gradle.kts | 30 -- .../libtmux/benchmark/ModeBenchmark.java | 316 ------------------ docs/benchmarks/modes.md | 51 --- docs/guide/batching-and-chaining.md | 11 +- docs/guide/execution-modes.md | 132 -------- docs/guide/getting-started.md | 17 +- docs/guide/mcp.md | 17 +- integration-tests/README.md | 13 - .../it/CarrierFromEnvironmentTest.java | 190 ----------- .../it/CommandChainIntegrationTest.java | 4 + .../it/ExecutionModeConformanceTest.java | 315 ----------------- .../libtmux/it/OperationsIntegrationTest.java | 21 ++ libtmux/README.md | 29 +- .../main/java/io/github/libtmux/Client.java | 18 +- .../java/io/github/libtmux/ExecutionMode.java | 119 ------- .../main/java/io/github/libtmux/Hooks.java | 35 +- .../main/java/io/github/libtmux/Layout.java | 7 + .../main/java/io/github/libtmux/Layouts.java | 172 +++++++++- .../main/java/io/github/libtmux/Options.java | 45 ++- .../src/main/java/io/github/libtmux/Pane.java | 104 +++--- .../main/java/io/github/libtmux/Server.java | 211 +++++++----- .../java/io/github/libtmux/ServerConfig.java | 42 +-- .../io/github/libtmux/ServerIdentity.java | 28 +- .../main/java/io/github/libtmux/Session.java | 44 ++- .../java/io/github/libtmux/TargetIds.java | 7 +- .../io/github/libtmux/TmuxEnvironment.java | 27 +- .../main/java/io/github/libtmux/Window.java | 88 ++--- .../libtmux/snapshot/ServerSnapshot.java | 159 ++++++++- .../libtmux/transport/ControlTransport.java | 232 ------------- .../transport/VirtualThreadTransport.java | 93 ------ .../io/github/libtmux/ExecutionModeTest.java | 79 ----- .../java/io/github/libtmux/HandleTest.java | 88 ++++- .../io/github/libtmux/ServerConfigTest.java | 60 ---- .../java/io/github/libtmux/ServerTest.java | 72 ++++ .../java/io/github/libtmux/TargetIdTest.java | 7 + .../github/libtmux/TmuxEnvironmentTest.java | 4 + .../libtmux/snapshot/ServerSnapshotTest.java | 176 ++++++++-- .../transport/ControlTransportTest.java | 64 ---- .../transport/VirtualThreadTransportTest.java | 77 ----- settings.gradle.kts | 1 - 44 files changed, 1030 insertions(+), 2249 deletions(-) delete mode 100644 benchmarks/README.md delete mode 100644 benchmarks/build.gradle.kts delete mode 100644 benchmarks/src/test/java/io/github/libtmux/benchmark/ModeBenchmark.java delete mode 100644 docs/benchmarks/modes.md delete mode 100644 docs/guide/execution-modes.md delete mode 100644 integration-tests/src/test/java/io/github/libtmux/it/CarrierFromEnvironmentTest.java delete mode 100644 integration-tests/src/test/java/io/github/libtmux/it/ExecutionModeConformanceTest.java delete mode 100644 libtmux/src/main/java/io/github/libtmux/ExecutionMode.java delete mode 100644 libtmux/src/main/java/io/github/libtmux/transport/ControlTransport.java delete mode 100644 libtmux/src/main/java/io/github/libtmux/transport/VirtualThreadTransport.java delete mode 100644 libtmux/src/test/java/io/github/libtmux/ExecutionModeTest.java delete mode 100644 libtmux/src/test/java/io/github/libtmux/transport/ControlTransportTest.java delete mode 100644 libtmux/src/test/java/io/github/libtmux/transport/VirtualThreadTransportTest.java diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 773942d..c4f3d58 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -16,7 +16,6 @@ Nothing about this is a convention you have to remember: | --- | --- | --- | | `libtmux*/` | yes | one artifact each, named for its directory | | `integration-tests/` | no | the real-tmux suite, which spans artifacts | -| `benchmarks/` | no | the carrier measurements | | `examples/` | no | whole runnable programs, run by its own suite | | [`docs-tests/`](../docs-tests/) | no | compiles and runs every snippet in the docs | | `scripts/` | no | what the build does not do | @@ -88,19 +87,6 @@ A unix socket path cannot exceed about 104 bytes, and tmux reports a longer one as `error connecting to … (File name too long)`. That rules out sockets under a build directory or a deep scratch path, and it is why the roots above are short. -`ExecutionMode` chooses how a command travels, and the suite can be run under -any of them: - -```console -$ LIBTMUX_MODE=control ./gradlew check -``` - -A failure that appears only under one carrier is a real finding — the library -claims the answer does not depend on the carrier, and -`ExecutionModeConformanceTest` is where that claim is gated. Confirm it against -a clean `/tmp` first: the failure modes of cross-port debris and of a genuine -carrier defect look alike. - Against every supported tmux release: ```console diff --git a/.github/WRITING.md b/.github/WRITING.md index 4c6a776..8434a67 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -79,7 +79,7 @@ makes it decidable: points at `tmux_list_servers`. ``` -Name identifiers literally: `Pane.capture`, `LIBTMUX_MODE`, `--rerun-tasks`, +Name identifiers literally: `Pane.capture`, `LIBTMUX_WATCH`, `--rerun-tasks`, `tmux://panes/{pane}`. Lead with a concrete verb — add, fix, remove, reject, `now`, `no longer`. @@ -354,8 +354,8 @@ has nothing but the string: "JDK 21" both appear upstream — this project writes **JDK 21**. - A **pane**, **window**, **session**, and **server** are what tmux calls them. Do not introduce a synonym for one. -- Write the identifier, not a description of it: `LIBTMUX_MODE=control`, not - "the mode environment variable"; `--rerun-tasks`, not "the rerun flag"; +- Write the identifier, not a description of it: `LIBTMUX_WATCH=true`, not + "the watch environment variable"; `--rerun-tasks`, not "the rerun flag"; `/tmp/libtmux-java-test/`, not "the test socket directory". Treat AI slop as review-hostile noise. The goal is information density: diff --git a/README.md b/README.md index c940f6f..d93a744 100644 --- a/README.md +++ b/README.md @@ -159,32 +159,11 @@ In a real pane those two variables are already set, so `TmuxEnvironment.current( takes nothing and returns empty when there is no pane to describe. This README is not running inside one, so the example supplies them. -## Three switches +## Avoid unnecessary round trips -| to stop | write | which costs | -| ------------------------------------------- | ------------------------------ | ------------------------------ | -| paying for a process per command | `.mode(ExecutionMode.CONTROL)` | one tmux client, then reused | -| waiting on the thread you were handed | `.mode(ExecutionMode.VIRTUAL)` | a virtual thread per command | -| round-tripping to learn what you just made | `server.chain()` | one request, however many steps | - -```java -ServerConfig config = ServerConfig.builder() - .endpoint(ServerEndpoint.socketPath(socket)) - .mode(ExecutionMode.CONTROL) - .build(); -``` - -A carrier can also be chosen from outside the program that uses one, so trying -another costs nothing: - -```console -$ LIBTMUX_MODE=control java -jar app.jar -``` - -A carrier and a grouping are separate choices that compose, and neither changes -what a call returns: the same filter answers identically under each. What that -costs is measured in [the benchmark](docs/benchmarks/modes.md), which shows the -identical answers next to the different prices. +`server.batch()` sends independent commands in one invocation. `server.chain()` +does the same for dependent commands, letting tmux carry the current target from +one step to the next. Both retain an outcome for every operation. ## What it is like to use @@ -238,7 +217,7 @@ README, and each is [on Maven Central](https://central.sonatype.com/namespace/io Not published, and part of how the library is built: [`examples/`](examples/) · [`integration-tests/`](integration-tests/) · -[`docs-tests/`](docs-tests/) · [`benchmarks/`](benchmarks/) · +[`docs-tests/`](docs-tests/) · [`scripts/`](scripts/) · `build-logic/` A directory is a published artifact exactly when it appears above, and @@ -317,7 +296,6 @@ $ ./gradlew testTmuxMatrix -PlibtmuxMatrix=/path/to/tmux/builds ## Documentation - [Getting started](docs/guide/getting-started.md) -- [Execution modes](docs/guide/execution-modes.md) — and the [measured comparison](docs/benchmarks/modes.md) - [Filtering](docs/guide/filtering.md) - [Options and hooks](docs/guide/options-and-hooks.md) - [Batching and chaining](docs/guide/batching-and-chaining.md) diff --git a/benchmarks/README.md b/benchmarks/README.md deleted file mode 100644 index 5c756c0..0000000 --- a/benchmarks/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# benchmarks - -**Measures what each carrier costs. Not published.** - -`ExecutionMode` changes how a command travels, never what it answers. This -measures the difference in price, and regenerates -[`docs/benchmarks/modes.md`](../docs/benchmarks/modes.md) from a real run. - -```console -$ ./gradlew modeBenchmark -PlibtmuxTmux=/path/to/tmux -``` - -The table is **never hand-edited**. It shows identical answers next to different -prices, which is the only honest way to present a performance switch. - -Its own module, and excluded from `check`: a benchmark starts a tmux server per -case and takes seconds. Keeping it inside a published artifact's tests made that -a matter of remembering a tag rather than a matter of where the code lives. - -## Next - -- [Execution modes](../docs/guide/execution-modes.md) · [the measured table](../docs/benchmarks/modes.md) diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts deleted file mode 100644 index 4bbb543..0000000 --- a/benchmarks/build.gradle.kts +++ /dev/null @@ -1,30 +0,0 @@ -// Measures what each carrier costs, and rewrites docs/benchmarks/modes.md from a real run. -// -// Its own module, and never published. A benchmark takes seconds per case and starts a server per -// case, so it must not run in any ordinary suite; keeping it in a published artifact's tests made -// that a matter of remembering a tag rather than a matter of where the code lives. -plugins { id("libtmux.java-library") } - -dependencies { - testImplementation(project(":libtmux")) - testImplementation(project(":libtmux-junit5")) -} - -// Nothing here belongs to `check`: it writes a file and takes seconds. Run it when the table needs -// regenerating. -tasks.named("test") { enabled = false } - -tasks.register("modeBenchmark") { - group = "verification" - description = "Measures each execution mode and rewrites docs/benchmarks/modes.md." - val tests = sourceSets.test.get() - testClassesDirs = tests.output.classesDirs - classpath = tests.runtimeClasspath - useJUnitPlatform { includeTags("benchmark") } - systemProperty("libtmux.tmux", providers.gradleProperty("libtmuxTmux").getOrElse("tmux")) - systemProperty( - "libtmux.benchmark.out", - rootProject.layout.projectDirectory.file("docs/benchmarks/modes.md").asFile.path, - ) - outputs.upToDateWhen { false } -} diff --git a/benchmarks/src/test/java/io/github/libtmux/benchmark/ModeBenchmark.java b/benchmarks/src/test/java/io/github/libtmux/benchmark/ModeBenchmark.java deleted file mode 100644 index 882f20b..0000000 --- a/benchmarks/src/test/java/io/github/libtmux/benchmark/ModeBenchmark.java +++ /dev/null @@ -1,316 +0,0 @@ -package io.github.libtmux.benchmark; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.libtmux.CommandChain; -import io.github.libtmux.ExecutionMode; -import io.github.libtmux.Server; -import io.github.libtmux.ServerConfig; -import io.github.libtmux.ServerEndpoint; -import io.github.libtmux.Session; -import io.github.libtmux.Window; -import io.github.libtmux.Window_; -import io.github.libtmux.batch.Batch; -import io.github.libtmux.transport.CommandRequest; -import io.github.libtmux.transport.CommandResult; -import io.github.libtmux.transport.ControlTransport; -import io.github.libtmux.transport.ProcessTransport; -import io.github.libtmux.transport.TmuxTransport; -import io.github.libtmux.transport.VirtualThreadTransport; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; -import java.util.function.Function; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * Measures what each execution mode costs, and writes the table the docs show. - * - *

Tagged {@code benchmark} and excluded from the ordinary suite and the release matrix: it takes - * seconds rather than milliseconds and it writes a file. Run it with {@code ./gradlew modeBenchmark}. - * - *

Numbers are never written by hand. This regenerates {@code docs/benchmarks/modes.md} from a run - * on the tmux it is given, and stamps which tmux that was, because a table without its conditions is - * a claim rather than a measurement. - */ -@Tag("benchmark") -final class ModeBenchmark { - - private static final int ROUNDS = 20; - - /** - * The tmux the measurements ran against, asked of the running server rather than inferred. - * - *

A path would name a build without proving which one answered, and would put whichever - * machine measured last into a shipped document. - */ - private String tmux = ""; - - /** Counts what a carrier really did, which is the only honest way to report process cost. */ - private static final class Counting implements TmuxTransport { - - private final TmuxTransport delegate; - private final AtomicInteger dispatches = new AtomicInteger(); - - Counting(TmuxTransport delegate) { - - this.delegate = delegate; - } - - @Override - public CommandResult execute(CommandRequest request) { - dispatches.incrementAndGet(); - return delegate.execute(request); - } - - @Override - public String realm() { - return delegate.realm(); - } - - @Override - public void close() { - delegate.close(); - } - } - - /** - * One measurement of one scenario. - * - * @param label the carrier, and the strategy too where a section compares them - * @param processes tmux processes actually started, counted rather than assumed - * @param viaClient whether a control client carried what the processes did not - */ - private record Measured( - String label, long millis, int dispatches, int processes, boolean viaClient, String output) {} - - @Test - void writeTheModeTable(@TempDir Path directory) throws Exception { - List traversal = new ArrayList<>(); - List creation = new ArrayList<>(); - List grouping = new ArrayList<>(); - Map sameQuery = new LinkedHashMap<>(); - - for (ExecutionMode mode : ExecutionMode.values()) { - Measured read = measure(directory, mode, "traversal", ModeBenchmark::plantWindows, ModeBenchmark::traverse); - traversal.add(read); - Measured oneAtATime = measure(directory, mode, "creation", server -> {}, ModeBenchmark::create); - creation.add(oneAtATime); - grouping.add(relabelled(oneAtATime, mode + ", one call at a time")); - grouping.add(relabelled( - measure(directory, mode, "batch", server -> {}, ModeBenchmark::createBatched), mode + ", batch()")); - grouping.add(relabelled( - measure(directory, mode, "chain", server -> {}, ModeBenchmark::createChained), mode + ", chain()")); - // The traversal scenario is the one whose work *is* the query below, so its output is - // what the table may show. Taking creation's would have labelled a window count as a - // filter result. - sameQuery.put(mode.name(), read.output()); - } - - assertEquals( - 1, Set.copyOf(sameQuery.values()).size(), "the modes disagreed about the query result: " + sameQuery); - assertEquals( - 1, - grouping.stream().map(Measured::output).distinct().count(), - "grouping the same commands built something different: " - + grouping.stream() - .map(row -> row.label() + "=" + row.output()) - .toList()); - - // Told where to write rather than guessing from a working directory, which for a Gradle - // Test task is the module and not the root. - Path report = Path.of(System.getProperty("libtmux.benchmark.out", "build/modes.md")); - Files.createDirectories(report.getParent()); - Files.writeString(report, render(traversal, creation, grouping, sameQuery)); - - assertTrue(Files.exists(report), "the benchmark wrote no table"); - } - - // ------------------------------------------------------------------------------- scenarios - - /** Gives the traversal something to find, before the clock starts. */ - private static void plantWindows(Server server) { - Session session = server.sessions().get(0); - for (int i = 0; i < 3; i++) { - String name = "bench-" + i; - session.newWindow(w -> w.named(name).detached()); - } - } - - /** Reads the hierarchy repeatedly: what a program watching tmux does. */ - private static String traverse(Server server) { - String seen = ""; - for (int round = 0; round < ROUNDS; round++) { - seen = server.windows().stream() - .filter(Window_.name().startsWith("bench")) - .map(Window::name) - .sorted() - .toList() - .toString(); - } - return seen; - } - - /** Builds a workspace: what a program setting tmux up does. */ - private static String create(Server server) { - Session session = server.sessions().get(0); - for (int round = 0; round < ROUNDS; round++) { - String name = "bench-" + round; - session.newWindow(w -> w.named(name).detached()); - } - return Integer.toString(session.refresh().windows().size()); - } - - /** The same workspace, asked for in one request. */ - private static String createBatched(Server server) { - Session session = server.sessions().get(0); - Batch batch = server.batch(); - for (int round = 0; round < ROUNDS; round++) { - batch.add("new-window", "-d", "-n", "bench-" + round); - } - batch.run(); - return Integer.toString(session.refresh().windows().size()); - } - - /** The same workspace again, as steps that each act on what the last one made. */ - private static String createChained(Server server) { - Session session = server.sessions().get(0); - CommandChain chain = server.chain(); - for (int round = 0; round < ROUNDS; round++) { - chain.newWindow("bench-" + round); - } - chain.run(); - return Integer.toString(session.refresh().windows().size()); - } - - /** The same measurement under a name that says which strategy produced it. */ - private static Measured relabelled(Measured measured, String label) { - return new Measured( - label, - measured.millis(), - measured.dispatches(), - measured.processes(), - measured.viaClient(), - measured.output()); - } - - // ------------------------------------------------------------------------------- measuring - - private Measured measure( - Path root, ExecutionMode mode, String scenario, Consumer setUp, Function work) - throws IOException { - Path home = root.resolve(mode.name() + "-" + scenario); - Files.createDirectories(home); - Path config = home.resolve("empty.conf"); - Files.writeString(config, ""); - ServerConfig built = ServerConfig.builder() - .binary(System.getProperty("libtmux.tmux", "tmux")) - .endpoint(ServerEndpoint.socketPath(home.resolve("s"))) - .configFile(config) - .defaultTimeout(Duration.ofSeconds(30)) - .build(); - - // Two counters, because a carrier that falls back does not say so. The inner one is the only - // thing that ever starts a process, so counting there reports what was really spent rather - // than what the mode implies — which is how a command group under CONTROL, carried by a - // process because a control client frames one reply per command, shows up as the process it - // is instead of hiding behind "one client, reused". - Counting processes = new Counting(new ProcessTransport()); - TmuxTransport carrier = - switch (mode) { - case DIRECT -> processes; - case CONTROL -> new ControlTransport(built, processes); - case VIRTUAL -> new VirtualThreadTransport(processes); - }; - Counting counting = new Counting(carrier); - - try (Server server = Server.using(built, counting)) { - server.newSession("bench"); - tmux = server.version().toString(); - setUp.accept(server); - // Warm: the first command of any mode pays for starting a server, which is not what - // is being compared. Whatever the scenario needed is already in place, so none of it is - // timed either. - server.windows(); - int before = counting.dispatches.get(); - int spawnedBefore = processes.dispatches.get(); - long started = System.nanoTime(); - String output = work.apply(server); - long millis = (System.nanoTime() - started) / 1_000_000; - int dispatches = counting.dispatches.get() - before; - int spawned = processes.dispatches.get() - spawnedBefore; - server.killServer(); - return new Measured(mode.name(), millis, dispatches, spawned, mode == ExecutionMode.CONTROL, output); - } finally { - counting.close(); - } - } - - // --------------------------------------------------------------------------------- the table - - private String render( - List traversal, List creation, List grouping, Map sameQuery) { - StringBuilder out = new StringBuilder(); - out.append("# Execution modes, measured\n\n") - .append("Regenerated by `./gradlew modeBenchmark`. Never edit by hand.\n\n") - .append("Measured against tmux `") - .append(tmux) - .append("`, ") - .append(ROUNDS) - .append(" rounds per scenario, on one machine at one moment. ") - .append("Read the shape, not the milliseconds.\n\n"); - - out.append("## Reading the hierarchy\n\n"); - table(out, "mode", traversal); - out.append("\n## Building a workspace\n\n"); - table(out, "mode", creation); - - out.append("\n## Collapsing round trips\n\n") - .append("The same ") - .append(ROUNDS) - .append(" windows, asked for three ways under each carrier. ") - .append("`batch()` and `chain()` are not modes: they compose with whichever one is in force.\n\n"); - table(out, "carrier and strategy", grouping); - out.append("\nA group is carried by a process even under `CONTROL`, because a control client ") - .append("frames one reply per command and a group would desynchronise the stream. ") - .append("The process column is counted, not assumed, so that shows up here.\n"); - - out.append("\n## The same query, every way\n\n") - .append("`server.windows().stream().filter(Window_.name().startsWith(\"bench\"))`, ") - .append("and what each mode answered:\n\n") - .append("| mode | result |\n| --- | --- |\n"); - sameQuery.forEach((mode, result) -> - out.append("| `").append(mode).append("` | `").append(result).append("` |\n")); - out.append("\nIdentical, which is the point: a mode changes the carrying and not the answer. ") - .append("`ExecutionModeConformanceTest` asserts that; this shows it.\n"); - return out.toString(); - } - - private static void table(StringBuilder out, String heading, List rows) { - out.append("| %s | wall clock | commands dispatched | tmux processes |%n".formatted(heading)) - .append("| --- | --- | --- | --- |\n"); - for (Measured row : rows) { - out.append("| `%s` | %d ms | %d | %s |%n" - .formatted(row.label(), row.millis(), row.dispatches(), processes(row))); - } - } - - /** What the processes cost, said the way the carrier spends it. */ - private static String processes(Measured row) { - if (!row.viaClient()) { - return Integer.toString(row.processes()); - } - return row.processes() == 0 ? "1 client, reused" : "1 client, reused + " + row.processes(); - } -} diff --git a/docs/benchmarks/modes.md b/docs/benchmarks/modes.md deleted file mode 100644 index 8bbfa33..0000000 --- a/docs/benchmarks/modes.md +++ /dev/null @@ -1,51 +0,0 @@ -# Execution modes, measured - -Regenerated by `./gradlew modeBenchmark`. Never edit by hand. - -Measured against tmux `3.7b`, 20 rounds per scenario, on one machine at one moment. Read the shape, not the milliseconds. - -## Reading the hierarchy - -| mode | wall clock | commands dispatched | tmux processes | -| --- | --- | --- | --- | -| `DIRECT` | 1300 ms | 80 | 80 | -| `CONTROL` | 172 ms | 80 | 1 client, reused | -| `VIRTUAL` | 766 ms | 80 | 80 | - -## Building a workspace - -| mode | wall clock | commands dispatched | tmux processes | -| --- | --- | --- | --- | -| `DIRECT` | 2314 ms | 108 | 108 | -| `CONTROL` | 317 ms | 108 | 1 client, reused | -| `VIRTUAL` | 1418 ms | 108 | 108 | - -## Collapsing round trips - -The same 20 windows, asked for three ways under each carrier. `batch()` and `chain()` are not modes: they compose with whichever one is in force. - -| carrier and strategy | wall clock | commands dispatched | tmux processes | -| --- | --- | --- | --- | -| `DIRECT, one call at a time` | 2314 ms | 108 | 108 | -| `DIRECT, batch()` | 279 ms | 9 | 9 | -| `DIRECT, chain()` | 196 ms | 9 | 9 | -| `CONTROL, one call at a time` | 317 ms | 108 | 1 client, reused | -| `CONTROL, batch()` | 110 ms | 9 | 1 client, reused + 1 | -| `CONTROL, chain()` | 96 ms | 9 | 1 client, reused + 1 | -| `VIRTUAL, one call at a time` | 1418 ms | 108 | 108 | -| `VIRTUAL, batch()` | 170 ms | 9 | 9 | -| `VIRTUAL, chain()` | 173 ms | 9 | 9 | - -A group is carried by a process even under `CONTROL`, because a control client frames one reply per command and a group would desynchronise the stream. The process column is counted, not assumed, so that shows up here. - -## The same query, every way - -`server.windows().stream().filter(Window_.name().startsWith("bench"))`, and what each mode answered: - -| mode | result | -| --- | --- | -| `DIRECT` | `[bench-0, bench-1, bench-2]` | -| `CONTROL` | `[bench-0, bench-1, bench-2]` | -| `VIRTUAL` | `[bench-0, bench-1, bench-2]` | - -Identical, which is the point: a mode changes the carrying and not the answer. `ExecutionModeConformanceTest` asserts that; this shows it. diff --git a/docs/guide/batching-and-chaining.md b/docs/guide/batching-and-chaining.md index 8996b58..da21534 100644 --- a/docs/guide/batching-and-chaining.md +++ b/docs/guide/batching-and-chaining.md @@ -2,8 +2,7 @@ Every snippet here is executed by `ExamplesTest`. -Both put several commands into one tmux invocation. They are not -[execution modes](execution-modes.md) — they work under any carrier. +Both put several commands into one tmux invocation. ## A batch: several commands, each with its own outcome @@ -45,11 +44,3 @@ split — with no round trip to learn either id. That is the difference from a batch: a batch is several commands that happen to travel together, a chain is several commands that depend on each other. - -## Under control mode - -Both work unchanged. A group is carried by a process even when the server is in -`CONTROL`, because control mode frames a reply per command and a group would -desynchronise the stream. Nothing marks the difference at the call site — see -[execution modes](execution-modes.md) for why that routing is the library's job -rather than yours. diff --git a/docs/guide/execution-modes.md b/docs/guide/execution-modes.md deleted file mode 100644 index 6b31930..0000000 --- a/docs/guide/execution-modes.md +++ /dev/null @@ -1,132 +0,0 @@ -# Choosing how commands reach tmux - -Every Java snippet here is executed by a test: `ExamplesTest` for the ones that -build something, `ExecutionModeConformanceTest` for the ones about precedence. - -One switch, chosen once: - -```java -ServerConfig config = ServerConfig.builder() - .endpoint(ServerEndpoint.socketPath(socket)) - .mode(ExecutionMode.CONTROL) - .build(); - -config.mode(); // → CONTROL -``` - -Nothing else changes. The same handles answer the same questions with the same -types; only the carrying differs. - -## The three carriers - -| mode | how a command travels | costs | can stream pane output | -| -------------------- | ---------------------------------------------- | ----------------------- | ---------------------- | -| `DIRECT` *(default)* | one tmux process per command | a process each time | no | -| `CONTROL` | one persistent tmux client | one client, then reused | yes | -| `VIRTUAL` | a process per command, waited for on a virtual thread | a process each time | no | - -`DIRECT` is what the tmux binary does when a shell runs it. Nothing is held -between commands, so nothing can be stale and nothing needs a session to exist. - -`CONTROL` attaches one client and sends everything down it. - -```java -ServerConfig config = ServerConfig.builder() - .endpoint(ServerEndpoint.socketPath(socket)) - .mode(ExecutionMode.VIRTUAL) - .build(); - -config.mode(); // → VIRTUAL -``` - -`VIRTUAL` is not an async mode, and nothing about it returns sooner. The call -still blocks until tmux answers; what changes is which thread waits. It is for a -caller stuck on a small pool of platform threads who cannot choose their own — -anyone already on a virtual thread should leave it alone, because every carrier -here is safe to call from one. `CarrierStarvationTest` runs the suite under a -scheduler with exactly one carrier thread to keep that true. - -Measured numbers are in [the benchmark](../benchmarks/modes.md), regenerated by -`./gradlew modeBenchmark`. - -## Flipping one without editing code - -A program that names no mode takes the one it is given. Nothing needs rebuilding -to try another: - -```console -$ LIBTMUX_MODE=control java -jar app.jar -``` - -```console -$ java -Dlibtmux.mode=control -jar app.jar -``` - -A value that is not a mode is refused rather than ignored, because falling back -to a default would leave you believing a carrier was in force that never was — -and every carrier answers the same, so nothing else would give it away. - -## Precedence - -Highest first: - -1. **Per-call** — `server.cmd(argv, timeout, ExecutionMode.DIRECT)`. -2. **In code** — `ServerConfig.builder().mode(…)`, which settles it. -3. **This JVM** — `-Dlibtmux.mode`. -4. **This environment** — `LIBTMUX_MODE`. -5. **Default** — `DIRECT` when nothing says otherwise. - -`config.mode()` reports the one that won, so what is in force is a question with -an answer rather than a guess about what the environment held. - -```java -List argv = List.of("display-message", "-p", "#{session_name}"); - -// carried the way the config said -List byConfig = server.cmd(argv, timeout).stdout(); - -// carried the way this call said -List byCall = server.cmd(argv, timeout, ExecutionMode.DIRECT).stdout(); - -byConfig.equals(byCall); // → true -``` - -Both answer the same thing. That is the point, and it is also the reason the -override is rarely worth reaching for: nothing a handle returns depends on which -carrier answered, so this changes cost and nothing else. The one case where the -carrier affects correctness — a command group — routes itself without being -asked. Reach for the override when you have measured a reason, not by default. - -A carrier made for an override belongs to the server that made it and is closed -with it, however the server's own transport was obtained. - -## What is deliberately not on this switch - -Batching and chaining are often listed alongside these. They are not -alternatives to them, and putting them on one switch would say they were. - -| this | is | and works | -| ---------------- | -------------------------------------- | ---------------- | -| `server.batch()` | several commands in one request | under any carrier | -| `server.chain()` | steps acting on what the last one made | under any carrier | - -What each buys is measured alongside the carriers in -[the benchmark](../benchmarks/modes.md). - -## Attaching takes a session - -Control mode attaches to a session, so it cannot carry the command that creates -the first one. Until a session exists the carrier falls back to a process, and -attaches as soon as there is something to attach to. - -```java -try (Server open = Server.open(config)) { - Session first = open.newSession("work"); // carried by a process - first.newWindow(w -> w.named("logs")); // carried by the control client - - open.hasSession("work"); // → true -} -``` - -Nothing in the API marks the difference, and nothing needs to: both answer with -the same types. The only trace is the process count in the benchmark. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index e509636..ef02191 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -36,9 +36,8 @@ session.name(); // → demo window.name(); // → build ``` -By default each command is one tmux process. One switch makes them share a -persistent client instead — see [execution modes](execution-modes.md), which has -the measured difference and says what else is worth reading. +Each command runs through one tmux process. Use a batch or chain when several +commands should share an invocation. `Server.open` owns the transport it creates and closes it. `Server.using` borrows one you own and never closes it, so several servers can share a transport. @@ -204,17 +203,20 @@ just created. A control client stays attached and pushes terminal output as it happens: ```java -try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - List seen = new CopyOnWriteArrayList<>(); - client.onOutput(seen::add); +try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription output = client.subscribeOutput(32)) { client.send("send-keys", "-t", session.name(), "echo streamed", "Enter"); + + PaneOutput arrived = output.next(Duration.ofSeconds(5)).orElseThrow(); + arrived.data().contains("streamed"); // → true } ``` Control-mode requests are independent: a failure discards nothing behind it, and every reply carries the request that produced it. Attaching is what makes tmux -push output at all. +push output at all. The bounded subscription reports overflow through +`droppedCount()` and never runs caller code on the reply reader. ## Pinning tmux's configuration @@ -236,7 +238,6 @@ pinned.configFile().isPresent(); // → true | you want to | read | | --------------------------------- | --------------------------------------------- | -| make every command cost less | [execution modes](execution-modes.md) | | select things without lambdas | [filtering](filtering.md) | | read and write tmux's settings | [options and hooks](options-and-hooks.md) | | send several commands at once | [batching and chaining](batching-and-chaining.md) | diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index c381c61..8cc0b5b 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -140,20 +140,20 @@ re-expands on its own one-second timer and reports **only when the value differs That is a change detector inside the server. With `--watch`, this server turns those into MCP `notifications/resources/updated`, so a client holding -`tmux://panes/%1/content` refreshes when there is a reason to and never otherwise. +`tmux://panes/%251/content` refreshes when pane `%1` changes and never otherwise. The same mechanism is available to any Java caller: ```java -try (ControlClient client = ControlClient.attach(server.config(), session.id())) { - client.onEvent(event -> { - event.subscription(); // which watch this came from - event.paneId(); // which pane, when the watch is over panes - event.value(); // what the format expanded to - }); - +try (ControlClient client = ControlClient.attach(server.config(), session.id()); + EventSubscription events = client.subscribeEvents(32)) { client.watch("names", "@*", "#{window_name}"); + + ControlEvent event = events.next(Duration.ofSeconds(2)).orElseThrow(); + event.subscription(); // which watch this came from + event.windowId(); // which window, when the watch is over windows + event.value(); // what the format expanded to } ``` @@ -223,6 +223,5 @@ recovery: `no pane %9 on this server; call tmux_list_panes for the 3 that exist` - [`libtmux-mcp` README](../../libtmux-mcp/README.md) — running it, and the tool list - [Filtering](filtering.md) — the expression model a `filter` argument carries -- [Execution modes](execution-modes.md) — how commands reach tmux underneath - [Watching output as it happens](streaming.md) — the control client directly - [Control-mode subscriptions](../spikes/23-control-subscriptions.md) — what was measured diff --git a/integration-tests/README.md b/integration-tests/README.md index 55643db..4e39551 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -21,19 +21,6 @@ $ ./scripts/tmux-matrix.sh ~/tmux-builds $ ./gradlew testTmuxMatrix -PlibtmuxMatrix=~/tmux-builds ``` -Under a different carrier, which must not change any answer: - -```console -$ LIBTMUX_MODE=control ./gradlew :integration-tests:test -``` - -## The one that matters most - -[`ExecutionModeConformanceTest`](src/test/java/io/github/libtmux/it/ExecutionModeConformanceTest.java) -runs the same trajectory under every carrier and compares step by step. It is -where the library's central promise is kept, and it has caught a real breach: -[`docs/spikes/21`](../docs/spikes/21-command-group-boundaries.md). - ## Before blaming a change This host may carry hundreds of tmux servers belonging to sibling ports. Count diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CarrierFromEnvironmentTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CarrierFromEnvironmentTest.java deleted file mode 100644 index 31d4984..0000000 --- a/integration-tests/src/test/java/io/github/libtmux/it/CarrierFromEnvironmentTest.java +++ /dev/null @@ -1,190 +0,0 @@ -package io.github.libtmux.it; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.libtmux.ExecutionMode; -import io.github.libtmux.Server; -import io.github.libtmux.ServerConfig; -import io.github.libtmux.ServerEndpoint; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.concurrent.TimeUnit; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * A carrier chosen for a program from outside it, proved by launching one. - * - *

Resolving {@code LIBTMUX_MODE} into a config is a unit test's job and is tested there. This - * asks the harder question: does a program started with the variable really carry its commands that - * way? So a JVM is launched with it and tmux is asked what attached — a control client reports - * itself through {@code #{client_control_mode}}, on every release in the supported range. - * - *

The same program is launched again without the variable, and must attach nothing. Without that - * arm the test would pass against a library that ignored the variable entirely. - */ -final class CarrierFromEnvironmentTest { - - private static final String TMUX = System.getProperty("libtmux.tmux", "tmux"); - - /** - * Longer than the launched program's own deadline, so that a stuck command fails as itself. - * - *

Its config takes the default 30-second timeout, and an error naming the tmux command that - * timed out is worth more than this killing the JVM that would have reported it. - */ - private static final long PATIENCE_SECONDS = 60; - - /** What a launched program did and said. */ - private record Launched(int status, List said) {} - - @Test - void aProgramLaunchedWithTheVariableCarriesItsCommandsThatWay(@TempDir Path directory) throws Exception { - Path socket = directory.resolve("s"); - Path config = emptyConfig(directory); - - try (Server server = Server.open(hosting(socket, config))) { - // Control mode attaches to a session, so there has to be one before a launched program - // can do anything but fall back. - server.newSession("host"); - - Launched chosen = launch(socket, config, "control"); - Launched unset = launch(socket, config, null); - - assertEquals(0, chosen.status(), "the launched program failed: " + chosen.said()); - assertTrue( - chosen.said().contains("mode=CONTROL"), - "the launched program did not take the carrier: " + chosen.said()); - assertTrue( - chosen.said().contains("control-clients=1"), - "the launched program reported a carrier it did not use: " + chosen.said()); - - assertEquals(0, unset.status(), "the launched program failed: " + unset.said()); - assertTrue(unset.said().contains("mode=DIRECT"), "an unset variable chose a carrier: " + unset.said()); - assertTrue( - unset.said().contains("control-clients="), - "something attached a control client without being asked: " + unset.said()); - - server.killServer(); - } - } - - @Test - void aProgramLaunchedWithAMisspelledVariableSaysSoAndStops(@TempDir Path directory) throws Exception { - Path socket = directory.resolve("s"); - Path config = emptyConfig(directory); - - try (Server server = Server.open(hosting(socket, config))) { - server.newSession("host"); - - Launched refused = launch(socket, config, "contro"); - - assertNotEquals(0, refused.status(), "a misspelled carrier was survivable: " + refused.said()); - assertTrue( - refused.said().stream().anyMatch(line -> line.contains(ExecutionMode.VARIABLE)), - "a misspelled carrier has to name what to fix: " + refused.said()); - assertTrue( - refused.said().stream().noneMatch(line -> line.startsWith("mode=")), - "a misspelled carrier ran anyway, on whichever one it fell back to: " + refused.said()); - - server.killServer(); - } - } - - /** - * The tmux server the launched programs talk to. - * - *

Its carrier is named, so that the only control client tmux can report is a launched - * program's. Left unsaid, this fixture would take the carrier of whoever ran the suite, and the - * arm proving nothing attaches would be watching its own host. - */ - private static ServerConfig hosting(Path socket, Path config) { - return ServerConfig.builder() - .binary(TMUX) - .endpoint(ServerEndpoint.socketPath(socket)) - .configFile(config) - .mode(ExecutionMode.DIRECT) - .build(); - } - - private static Path emptyConfig(Path directory) throws IOException { - Path config = directory.resolve("empty.conf"); - Files.writeString(config, ""); - return config; - } - - /** - * Runs {@link Probe} in a JVM of its own and returns everything it said. - * - *

A launched program is the only way to test this honestly: a variable cannot be set for a - * process that is already running, so a test that set one would be testing something else. - */ - private static Launched launch(Path socket, Path config, @Nullable String mode) throws Exception { - List command = List.of( - Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-classpath", - System.getProperty("java.class.path"), - Probe.class.getName(), - TMUX, - socket.toString(), - config.toString()); - - ProcessBuilder builder = new ProcessBuilder(command); - builder.redirectErrorStream(true); - // Whatever tmux this test is itself running inside is not what the probe is asking about. - builder.environment().remove("TMUX"); - builder.environment().remove("TMUX_PANE"); - if (mode == null) { - builder.environment().remove(ExecutionMode.VARIABLE); - } else { - builder.environment().put(ExecutionMode.VARIABLE, mode); - } - - // Written to a file rather than read from a pipe. Draining a pipe first would park this - // thread in read() with no deadline, where the wait below could never be reached — a - // launched program that hung would hang the suite instead of failing it. - Path said = socket.resolveSibling("said-" + (mode == null ? "unset" : mode)); - builder.redirectOutput(said.toFile()); - - Process process = builder.start(); - if (!process.waitFor(PATIENCE_SECONDS, TimeUnit.SECONDS)) { - process.destroyForcibly(); - throw new IllegalStateException("the launched program never finished; it said " + Files.readAllLines(said)); - } - return new Launched(process.exitValue(), Files.readAllLines(said)); - } - - /** - * A program that names no carrier, so that whatever it was launched with chooses one. - * - *

It asks tmux which clients are attached rather than reporting its own configuration, so - * what it prints is tmux's answer and not the library agreeing with itself. - */ - static final class Probe { - - public static void main(String[] args) throws IOException { - ServerConfig config = ServerConfig.builder() - .binary(args[0]) - .endpoint(ServerEndpoint.socketPath(Path.of(args[1]))) - .configFile(Path.of(args[2])) - .build(); - - try (Server server = Server.open(config)) { - // Makes the carrier do something, which is what attaches a control client. - if (server.sessions().size() != 1) { - throw new IllegalStateException("the host session is not there"); - } - - List attached = server.cmd("list-clients", "-F", "#{client_control_mode}") - .stdout(); - System.out.println("mode=" + config.mode()); - System.out.println("control-clients=" + String.join(",", attached)); - } - } - } -} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java index 82925eb..172b2dc 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java @@ -85,6 +85,10 @@ void anUnknownLayoutIsRefusedBeforeAnythingRuns(Server server) { IllegalArgumentException.class, () -> server.chain().newWindow("safe").arrange("not-a-real-layout"), "the check has to happen while building the chain, not when running it"); + assertThrows( + IllegalArgumentException.class, + () -> server.chain().newWindow("safe").arrange("0000,80x24,0,0,1"), + "a serialized layout with the wrong checksum is just as unsafe"); assertEquals(1, server.windows().size(), "and nothing was dispatched"); } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ExecutionModeConformanceTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ExecutionModeConformanceTest.java deleted file mode 100644 index c3fe548..0000000 --- a/integration-tests/src/test/java/io/github/libtmux/it/ExecutionModeConformanceTest.java +++ /dev/null @@ -1,315 +0,0 @@ -package io.github.libtmux.it; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import io.github.libtmux.ExecutionMode; -import io.github.libtmux.Pane; -import io.github.libtmux.Server; -import io.github.libtmux.ServerConfig; -import io.github.libtmux.ServerEndpoint; -import io.github.libtmux.Session; -import io.github.libtmux.Window; -import io.github.libtmux.Window_; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -/** - * The same work, carried every way, answering the same thing. - * - *

A mode changes how a command travels and nothing else. That is the whole promise of - * {@link ExecutionMode}, and it is the kind of promise that quietly stops being true, so every - * scenario runs against one server per carrier and the answers are compared step by step. - * - *

Driven off {@code values()}, so a carrier added later is covered without anyone remembering to - * add it here. - * - *

Servers are built here rather than taken from the fixture: the fixture provides one, and this - * needs one per carrier differing in nothing else. - */ -final class ExecutionModeConformanceTest { - - private static final Duration TIMEOUT = Duration.ofSeconds(30); - - /** One named step, and what it answered. */ - private record Step(String name, Function work) {} - - private static final List SCENARIOS = List.of( - new Step("the hierarchy a capture sees", server -> { - Session session = server.sessions().get(0); - session.newWindow(w -> w.named("editor").detached()); - session.newWindow(w -> w.named("logs").detached()); - List windows = session.refresh().windows(); - // Not every name: automatic-rename takes the first window's from what its pane runs, - // and attaching a control client changes that. tmux naming a window after its process - // is not a carrier changing an answer. - return List.of( - Integer.toString(windows.size()), - windows.stream() - .map(Window::name) - .filter(name -> name.equals("editor") || name.equals("logs")) - .sorted() - .toList() - .toString()); - }), - new Step("what creating a pane reported", server -> { - Window window = server.sessions().get(0).windows().get(0); - Pane created = window.split(s -> s.toRight()); - return List.of( - Integer.toString(created.window().panes().size()), - Boolean.toString(created.edges().right())); - }), - new Step("an option read back", server -> { - var options = server.sessions().get(0).options(); - options.set("status-left", "carried"); - options.append("status-left", "-and-appended"); - return options.get("status-left").orElse(""); - }), - new Step( - "a typed filter", - server -> server.windows().stream() - .filter(Window_.name().startsWith("edit")) - .map(Window::name) - .sorted() - .toList()), - new Step("a format expansion", server -> { - Session session = server.sessions().get(0); - return session.expand("#{session_name}") + "|" - + session.windows().get(0).expand("#{window_index}"); - }), - new Step("the outcomes of a batch", server -> { - var result = server.batch() - .add("new-window", "-d", "-n", "one") - .add("new-window", "-d", "-n", "two") - .run(); - List outcomes = new ArrayList<>(); - outcomes.add(Boolean.toString(result.succeeded())); - result.operations() - .forEach(operation -> outcomes.add(operation.outcome().name())); - return outcomes; - }), - new Step( - "how a failing command failed", - server -> Boolean.toString( - server.cmd("kill-session", "-t", "=no-such-session").succeeded())), - // tmux runs the guarded command itself, and in control mode says so in the reply stream. - // A carrier that took those lines for an answer would misread every reply after them, so - // this step is here to read the hierarchy back afterwards and see whether it still parses. - new Step("a capture taken after tmux ran a command of its own", server -> { - server.ifShell("true", "rename-window guarded"); - Session session = server.sessions().get(0); - for (int attempt = 0; attempt < 100 && !named(session, "guarded"); attempt++) { - sleepBriefly(); - } - return session.refresh().windows().stream() - .map(Window::name) - .filter("guarded"::equals) - .toList() - .toString(); - }), - // tmux ends a command at a trailing semicolon on any argument, not only at one standing - // alone, so this argv is two commands however it travels. A carrier that quoted the - // semicolon instead would make it one — the window would be named "grouped;" and would - // take the listing as the program to run in it. - new Step("an argv whose trailing semicolon ends a command", server -> { - server.cmd(List.of("new-window", "-d", "-n", "grouped;", "list-windows", "-F", "#{window_name}")); - Session session = server.sessions().get(0); - return session.refresh().windows().stream() - .map(Window::name) - .filter(name -> name.startsWith("grouped")) - .sorted() - .toList() - .toString(); - }), - // The other half of the same rule: a backslash before that semicolon keeps it, and the - // argument ends with a semicolon rather than the command ending there. - new Step("an argv whose trailing semicolon is escaped", server -> server.expand("escaped\\;"))); - - private static boolean named(Session session, String name) { - return session.refresh().windows().stream().anyMatch(window -> name.equals(window.name())); - } - - /** if-shell runs its command asynchronously, so the effect is waited for rather than assumed. */ - private static void sleepBriefly() { - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("interrupted while waiting for if-shell", e); - } - } - - /** - * Every scenario, in order, against one server per carrier, compared step by step. - * - *

One trajectory per carrier rather than a fresh server for every scenario: it starts one - * tmux per mode instead of one per scenario per mode, and it compares a longer history rather - * than a series of first moves, which is the harder thing to keep identical. - */ - @Test - void everyCarrierAnswersTheSame(@TempDir Path directory) throws Exception { - Map> byMode = new LinkedHashMap<>(); - for (ExecutionMode mode : ExecutionMode.values()) { - byMode.put(mode, trajectory(directory, mode)); - } - - // DIRECT is the baseline by name rather than by position: it is the default, and a reader - // comparing against "whichever came first" would be reading the enum's declaration order. - List expected = Objects.requireNonNull(byMode.get(ExecutionMode.DIRECT), "no trajectory for DIRECT"); - byMode.forEach((mode, answered) -> { - for (int step = 0; step < SCENARIOS.size(); step++) { - assertEquals( - expected.get(step), - answered.get(step), - SCENARIOS.get(step).name() + " differed between DIRECT and " + mode); - } - }); - } - - /** - * A command tmux answers by running another one must not cost the carrier its place. - * - *

tmux reports the work it does itself in a control-mode reply stream. A carrier that took - * those blocks for answers would read every later reply one out of step — an empty listing - * returned as though it were a full one, which surfaces as a capture whose windows belong to a - * session that listing never mentioned. Silent, and nothing about it looks like a transport - * fault, so it is asserted directly rather than left to a scenario that happens to notice. - */ - @Test - void aCommandThatMakesTmuxRunAnotherLeavesTheCarrierInPlace(@TempDir Path directory) throws Exception { - Path home = directory.resolve("deferred"); - Files.createDirectories(home); - Path config = home.resolve("empty.conf"); - Files.writeString(config, ""); - ServerConfig built = ServerConfig.builder() - .binary(System.getProperty("libtmux.tmux", "tmux")) - .endpoint(ServerEndpoint.socketPath(home.resolve("s"))) - .configFile(config) - .mode(ExecutionMode.CONTROL) - .build(); - - try (Server server = Server.open(built)) { - try { - server.newSession("deferred"); - server.ifShell("true", "rename-window then-ran"); - - // Reading repeatedly rather than once: a carrier one reply out of step answers the - // first read with the block before it, and only a later read runs out of stale ones. - for (int read = 0; read < 20; read++) { - assertEquals(1, server.sessions().size(), "read " + read + " saw a different server"); - } - } finally { - server.killServer(); - } - } - } - - /** Runs every scenario in order against one server, and answers with what each one said. */ - private static List trajectory(Path root, ExecutionMode mode) throws IOException { - Path home = root.resolve(mode.name().toLowerCase(Locale.ROOT)); - Files.createDirectories(home); - Path config = home.resolve("empty.conf"); - Files.writeString(config, ""); - ServerConfig built = ServerConfig.builder() - .binary(System.getProperty("libtmux.tmux", "tmux")) - .endpoint(ServerEndpoint.socketPath(home.resolve("s"))) - .configFile(config) - .mode(mode) - .build(); - - List answers = new ArrayList<>(); - try (Server server = Server.open(built)) { - try { - server.newSession("conformance"); - for (Step scenario : SCENARIOS) { - answers.add(scenario.work().apply(server)); - } - } finally { - server.killServer(); - } - } - return answers; - } - - // ---------------------------------------------------------------------------- precedence - - /** - * Highest first: the call, then the config. - * - *

Asserted by what each level answers rather than by reading a field back, because a carrier - * that reported its own choice without using it would pass a weaker test. - * - *

The levels below a config — the property, the variable, and the default — are settled - * before a server exists and are asserted in {@code ServerConfigTest}, which can hold the - * ambient ones still while it looks. - */ - @Test - void aCallOverridesTheServerWhichOverridesTheDefault(@TempDir Path directory) throws Exception { - Path home = directory.resolve("precedence"); - Files.createDirectories(home); - Path config = home.resolve("empty.conf"); - Files.writeString(config, ""); - ServerConfig control = ServerConfig.builder() - .binary(System.getProperty("libtmux.tmux", "tmux")) - .endpoint(ServerEndpoint.socketPath(home.resolve("s"))) - .configFile(config) - .mode(ExecutionMode.CONTROL) - .build(); - - assertEquals(ExecutionMode.CONTROL, control.mode(), "the config overrides the default"); - - try (Server server = Server.open(control)) { - server.newSession("precedence"); - - // Whichever carrier answers, the answer is the same; the override changes only how. - String byConfig = server.cmd(List.of("display-message", "-p", "#{session_name}"), TIMEOUT) - .stdout() - .get(0); - String byCall = server.cmd( - List.of("display-message", "-p", "#{session_name}"), TIMEOUT, ExecutionMode.DIRECT) - .stdout() - .get(0); - - assertEquals(byConfig, byCall, "an override must not change what a command answers"); - assertEquals("precedence", byCall); - server.killServer(); - } - } - - /** A carrier made for an override belongs to the server that made it. */ - @Test - void anOverrideCarrierIsClosedWithTheServer(@TempDir Path directory) throws Exception { - Path home = directory.resolve("owning"); - Files.createDirectories(home); - Path config = home.resolve("empty.conf"); - Files.writeString(config, ""); - ServerConfig built = ServerConfig.builder() - .binary(System.getProperty("libtmux.tmux", "tmux")) - .endpoint(ServerEndpoint.socketPath(home.resolve("s"))) - .configFile(config) - .mode(ExecutionMode.CONTROL) - .build(); - - Server server = Server.open(built); - server.newSession("owning"); - server.cmd(List.of("display-message", "-p", "ok"), TIMEOUT, ExecutionMode.DIRECT); - server.killServer(); - server.close(); - - assertThrows( - IllegalStateException.class, - () -> server.cmd(List.of("display-message", "-p", "ok"), TIMEOUT, ExecutionMode.DIRECT), - "a closed server must not carry anything, by any mode"); - } -} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java index f658f1a..1beffbf 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java @@ -130,6 +130,27 @@ void anOperationOnSomethingAlreadyGoneRaises(Server server) { "tmux reports the missing target, and a silent no-op would hide it"); } + @Test + void aHandleCannotMutateAReplacementServerThatReusedItsId(Server server) { + Session stale = session(server); + server.killServer(); + + try (Server replacement = Server.open(server.config())) { + try { + Session current = replacement.newSession("replacement"); + assertEquals(stale.id(), current.id(), "the replacement did not reuse the id this test exercises"); + assertNotEquals( + stale, current, "equal numeric ids from different server processes are not one session"); + + assertThrows(ObjectDoesNotExist.class, () -> stale.rename("corrupted")); + + assertEquals("replacement", replacement.sessions().get(0).name()); + } finally { + replacement.killServer(); + } + } + } + private static boolean awaitOutput(Pane pane, String expected) { for (int attempt = 0; attempt < 100; attempt++) { if (pane.capture().stream().anyMatch(line -> line.contains(expected))) { diff --git a/libtmux/README.md b/libtmux/README.md index a41e5c1..43e2fbe 100644 --- a/libtmux/README.md +++ b/libtmux/README.md @@ -138,31 +138,6 @@ silent `first()`. Full guide: **[Filtering](../docs/guide/filtering.md)**. -### Three switches when it is too slow - -| to stop | write | which costs | -| --- | --- | --- | -| a process per command | `.mode(ExecutionMode.CONTROL)` | one tmux client, then reused | -| blocking the thread you were handed | `.mode(ExecutionMode.VIRTUAL)` | a virtual thread per command | -| round-tripping to learn what you just made | `server.chain()` | one request, however many steps | - -```java -ServerConfig fast = ServerConfig.builder() - .endpoint(ServerEndpoint.socketPath(socket)) - .mode(ExecutionMode.CONTROL) - .build(); -``` - -Or from outside the program entirely, so trying one costs nothing: - -```console -$ LIBTMUX_MODE=control java -jar app.jar -``` - -**A carrier changes cost, never answers.** The same filter answers identically -under each, and [`ExecutionModeConformanceTest`](../integration-tests/src/test/java/io/github/libtmux/it/ExecutionModeConformanceTest.java) -is where that promise is kept. Measured prices: **[the benchmark](../docs/benchmarks/modes.md)**. - ### Several commands, one invocation ```java @@ -214,7 +189,7 @@ try { - **`io.github.libtmux.snapshot`** — the immutable capture a traversal reads from - **`io.github.libtmux.transport`** — how a command travels: `TmuxTransport`, `CommandResult`, and dispatch certainty -- **`io.github.libtmux.control`** — `ControlClient`, for control mode and `%output` +- **`io.github.libtmux.control`** — `ControlClient`, for subscriptions and `%output` - **`io.github.libtmux.batch`** — `Batch`, `BatchResult`, per-operation outcomes - **`io.github.libtmux.format`** — tmux format templates and row parsing @@ -222,7 +197,7 @@ try { - [Getting started](../docs/guide/getting-started.md) · [Snapshots and handles](../docs/guide/snapshots-and-handles.md) - [Filtering](../docs/guide/filtering.md) · [Batching and chaining](../docs/guide/batching-and-chaining.md) -- [Execution modes](../docs/guide/execution-modes.md) · [Streaming](../docs/guide/streaming.md) +- [Streaming](../docs/guide/streaming.md) - [Options and hooks](../docs/guide/options-and-hooks.md) - Runnable programs: [`examples/`](../examples/) - Testing your own code against real tmux: [`libtmux-junit5`](../libtmux-junit5/) diff --git a/libtmux/src/main/java/io/github/libtmux/Client.java b/libtmux/src/main/java/io/github/libtmux/Client.java index e69e1e1..9abb3f8 100644 --- a/libtmux/src/main/java/io/github/libtmux/Client.java +++ b/libtmux/src/main/java/io/github/libtmux/Client.java @@ -35,12 +35,12 @@ public String name() { *

Detaching is not killing: the session outlives the client, which is the reason tmux exists. */ public void detach() { - server.run(List.of("detach-client", "-t", state.name())); + server.run(snapshot, List.of("detach-client", "-t", state.name())); } /** Detaches every other client, leaving this one attached. */ public void detachOthers() { - server.run(List.of("detach-client", "-a", "-t", state.name())); + server.run(snapshot, List.of("detach-client", "-a", "-t", state.name())); } /** @@ -51,7 +51,9 @@ public void detachOthers() { */ public void switchTo(Session session) { Objects.requireNonNull(session, "session"); + server.requireSameIncarnation(snapshot, session.server(), session.snapshot()); server.run( + snapshot, List.of("switch-client", "-c", state.name(), "-t", session.id().value())); } @@ -62,7 +64,7 @@ public void switchTo(Session session) { * {@code refresh-client}, and it changes the terminal rather than this handle. */ public void redraw() { - server.run(List.of("refresh-client", "-t", state.name())); + server.run(snapshot, List.of("refresh-client", "-t", state.name())); } /** The server this client is connected to. */ @@ -70,6 +72,10 @@ public Server server() { return server; } + ServerSnapshot snapshot() { + return snapshot; + } + /** The session this client was attached to when captured. A pure read of the capture. */ public Optional session() { return state.session().flatMap(snapshot::session).map(session -> new Session(server, snapshot, session)); @@ -99,7 +105,7 @@ public Optional fetchAttachment() { /** Takes a new capture and returns this client as it is now, or empty if it has gone. */ public Optional refresh() { - ServerSnapshot fresh = server.snapshot(); + ServerSnapshot fresh = server.refresh(snapshot); return fresh.clients().stream() .filter(client -> client.name().equals(state.name())) .findFirst() @@ -109,13 +115,13 @@ public Optional refresh() { @Override public boolean equals(Object other) { return other instanceof Client that - && server.identity().equals(that.server.identity()) + && server.identity(snapshot).equals(that.server.identity(that.snapshot)) && state.name().equals(that.state.name()); } @Override public int hashCode() { - return Objects.hash(server.identity(), state.name()); + return Objects.hash(server.identity(snapshot), state.name()); } @Override diff --git a/libtmux/src/main/java/io/github/libtmux/ExecutionMode.java b/libtmux/src/main/java/io/github/libtmux/ExecutionMode.java deleted file mode 100644 index c047af8..0000000 --- a/libtmux/src/main/java/io/github/libtmux/ExecutionMode.java +++ /dev/null @@ -1,119 +0,0 @@ -package io.github.libtmux; - -import java.util.Arrays; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Properties; -import org.jspecify.annotations.Nullable; - -/** - * How commands travel to tmux. - * - *

Chosen once on a {@link ServerConfig} and applied to everything that server does. It changes - * only the carrying: the same handles answer the same questions with the same types, whichever mode - * is in force. {@code ExecutionModeConformanceTest} runs identical scenarios through each and - * asserts identical outcomes. - * - *

{@code
- * ServerConfig config = ServerConfig.builder()
- *         .endpoint(ServerEndpoint.socketPath(socket))
- *         .mode(ExecutionMode.CONTROL)
- *         .build();
- * }
- * - *

Batching and chaining are deliberately absent. They are often described - * alongside these, but they are not ways of carrying a command: {@link Server#batch()} groups - * commands into one request and {@link Server#chain()} lets each step act on what the last one - * made, and both compose over whichever mode is in force rather than replacing it. - * - *

Neither could be a mode even if it were wanted. This is a blocking API: - * {@code server.windows()} has to return a list by the time it returns, so a carrier that held the - * command back to group it would have nothing to give. Putting them on this switch would also tell - * a reader they were alternatives to these, when in fact they run under any of them. - */ -public enum ExecutionMode { - - /** - * One tmux process per command. - * - *

What the tmux binary does when a shell runs it. No state is held between commands, so - * nothing can be stale and nothing needs a session to exist first. Costs a process each time. - */ - DIRECT, - - /** - * One persistent tmux client, in control mode, carrying every command. - * - *

Costs one process for the life of the server rather than one per command, and is the only - * mode that can also stream pane output as it happens. - * - *

tmux control mode attaches to a session, so this cannot carry the command that creates the - * first one. Until a session exists the carrier falls back to {@link #DIRECT}, and attaches as - * soon as there is something to attach to. A caller never has to know: the fallback is invisible - * apart from the process count. - */ - CONTROL, - - /** - * A process per command, waited for on a virtual thread rather than on the caller's own. - * - *

Nothing becomes asynchronous: the call still blocks until tmux answers. What changes is - * which thread is parked meanwhile. A caller running on a small pool of platform threads keeps - * those free while tmux is slow, for the price of one virtual thread per command. - * - *

A caller already on a virtual thread should not select this: every carrier here is safe to - * call from one, because the transports block on a lock rather than inside {@code synchronized}. - * This exists for the caller who cannot choose their own threads. - */ - VIRTUAL; - - /** The JVM flag naming a mode: {@code -Dlibtmux.mode=control}. */ - public static final String PROPERTY = "libtmux.mode"; - - /** The environment variable naming a mode: {@code LIBTMUX_MODE=control}. */ - public static final String VARIABLE = "LIBTMUX_MODE"; - - /** - * The mode a JVM flag or an environment variable asks for, if either does. - * - *

Lets an operator move a program onto another carrier without editing or rebuilding it, - * which is the only reason this exists: a mode changes cost and nothing else, so trying one is - * meant to be cheap. {@link #PROPERTY} wins over {@link #VARIABLE}, a flag passed to this JVM - * being more deliberate than an environment it merely inherited. - * - *

An unreadable value is refused rather than ignored. Falling back to a default would leave - * an operator believing a carrier was in force that never was, and the whole point of the - * variable is that the answer looks the same either way. - * - *

Reading is separated from the sources for the reason {@link TmuxEnvironment} gives: the - * precedence is then testable without a JVM flag or a process environment to arrange. - * - * @param properties typically {@code System.getProperties()} - * @param environment typically {@code System.getenv()} - * @return the mode asked for, or empty when neither source says anything - * @throws IllegalArgumentException if either source names something that is not a mode - */ - public static Optional of(Properties properties, Map environment) { - Objects.requireNonNull(properties, "properties"); - Objects.requireNonNull(environment, "environment"); - Optional fromProperty = read(PROPERTY, properties.getProperty(PROPERTY)); - return fromProperty.isPresent() ? fromProperty : read(VARIABLE, environment.get(VARIABLE)); - } - - /** One source's answer: absent, a mode, or a refusal naming what to fix. */ - private static Optional read(String source, @Nullable String value) { - // An unset variable is commonly spelled as an empty one, and means the same thing here. - if (value == null || value.isBlank()) { - return Optional.empty(); - } - String wanted = value.strip(); - for (ExecutionMode mode : values()) { - if (mode.name().equalsIgnoreCase(wanted)) { - return Optional.of(mode); - } - } - throw new IllegalArgumentException( - source + "=" + value + " is not a mode; expected one of " + Arrays.toString(values())); - } -} diff --git a/libtmux/src/main/java/io/github/libtmux/Hooks.java b/libtmux/src/main/java/io/github/libtmux/Hooks.java index 6557ce9..cbab8a5 100644 --- a/libtmux/src/main/java/io/github/libtmux/Hooks.java +++ b/libtmux/src/main/java/io/github/libtmux/Hooks.java @@ -1,10 +1,13 @@ package io.github.libtmux; +import io.github.libtmux.snapshot.ServerSnapshot; +import io.github.libtmux.transport.CommandResult; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.jspecify.annotations.Nullable; /** * The tmux hooks at one scope. @@ -23,27 +26,29 @@ public final class Hooks { private final Server server; + private final @Nullable ServerSnapshot snapshot; private final List scope; - private Hooks(Server server, List scope) { + private Hooks(Server server, @Nullable ServerSnapshot snapshot, List scope) { this.server = server; + this.snapshot = snapshot; this.scope = scope; } static Hooks global(Server server) { - return new Hooks(server, List.of("-g")); + return new Hooks(server, null, List.of("-g")); } - static Hooks session(Server server, SessionId session) { - return new Hooks(server, List.of("-t", session.value())); + static Hooks session(Server server, ServerSnapshot snapshot, SessionId session) { + return new Hooks(server, snapshot, List.of("-t", session.value())); } - static Hooks window(Server server, WindowId window) { - return new Hooks(server, List.of("-w", "-t", window.value())); + static Hooks window(Server server, ServerSnapshot snapshot, WindowId window) { + return new Hooks(server, snapshot, List.of("-w", "-t", window.value())); } - static Hooks pane(Server server, PaneId pane) { - return new Hooks(server, List.of("-p", "-t", pane.value())); + static Hooks pane(Server server, ServerSnapshot snapshot, PaneId pane) { + return new Hooks(server, snapshot, List.of("-p", "-t", pane.value())); } /** @@ -53,17 +58,17 @@ static Hooks pane(Server server, PaneId pane) { * command joins the first. */ public void set(String event, String command) { - server.run(argv("set-hook", List.of(event, command))); + run(argv("set-hook", List.of(event, command))); } /** Binds another command to an event, after whatever is already bound to it. */ public void append(String event, String command) { - server.run(argv("set-hook", List.of("-a", event, command))); + run(argv("set-hook", List.of("-a", event, command))); } /** Removes everything bound to an event at this scope. */ public void unset(String event) { - server.run(argv("set-hook", List.of("-u", event))); + run(argv("set-hook", List.of("-u", event))); } /** @@ -73,7 +78,7 @@ public void unset(String event) { * and binds nothing. */ public void run(String event) { - server.run(argv("set-hook", List.of("-R", event))); + run(argv("set-hook", List.of("-R", event))); } /** @@ -85,7 +90,7 @@ public void run(String event) { */ public Map> all() { Map> hooks = new LinkedHashMap<>(); - for (String line : server.run(argv("show-hooks", List.of())).stdout()) { + for (String line : run(argv("show-hooks", List.of())).stdout()) { int split = line.indexOf(' '); if (split <= 0) { continue; @@ -99,6 +104,10 @@ public Map> all() { return Collections.unmodifiableMap(hooks); } + private CommandResult run(List argv) { + return snapshot == null ? server.run(argv) : server.run(snapshot, argv); + } + private List argv(String command, List tail) { List argv = new ArrayList<>(1 + scope.size() + tail.size()); argv.add(command); diff --git a/libtmux/src/main/java/io/github/libtmux/Layout.java b/libtmux/src/main/java/io/github/libtmux/Layout.java index aab58b0..1f0ca24 100644 --- a/libtmux/src/main/java/io/github/libtmux/Layout.java +++ b/libtmux/src/main/java/io/github/libtmux/Layout.java @@ -48,6 +48,13 @@ TmuxVersion since() { }; } + /** Refuses this layout when the running tmux predates it. */ + public void requireSupported(TmuxVersion running) { + if (!running.atLeast(since())) { + throw new UnsupportedTmuxVersion("the " + this + " layout", since(), running); + } + } + @Override public String toString() { return name; diff --git a/libtmux/src/main/java/io/github/libtmux/Layouts.java b/libtmux/src/main/java/io/github/libtmux/Layouts.java index b36ddfb..5650a10 100644 --- a/libtmux/src/main/java/io/github/libtmux/Layouts.java +++ b/libtmux/src/main/java/io/github/libtmux/Layouts.java @@ -1,7 +1,8 @@ package io.github.libtmux; -import java.util.Set; -import java.util.regex.Pattern; +import java.util.Arrays; +import java.util.List; +import org.jspecify.annotations.Nullable; /** * Which layout names are safe to hand to tmux. @@ -12,15 +13,16 @@ * program never created. A workspace file is user-supplied text, so the name has to be checked * before it is dispatched rather than after tmux has had it. * - *

tmux accepts either one of its five named layouts or a serialized layout string, which begins - * with a four-digit checksum followed by a comma. + *

tmux accepts either one of its named layouts or a serialized layout carrying tmux's own + * checksum. The checksum is verified here; a four-hex-digit prefix alone does not make the rest + * safe for tmux to parse. */ public final class Layouts { - private static final Set NAMED = - Set.of("even-horizontal", "even-vertical", "main-horizontal", "main-vertical", "tiled"); - - private static final Pattern SERIALIZED = Pattern.compile("^[0-9a-f]{4},.+"); + private static final int MAX_SERIALIZED_LENGTH = 8_191; + private static final int MAX_DEPTH = 256; + private static final List NAMED = + Arrays.stream(Layout.values()).map(Layout::tmuxName).toList(); private Layouts() {} @@ -31,10 +33,162 @@ private Layouts() {} * is not a recoverable error */ public static String require(String layout) { - if (NAMED.contains(layout) || SERIALIZED.matcher(layout).matches()) { + if (NAMED.contains(layout) || isSerialized(layout)) { return layout; } throw new IllegalArgumentException( "not a tmux layout: '" + layout + "'; expected one of " + NAMED + " or a serialized layout"); } + + /** Requires an exact layout string rather than a built-in layout name. */ + static String requireSerialized(String layout) { + if (isSerialized(layout)) { + return layout; + } + throw new IllegalArgumentException("not a layout tmux wrote: " + layout); + } + + private static boolean isSerialized(String layout) { + if (layout.length() < 6 || layout.charAt(4) != ',') { + return false; + } + for (int index = 0; index < 4; index++) { + char digit = layout.charAt(index); + if (!((digit >= '0' && digit <= '9') || (digit >= 'a' && digit <= 'f') || (digit >= 'A' && digit <= 'F'))) { + return false; + } + } + int declared; + try { + declared = Integer.parseInt(layout.substring(0, 4), 16); + } catch (NumberFormatException notHex) { + return false; + } + int checksum = 0; + for (int index = 5; index < layout.length(); index++) { + checksum = ((checksum >> 1) + ((checksum & 1) << 15)) & 0xffff; + checksum = (checksum + layout.charAt(index)) & 0xffff; + } + String body = layout.substring(5); + return checksum == declared && body.length() <= MAX_SERIALIZED_LENGTH && new Serialized(body).valid(); + } + + /** The subset parsed by tmux's layout_construct, with layout_check's size invariants. */ + private static final class Serialized { + + private final String value; + private int at; + + Serialized(String value) { + this.value = value; + } + + boolean valid() { + return cell(0) != null && at == value.length(); + } + + private @Nullable Cell cell(int depth) { + if (depth > MAX_DEPTH) { + return null; + } + int width = number('x'); + int height = number(','); + int x = number(','); + int y = unsigned(); + if (width < 1 || height < 1 || x < 0 || y < 0) { + return null; + } + skipPaneId(); + if (at == value.length() || delimiter(value.charAt(at))) { + return new Cell(width, height); + } + + char open = value.charAt(at++); + char close; + if (open == '{') { + close = '}'; + } else if (open == '[') { + close = ']'; + } else { + return null; + } + + List children = new java.util.ArrayList<>(); + @Nullable Cell child = cell(depth + 1); + if (child == null) { + return null; + } + children.add(child); + while (at < value.length() && value.charAt(at) == ',') { + at++; + child = cell(depth + 1); + if (child == null) { + return null; + } + children.add(child); + } + if (at >= value.length() || value.charAt(at++) != close) { + return null; + } + return fits(open, width, height, children) ? new Cell(width, height) : null; + } + + private int number(char terminator) { + int number = unsigned(); + if (number < 0 || at >= value.length() || value.charAt(at++) != terminator) { + return -1; + } + return number; + } + + private int unsigned() { + int start = at; + long number = 0; + while (at < value.length() && value.charAt(at) >= '0' && value.charAt(at) <= '9') { + number = number * 10 + value.charAt(at++) - '0'; + if (number > Integer.MAX_VALUE) { + return -1; + } + } + return at == start ? -1 : (int) number; + } + + /** A comma followed by another width belongs to the parent, not to this cell's pane id. */ + private void skipPaneId() { + if (at >= value.length() || value.charAt(at) != ',') { + return; + } + int saved = at++; + int id = unsigned(); + if (id < 0 || (at < value.length() && value.charAt(at) == 'x')) { + at = saved; + } + } + + private static boolean delimiter(char character) { + return character == ',' || character == '}' || character == ']'; + } + + private static boolean fits(char open, int width, int height, List children) { + long total = children.size() - 1L; + if (open == '{') { + for (Cell child : children) { + if (child.height() != height) { + return false; + } + total += child.width(); + } + return total == width; + } + for (Cell child : children) { + if (child.width() != width) { + return false; + } + total += child.height(); + } + return total == height; + } + } + + private record Cell(int width, int height) {} } diff --git a/libtmux/src/main/java/io/github/libtmux/Options.java b/libtmux/src/main/java/io/github/libtmux/Options.java index bae2955..c70dc14 100644 --- a/libtmux/src/main/java/io/github/libtmux/Options.java +++ b/libtmux/src/main/java/io/github/libtmux/Options.java @@ -1,11 +1,14 @@ package io.github.libtmux; +import io.github.libtmux.snapshot.ServerSnapshot; +import io.github.libtmux.transport.CommandResult; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import org.jspecify.annotations.Nullable; /** * The tmux options at one scope. @@ -20,31 +23,33 @@ public final class Options { private final Server server; + private final @Nullable ServerSnapshot snapshot; private final List scope; - private Options(Server server, List scope) { + private Options(Server server, @Nullable ServerSnapshot snapshot, List scope) { this.server = server; + this.snapshot = snapshot; this.scope = scope; } static Options server(Server server) { - return new Options(server, List.of("-s")); + return new Options(server, null, List.of("-s")); } static Options global(Server server) { - return new Options(server, List.of("-g")); + return new Options(server, null, List.of("-g")); } - static Options session(Server server, SessionId session) { - return new Options(server, List.of("-t", session.value())); + static Options session(Server server, ServerSnapshot snapshot, SessionId session) { + return new Options(server, snapshot, List.of("-t", session.value())); } - static Options window(Server server, WindowId window) { - return new Options(server, List.of("-w", "-t", window.value())); + static Options window(Server server, ServerSnapshot snapshot, WindowId window) { + return new Options(server, snapshot, List.of("-w", "-t", window.value())); } - static Options pane(Server server, PaneId pane) { - return new Options(server, List.of("-p", "-t", pane.value())); + static Options pane(Server server, ServerSnapshot snapshot, PaneId pane) { + return new Options(server, snapshot, List.of("-p", "-t", pane.value())); } /** @@ -58,7 +63,7 @@ static Options pane(Server server, PaneId pane) { * genuinely set to the empty string comes back as an empty value, not as absent */ public Optional get(String name) { - var result = server.cmd(argv("show-options", List.of("-A", "-v", name))); + var result = cmd(argv("show-options", List.of("-A", "-v", name))); if (!result.succeeded()) { return Optional.empty(); } @@ -72,7 +77,7 @@ public Map all() { private Map read(List flags) { Map options = new LinkedHashMap<>(); - for (String line : server.run(argv("show-options", flags)).stdout()) { + for (String line : run(argv("show-options", flags)).stdout()) { int split = line.indexOf(' '); if (split < 0) { // A flag option prints its name alone when set and nothing when unset. @@ -107,7 +112,7 @@ public Map effective() { /** Sets one option at this scope. */ public void set(String name, String value) { - server.run(argv("set-option", List.of(name, value))); + run(argv("set-option", List.of(name, value))); } /** @@ -119,7 +124,7 @@ public void set(String name, String value) { * @return whether the value was taken, false when this scope already set the option */ public boolean setIfAbsent(String name, String value) { - return server.cmd(argv("set-option", List.of("-o", name, value))).succeeded(); + return cmd(argv("set-option", List.of("-o", name, value))).succeeded(); } /** @@ -129,7 +134,7 @@ public boolean setIfAbsent(String name, String value) { * what a caller building a value up piece by piece wants. */ public void append(String name, String suffix) { - server.run(argv("set-option", List.of("-a", name, suffix))); + run(argv("set-option", List.of("-a", name, suffix))); } /** @@ -139,12 +144,20 @@ public void append(String name, String suffix) { * expansion happens once, when this is called; the option does not stay live. */ public void setExpanded(String name, String format) { - server.run(argv("set-option", List.of("-F", name, format))); + run(argv("set-option", List.of("-F", name, format))); } /** Removes one option at this scope, so it falls back to whatever it inherits. */ public void unset(String name) { - server.run(argv("set-option", List.of("-u", name))); + run(argv("set-option", List.of("-u", name))); + } + + private CommandResult cmd(List argv) { + return snapshot == null ? server.cmd(argv) : server.cmd(snapshot, argv); + } + + private CommandResult run(List argv) { + return snapshot == null ? server.run(argv) : server.run(snapshot, argv); } private List argv(String command, List tail) { diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index c72d487..61ed5a6 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -100,7 +100,8 @@ public void resize(Direction direction, int cells) { if (cells < 1) { throw new IllegalArgumentException("cells is not positive: " + cells); } - server.run(List.of("resize-pane", "-t", state.id().value(), direction.flag(), Integer.toString(cells))); + server.run( + snapshot, List.of("resize-pane", "-t", state.id().value(), direction.flag(), Integer.toString(cells))); } /** Which sides of its window the pane touches. */ @@ -110,7 +111,7 @@ public PaneEdges edges() { /** Puts this pane into copy mode, where its scrollback can be navigated. */ public void copyMode() { - server.run(List.of("copy-mode", "-t", state.id().value())); + server.run(snapshot, List.of("copy-mode", "-t", state.id().value())); } /** @@ -128,17 +129,17 @@ public Optional mode() { /** Shows a clock in this pane. */ public void clockMode() { - server.run(List.of("clock-mode", "-t", state.id().value())); + server.run(snapshot, List.of("clock-mode", "-t", state.id().value())); } /** Puts this pane into the session and window browser. */ public void chooseTree() { - server.run(List.of("choose-tree", "-t", state.id().value())); + server.run(snapshot, List.of("choose-tree", "-t", state.id().value())); } /** Puts this pane into the option browser. */ public void customizeMode() { - server.run(List.of("customize-mode", "-t", state.id().value())); + server.run(snapshot, List.of("customize-mode", "-t", state.id().value())); } /** @@ -149,12 +150,12 @@ public void customizeMode() { * a caller tells the two apart. */ public void chooseBuffer() { - server.run(List.of("choose-buffer", "-t", state.id().value())); + server.run(snapshot, List.of("choose-buffer", "-t", state.id().value())); } /** Puts this pane into the client browser, or leaves it alone when no client is attached. */ public void chooseClient() { - server.run(List.of("choose-client", "-t", state.id().value())); + server.run(snapshot, List.of("choose-client", "-t", state.id().value())); } /** @@ -172,13 +173,13 @@ public void chooseClient() { */ public void findWindow(String match) { Objects.requireNonNull(match, "match"); - server.run(List.of("find-window", "-t", state.id().value(), match)); + server.run(snapshot, List.of("find-window", "-t", state.id().value(), match)); } /** Narrows the window browser by name alone. See {@link #findWindow} for what it does not do. */ public void findWindowByName(String match) { Objects.requireNonNull(match, "match"); - server.run(List.of("find-window", "-N", "-t", state.id().value(), match)); + server.run(snapshot, List.of("find-window", "-N", "-t", state.id().value(), match)); } /** @@ -188,7 +189,7 @@ public void findWindowByName(String match) { */ public void findWindowByContent(String match) { Objects.requireNonNull(match, "match"); - server.run(List.of("find-window", "-C", "-t", state.id().value(), match)); + server.run(snapshot, List.of("find-window", "-C", "-t", state.id().value(), match)); } /** @@ -199,12 +200,12 @@ public void findWindowByContent(String match) { * not the one exposed here. */ public void exitMode() { - server.run(List.of("copy-mode", "-q", "-t", state.id().value())); + server.run(snapshot, List.of("copy-mode", "-q", "-t", state.id().value())); } /** Makes this the active pane of its window. */ public void select() { - server.run(List.of("select-pane", "-t", state.id().value())); + server.run(snapshot, List.of("select-pane", "-t", state.id().value())); } /** @@ -215,20 +216,22 @@ public void select() { */ public Pane retitle(String title) { Objects.requireNonNull(title, "title"); - server.run(List.of("select-pane", "-t", state.id().value(), "-T", title)); + server.run(snapshot, List.of("select-pane", "-t", state.id().value(), "-T", title)); return refresh(); } /** Resizes this pane. */ public void resizeTo(Dimensions size) { - server.run(List.of( - "resize-pane", - "-t", - state.id().value(), - "-x", - Integer.toString(size.width()), - "-y", - Integer.toString(size.height()))); + server.run( + snapshot, + List.of( + "resize-pane", + "-t", + state.id().value(), + "-x", + Integer.toString(size.width()), + "-y", + Integer.toString(size.height()))); } /** The server this pane lives on. */ @@ -236,6 +239,10 @@ public Server server() { return server; } + ServerSnapshot snapshot() { + return snapshot; + } + /** The window link this pane was reached through. A pure read of the capture. */ public Window window() { return snapshot.window(state.context()) @@ -245,12 +252,12 @@ public Window window() { /** This pane's own hooks. */ public Hooks hooks() { - return Hooks.pane(server, state.id()); + return Hooks.pane(server, snapshot, state.id()); } /** This pane's own options. */ public Options options() { - return Options.pane(server, state.id()); + return Options.pane(server, snapshot, state.id()); } /** This pane's visible content, one element per line. */ @@ -281,7 +288,8 @@ public List capture(Consumer configure) { * @throws UnsupportedTmuxVersion if the spec asks for something this server does not have */ public List capture(CaptureSpec spec) { - return server.run(spec.argv(state.id().value(), server.version())).stdout(); + return server.run(snapshot, spec.argv(state.id().value(), server.version())) + .stdout(); } /** @@ -290,12 +298,12 @@ public List capture(CaptureSpec spec) { *

Separate from {@link #sendLine} rather than a boolean, so a call site says which it means. */ public void send(String keys) { - server.run(List.of("send-keys", "-t", state.id().value(), keys)); + server.run(snapshot, List.of("send-keys", "-t", state.id().value(), keys)); } /** Sends a line to this pane and presses Enter, which is how a command gets run. */ public void sendLine(String command) { - server.run(List.of("send-keys", "-t", state.id().value(), command, "Enter")); + server.run(snapshot, List.of("send-keys", "-t", state.id().value(), command, "Enter")); } /** @@ -335,6 +343,7 @@ private String currentCommandNow() { public String expand(String format) { Objects.requireNonNull(format, "format"); List reported = server.run( + snapshot, List.of("display-message", "-p", "-t", state.id().value(), format)) .stdout(); return reported.isEmpty() ? "" : reported.get(0); @@ -348,7 +357,7 @@ public String expand(String format) { * of the call. */ public void respawn() { - server.run(List.of("respawn-pane", "-k", "-t", state.id().value())); + server.run(snapshot, List.of("respawn-pane", "-k", "-t", state.id().value())); } /** @@ -363,7 +372,7 @@ public void respawn(String... command) { List argv = new ArrayList<>(List.of("respawn-pane", "-k", "-t", state.id().value())); argv.addAll(List.of(command)); - server.run(argv); + server.run(snapshot, argv); } /** @@ -375,12 +384,12 @@ public void respawn(String... command) { */ public void pipeTo(String shellCommand) { Objects.requireNonNull(shellCommand, "shellCommand"); - server.run(List.of("pipe-pane", "-O", "-t", state.id().value(), shellCommand)); + server.run(snapshot, List.of("pipe-pane", "-O", "-t", state.id().value(), shellCommand)); } /** Stops sending this pane's output anywhere. Doing so twice is not an error. */ public void stopPiping() { - server.run(List.of("pipe-pane", "-t", state.id().value())); + server.run(snapshot, List.of("pipe-pane", "-t", state.id().value())); } /** Moves this pane into a window of its own with the given name. */ @@ -396,16 +405,17 @@ public Window breakOut(String windowName) { private Window breakNamed(Optional wanted, String supplied) { List argv = new ArrayList<>(List.of("break-pane", "-d", "-n", supplied)); argv.addAll(List.of("-s", state.id().value(), "-P", "-F", BROKEN_OUT.template())); - List fields = BROKEN_OUT.split(server.run(argv).stdout().get(0)); + List fields = + BROKEN_OUT.split(server.run(snapshot, argv).stdout().get(0)); WindowContext created = new WindowContext( new SessionId(fields.get(0)), new WindowIndex(Integer.parseInt(fields.get(2))), new WindowId(fields.get(1))); if (server.version().equals(BREAK_PANE_NAMING_BROKEN)) { // 3.7 took the name and ignored it, so the caller's choice is applied afterwards. - wanted.ifPresent(name -> server.run(List.of("rename-window", "-t", fields.get(1), name))); + wanted.ifPresent(name -> server.run(snapshot, List.of("rename-window", "-t", fields.get(1), name))); } - ServerSnapshot fresh = server.snapshot(); + ServerSnapshot fresh = server.refresh(snapshot); return fresh.window(created) .map(window -> new Window(server, fresh, window)) .orElseThrow(() -> new ObjectDoesNotExist("the window just broken out is already gone")); @@ -445,7 +455,7 @@ public Pane split(Consumer configure) { * @throws UnsupportedTmuxVersion if the spec asks for something this server does not have */ public Pane split(SplitSpec spec) { - return created(server, spec.argv(state.id().value(), CREATED.template(), server.version())); + return created(server, snapshot, spec.argv(state.id().value(), CREATED.template(), server.version())); } /** @@ -455,13 +465,13 @@ public Pane split(SplitSpec spec) { * pane a fresh listing happens to put last — two splits racing would otherwise be * indistinguishable. */ - static Pane created(Server server, List argv) { - List reported = server.run(argv).stdout(); + static Pane created(Server server, ServerSnapshot previous, List argv) { + List reported = server.run(previous, argv).stdout(); if (reported.isEmpty()) { throw new LibTmuxException("tmux created a pane without reporting which"); } PaneId id = new PaneId(CREATED.split(reported.get(0)).get(0)); - ServerSnapshot fresh = server.snapshot(); + ServerSnapshot fresh = server.refresh(previous); return fresh.panes().stream() .filter(pane -> pane.id().equals(id)) .findFirst() @@ -476,29 +486,37 @@ static String createdFormat() { /** Pastes a named buffer into this pane, as though it had been typed. */ public void paste(String bufferName) { - server.run(List.of("paste-buffer", "-b", bufferName, "-t", state.id().value())); + server.run( + snapshot, + List.of("paste-buffer", "-b", bufferName, "-t", state.id().value())); } /** Discards this pane's scrollback. */ public void clearHistory() { - server.run(List.of("clear-history", "-t", state.id().value())); + server.run(snapshot, List.of("clear-history", "-t", state.id().value())); } /** Swaps this pane's position with another's. */ public void swapWith(Pane other) { + Objects.requireNonNull(other, "other"); + server.requireSameIncarnation(snapshot, other.server(), other.snapshot()); server.run( + snapshot, List.of("swap-pane", "-s", state.id().value(), "-t", other.id().value())); } /** Moves this pane into another window, splitting it. */ public void joinTo(Window window) { + Objects.requireNonNull(window, "window"); + server.requireSameIncarnation(snapshot, window.server(), window.snapshot()); server.run( + snapshot, List.of("join-pane", "-s", state.id().value(), "-t", window.id().value())); } /** Closes this pane. */ public void kill() { - server.run(List.of("kill-pane", "-t", state.id().value())); + server.run(snapshot, List.of("kill-pane", "-t", state.id().value())); } /** @@ -507,7 +525,7 @@ public void kill() { * @throws ObjectDoesNotExist if the pane is gone */ public Pane refresh() { - ServerSnapshot fresh = server.snapshot(); + ServerSnapshot fresh = server.refresh(snapshot); return fresh.panes().stream() .filter(pane -> pane.id().equals(state.id())) .findFirst() @@ -518,13 +536,13 @@ public Pane refresh() { @Override public boolean equals(Object other) { return other instanceof Pane that - && server.identity().equals(that.server.identity()) + && server.identity(snapshot).equals(that.server.identity(that.snapshot)) && state.id().equals(that.state.id()); } @Override public int hashCode() { - return Objects.hash(server.identity(), state.id()); + return Objects.hash(server.identity(snapshot), state.id()); } @Override diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 975c852..f008913 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -2,6 +2,7 @@ import io.github.libtmux.batch.Batch; import io.github.libtmux.format.RowFormat; +import io.github.libtmux.internal.CommandStrings; import io.github.libtmux.snapshot.ClientState; import io.github.libtmux.snapshot.PaneState; import io.github.libtmux.snapshot.ServerSnapshot; @@ -10,20 +11,16 @@ import io.github.libtmux.snapshot.WindowState; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; -import io.github.libtmux.transport.ControlTransport; import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; -import io.github.libtmux.transport.VirtualThreadTransport; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import org.jspecify.annotations.Nullable; @@ -85,6 +82,7 @@ private static String[] withFloating() { } private static final RowFormat CLIENTS = RowFormat.of("client_name", "session_id"); + private static final RowFormat PROCESS = RowFormat.of("pid", "version"); /** Long enough for a pending signal to come straight back, short enough not to be a wait. */ private static final Duration DRAIN_TIMEOUT = Duration.ofMillis(250); @@ -94,11 +92,7 @@ private static String[] withFloating() { private final boolean owned; private final AtomicBoolean closed = new AtomicBoolean(); - /** Carriers made for a per-call override. Owned here, since here is what made them. */ - private final Map overrides = new ConcurrentHashMap<>(); - private final ServerIdentity identity; - private volatile @Nullable TmuxVersion version; private Server(ServerConfig config, TmuxTransport transport, boolean owned) { this.config = config; @@ -407,7 +401,7 @@ public List listKeys() { public WakeReason waitFor(String channel, Duration timeout) { try { cmd(List.of("wait-for", channel), timeout); - } catch (io.github.libtmux.transport.TmuxTransportException e) { + } catch (io.github.libtmux.transport.TmuxTimeoutException e) { // The transport killed the waiting client at the deadline; nothing signalled it. return isAlive() ? WakeReason.TIMED_OUT : WakeReason.SERVER_GONE; } @@ -464,14 +458,9 @@ public Hooks hooks() { * started by a different build than the one this client is invoking. */ public TmuxVersion version() { - TmuxVersion known = version; - if (known == null) { - // One tmux build serves a server for its whole life, so this is asked once. - known = TmuxVersion.parse( - run(List.of("display-message", "-p", "#{version}")).stdout().get(0)); - version = known; - } - return known; + return process() + .map(ServerProcess::version) + .orElseThrow(() -> new LibTmuxException("no tmux server is answering on this endpoint")); } /** Which server this is. Every handle taken from it is scoped by this. */ @@ -479,24 +468,30 @@ public ServerIdentity identity() { return identity; } + void requireSameServer(Server other) { + Objects.requireNonNull(other, "other"); + if (!identity.equals(other.identity)) { + throw new IllegalArgumentException("handles belong to different tmux servers"); + } + } + + void requireSameIncarnation(ServerSnapshot snapshot, Server other, ServerSnapshot otherSnapshot) { + requireSameServer(other); + if (!identity(snapshot).equals(other.identity(otherSnapshot))) { + throw new IllegalArgumentException("handles belong to different tmux server incarnations"); + } + } + + ServerIdentity identity(ServerSnapshot snapshot) { + return snapshot.serverPid().isPresent() + ? identity.at(snapshot.serverPid().orElseThrow()) + : identity; + } + /** A server over a transport it owns and closes. */ public static Server open(ServerConfig config) { Objects.requireNonNull(config, "config"); - return new Server(config, carrierFor(config), true); - } - - /** - * Builds the carrier the config asked for. - * - *

A mode is a transport choice and nothing more, which is why this is the only place the - * enum is consulted. See {@code docs/spikes/19} for why nothing above here has to care. - */ - private static TmuxTransport carrierFor(ServerConfig config) { - return switch (config.mode()) { - case DIRECT -> new ProcessTransport(); - case CONTROL -> new ControlTransport(config, new ProcessTransport()); - case VIRTUAL -> new VirtualThreadTransport(new ProcessTransport()); - }; + return new Server(config, new ProcessTransport(), true); } /** A server over a transport the caller owns. Closing this server never closes it. */ @@ -531,50 +526,6 @@ public CommandResult cmd(List argv) { return cmd(argv, config.defaultTimeout()); } - /** - * Runs one tmux command against this server, carried the way this call asks rather than the way - * the config asks. - * - *

Precedence, highest first: - * - *

    - *
  1. this argument - *
  2. {@link ServerConfig.Builder#mode} - *
  3. {@link ExecutionMode#DIRECT} - *
- * - *

Rarely worth reaching for. Nothing a handle returns depends on which carrier answered — see - * {@code docs/spikes/19} — so this changes cost and nothing else, and the one case where the - * carrier affects correctness routes itself. It exists for the caller who has measured a reason. - * - *

A carrier created for an override belongs to this server and is closed with it. - */ - public CommandResult cmd(List argv, Duration timeout, ExecutionMode mode) { - Objects.requireNonNull(mode, "mode"); - if (closed.get()) { - throw new IllegalStateException("server is closed"); - } - return carrierFor(mode).execute(new CommandRequest(config.endpointCommand(), argv, timeout)); - } - - /** - * The carrier for one mode, made once and kept. - * - *

The configured mode reuses the transport this server was built with, owned or borrowed as - * it always was. Any other mode gets a carrier of its own, which this server owns however the - * first one was obtained: it made it, so it closes it. - */ - private TmuxTransport carrierFor(ExecutionMode mode) { - if (mode == config.mode()) { - return transport; - } - return overrides.computeIfAbsent(mode, wanted -> switch (wanted) { - case DIRECT -> new ProcessTransport(); - case CONTROL -> new ControlTransport(config, new ProcessTransport()); - case VIRTUAL -> new VirtualThreadTransport(new ProcessTransport()); - }); - } - /** Runs one tmux command against this server, overriding the configured deadline. */ public CommandResult cmd(List argv, Duration timeout) { if (closed.get()) { @@ -595,26 +546,44 @@ public CommandResult cmd(List argv, Duration timeout) { *

Strict, unlike the lenient list accessors: a capture that failed raises instead of * returning an apparently valid empty graph, because a caller cannot tell those apart. * - * @throws LibTmuxException if any listing failed + * @throws LibTmuxException if a listing fails or the listings cannot form one valid snapshot */ public ServerSnapshot snapshot() { + try { + return hydrateSnapshot(); + } catch (LibTmuxException e) { + throw e; + } catch (RuntimeException e) { + throw new LibTmuxException("could not hydrate tmux snapshot: " + e.getMessage(), e); + } + } + + private ServerSnapshot hydrateSnapshot() { + Optional observed = process(); + if (observed.isEmpty()) { + return ServerSnapshot.of(Instant.now(), List.of(), List.of(), List.of(), List.of()); + } + ServerProcess process = observed.orElseThrow(); List sessions = new ArrayList<>(); for (List row : rows(SESSIONS, "list-sessions")) { sessions.add(new SessionState( - new SessionId(row.get(0)), row.get(1), "1".equals(row.get(2)), Integer.parseInt(row.get(3)))); + new SessionId(row.get(0)), + row.get(1), + positiveCount(row.get(2), "session_attached"), + Integer.parseInt(row.get(3)))); } List windows = new ArrayList<>(); for (List row : rows(WINDOWS, "list-windows", "-a")) { windows.add(new WindowState( context(row.get(0), row.get(2), row.get(1)), row.get(3), - "1".equals(row.get(4)), + bit(row.get(4), "window_active"), Integer.parseInt(row.get(5)), - "1".equals(row.get(6)), + bit(row.get(6), "window_linked"), new Dimensions(Integer.parseInt(row.get(7)), Integer.parseInt(row.get(8))), row.get(9))); } - boolean floatingKnown = version().atLeast(FLOATING_SINCE); + boolean floatingKnown = process.version().atLeast(FLOATING_SINCE); RowFormat paneFormat = floatingKnown ? PANES_WITH_FLOATING : PANES; List panes = new ArrayList<>(); for (List row : rows(paneFormat, "list-panes", "-a")) { @@ -622,25 +591,59 @@ public ServerSnapshot snapshot() { context(row.get(0), row.get(2), row.get(1)), new PaneId(row.get(3)), Integer.parseInt(row.get(4)), - "1".equals(row.get(5)), + bit(row.get(5), "pane_active"), row.get(6), new Dimensions(Integer.parseInt(row.get(7)), Integer.parseInt(row.get(8))), row.get(9), Path.of(row.get(10)), Long.parseLong(row.get(11)), new PaneEdges( - "1".equals(row.get(12)), - "1".equals(row.get(13)), - "1".equals(row.get(14)), - "1".equals(row.get(15))), - floatingKnown ? Optional.of("1".equals(row.get(16))) : Optional.empty())); + bit(row.get(12), "pane_at_top"), + bit(row.get(13), "pane_at_bottom"), + bit(row.get(14), "pane_at_left"), + bit(row.get(15), "pane_at_right")), + floatingKnown ? Optional.of(bit(row.get(16), "pane_floating_flag")) : Optional.empty())); } List clients = new ArrayList<>(); for (List row : rows(CLIENTS, "list-clients")) { clients.add(new ClientState( row.get(0), row.get(1).isEmpty() ? Optional.empty() : Optional.of(new SessionId(row.get(1))))); } - return ServerSnapshot.of(Instant.now(), sessions, windows, panes, clients); + return ServerSnapshot.of(Instant.now(), process.pid(), process.version(), sessions, windows, panes, clients); + } + + /** Reads process identity and version together so neither can come from a different server. */ + private Optional process() { + CommandResult result = cmd("display-message", "-p", PROCESS.template()); + if (!result.succeeded()) { + return Optional.empty(); + } + if (result.stdout().size() != 1) { + throw new LibTmuxException("tmux did not report exactly one server identity row"); + } + List fields = PROCESS.split(result.stdout().get(0)); + String pid = fields.get(0); + if (pid.isEmpty() || !pid.chars().allMatch(character -> character >= '0' && character <= '9')) { + throw new LibTmuxException("tmux reported a malformed server pid: " + pid); + } + return Optional.of(new ServerProcess(Long.parseLong(pid), TmuxVersion.parse(fields.get(1)))); + } + + private record ServerProcess(long pid, TmuxVersion version) {} + + private static boolean bit(String value, String field) { + return switch (value) { + case "0" -> false; + case "1" -> true; + default -> throw new IllegalArgumentException(field + " was neither 0 nor 1: " + value); + }; + } + + private static boolean positiveCount(String value, String field) { + if (value.isEmpty() || !value.chars().allMatch(character -> character >= '0' && character <= '9')) { + throw new IllegalArgumentException(field + " was not a non-negative count: " + value); + } + return Long.parseLong(value) > 0; } /** @@ -703,6 +706,34 @@ public CommandResult run(List argv) { return result; } + CommandResult cmd(ServerSnapshot snapshot, List argv) { + long pid = snapshot.serverPid() + .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); + String stale = "libtmux-stale-handle-" + pid; + CommandResult result = + cmd(List.of("if-shell", "-F", "#{==:#{pid}," + pid + "}", CommandStrings.stringify(argv), stale)); + if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { + throw new ObjectDoesNotExist("the tmux server this handle belonged to has ended"); + } + return result; + } + + CommandResult run(ServerSnapshot snapshot, List argv) { + CommandResult result = cmd(snapshot, argv); + if (!result.succeeded()) { + throw new LibTmuxException("tmux " + argv.get(0) + " failed: " + String.join("; ", result.stderr())); + } + return result; + } + + ServerSnapshot refresh(ServerSnapshot previous) { + ServerSnapshot fresh = snapshot(); + if (!identity(previous).equals(identity(fresh))) { + throw new ObjectDoesNotExist("the tmux server this handle belonged to has ended"); + } + return fresh; + } + private ServerSnapshot lenient() { try { return snapshot(); @@ -749,10 +780,6 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } - // A carrier made for an override is this server's however the first one was obtained, so it - // is closed either way. Borrowing a transport says nothing about the ones made afterwards. - overrides.values().forEach(TmuxTransport::close); - overrides.clear(); if (owned) { transport.close(); } diff --git a/libtmux/src/main/java/io/github/libtmux/ServerConfig.java b/libtmux/src/main/java/io/github/libtmux/ServerConfig.java index 1487e23..bc95838 100644 --- a/libtmux/src/main/java/io/github/libtmux/ServerConfig.java +++ b/libtmux/src/main/java/io/github/libtmux/ServerConfig.java @@ -27,14 +27,12 @@ public final class ServerConfig { private final ServerEndpoint endpoint; private final @Nullable Path configFile; private final Duration defaultTimeout; - private final ExecutionMode mode; - private ServerConfig(Builder builder, ExecutionMode mode) { + private ServerConfig(Builder builder) { this.binary = builder.binary; this.endpoint = builder.endpoint; this.configFile = builder.configFile; this.defaultTimeout = builder.defaultTimeout; - this.mode = mode; } /** A builder holding the documented defaults. */ @@ -62,18 +60,6 @@ public Duration defaultTimeout() { return defaultTimeout; } - /** - * How commands reach tmux. Changes the carrying, never the meaning. - * - *

Decided rather than merely requested: a config that named no mode reports the one - * {@code -Dlibtmux.mode} or {@code LIBTMUX_MODE} chose for it, so this is what - * {@link Server#open} will build. A server handed a transport by {@link Server#using} is - * carried by that transport whatever this says. - */ - public ExecutionMode mode() { - return mode; - } - /** * The argv prefix every command on this server begins with: the binary, the server selection, * and the config file if one was pinned. @@ -96,7 +82,6 @@ public Builder toBuilder() { builder.endpoint = endpoint; builder.configFile = configFile; builder.defaultTimeout = defaultTimeout; - builder.mode = mode; return builder; } @@ -107,9 +92,6 @@ public static final class Builder { private ServerEndpoint endpoint = ServerEndpoint.defaultSocket(); private @Nullable Path configFile; private Duration defaultTimeout = DEFAULT_TIMEOUT; - // Null until something names a mode, which is what lets an unset one fall to the ambient - // choice: a default of DIRECT here could not be told apart from a caller asking for DIRECT. - private @Nullable ExecutionMode mode; private Builder() {} @@ -131,17 +113,6 @@ public Builder configFile(Path configFile) { return this; } - /** - * Chooses how commands reach tmux, and settles it: an ambient choice cannot override this. - * - *

Left unsaid, the mode comes from {@link ExecutionMode#of} and falls back to - * {@link ExecutionMode#DIRECT}, which is what the tmux binary itself does. - */ - public Builder mode(ExecutionMode mode) { - this.mode = Objects.requireNonNull(mode, "mode"); - return this; - } - /** Sets the deadline a request gets when the caller does not supply one. */ public Builder defaultTimeout(Duration defaultTimeout) { this.defaultTimeout = Objects.requireNonNull(defaultTimeout, "defaultTimeout"); @@ -151,11 +122,7 @@ public Builder defaultTimeout(Duration defaultTimeout) { /** * Builds an immutable config, rejecting choices that could only fail later. * - *

Reads {@code -Dlibtmux.mode} and {@code LIBTMUX_MODE} when nothing named a mode, so - * the config carries a decided one from here on and nothing downstream consults them again. - * - * @throws IllegalArgumentException if a choice would only fail later, including a property - * or variable naming something that is not a mode + * @throws IllegalArgumentException if a choice would only fail later */ public ServerConfig build() { if (binary.isEmpty()) { @@ -164,10 +131,7 @@ public ServerConfig build() { if (defaultTimeout.isZero() || defaultTimeout.isNegative()) { throw new IllegalArgumentException("defaultTimeout is not positive"); } - ExecutionMode chosen = mode != null - ? mode - : ExecutionMode.of(System.getProperties(), System.getenv()).orElse(ExecutionMode.DIRECT); - return new ServerConfig(this, chosen); + return new ServerConfig(this); } } } diff --git a/libtmux/src/main/java/io/github/libtmux/ServerIdentity.java b/libtmux/src/main/java/io/github/libtmux/ServerIdentity.java index 5ea3216..2600ec2 100644 --- a/libtmux/src/main/java/io/github/libtmux/ServerIdentity.java +++ b/libtmux/src/main/java/io/github/libtmux/ServerIdentity.java @@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.Objects; +import java.util.OptionalLong; /** * Which tmux server a handle belongs to. @@ -21,14 +22,23 @@ public final class ServerIdentity { private final String realm; private final String server; + private final long processId; - private ServerIdentity(String realm, String server) { + private ServerIdentity(String realm, String server, long processId) { this.realm = realm; this.server = server; + this.processId = processId; } static ServerIdentity of(String realm, ServerEndpoint endpoint) { - return new ServerIdentity(Objects.requireNonNull(realm, "realm"), digest(endpoint)); + return new ServerIdentity(Objects.requireNonNull(realm, "realm"), digest(endpoint), 0); + } + + ServerIdentity at(long pid) { + if (pid < 1) { + throw new IllegalArgumentException("pid is not positive: " + pid); + } + return new ServerIdentity(realm, server, pid); } /** The execution realm the transport reaches tmux through. */ @@ -41,19 +51,27 @@ public String server() { return server; } + /** The live tmux process, present on an identity bound to a captured handle. */ + public OptionalLong processId() { + return processId == 0 ? OptionalLong.empty() : OptionalLong.of(processId); + } + @Override public boolean equals(Object other) { - return other instanceof ServerIdentity that && realm.equals(that.realm) && server.equals(that.server); + return other instanceof ServerIdentity that + && realm.equals(that.realm) + && server.equals(that.server) + && processId == that.processId; } @Override public int hashCode() { - return Objects.hash(realm, server); + return Objects.hash(realm, server, processId); } @Override public String toString() { - return "ServerIdentity[" + realm + ":" + server + "]"; + return "ServerIdentity[" + realm + ":" + server + (processId == 0 ? "" : "@" + processId) + "]"; } private static String digest(ServerEndpoint endpoint) { diff --git a/libtmux/src/main/java/io/github/libtmux/Session.java b/libtmux/src/main/java/io/github/libtmux/Session.java index 5769b5f..aa90e5b 100644 --- a/libtmux/src/main/java/io/github/libtmux/Session.java +++ b/libtmux/src/main/java/io/github/libtmux/Session.java @@ -53,6 +53,10 @@ public Server server() { return server; } + ServerSnapshot snapshot() { + return snapshot; + } + /** The window tmux had active in this session. A pure read of the capture. */ public Optional activeWindow() { return windows().stream().filter(Window::active).findFirst(); @@ -65,17 +69,27 @@ public Optional activePane() { /** Makes a window of this session the active one. */ public void selectWindow(Window window) { - server.run(List.of("select-window", "-t", window.id().value())); + Objects.requireNonNull(window, "window"); + server.requireSameIncarnation(snapshot, window.server(), window.snapshot()); + if (!state.id().equals(window.context().session())) { + throw new IllegalArgumentException("window does not belong to session " + state.id()); + } + server.run( + snapshot, + List.of( + "select-window", + "-t", + state.id().value() + ":" + window.index().value())); } /** Moves to the next window in this session, wrapping at the end. */ public void nextWindow() { - server.run(List.of("next-window", "-t", state.id().value())); + server.run(snapshot, List.of("next-window", "-t", state.id().value())); } /** Moves to the previous window in this session, wrapping at the start. */ public void previousWindow() { - server.run(List.of("previous-window", "-t", state.id().value())); + server.run(snapshot, List.of("previous-window", "-t", state.id().value())); } /** @@ -85,22 +99,22 @@ public void previousWindow() { * silently staying put */ public void lastWindow() { - server.run(List.of("last-window", "-t", state.id().value())); + server.run(snapshot, List.of("last-window", "-t", state.id().value())); } /** Detaches every client attached to this session, leaving the session running. */ public void detachClients() { - server.run(List.of("detach-client", "-s", state.id().value())); + server.run(snapshot, List.of("detach-client", "-s", state.id().value())); } /** This session's own options. */ public Options options() { - return Options.session(server, state.id()); + return Options.session(server, snapshot, state.id()); } /** This session's own hooks. */ public Hooks hooks() { - return Hooks.session(server, state.id()); + return Hooks.session(server, snapshot, state.id()); } /** This session's windows, in tmux's order. A pure read of the capture. */ @@ -147,9 +161,10 @@ public Window newWindow(Consumer configure) { * @throws UnsupportedTmuxVersion if the spec asks for something this server does not have */ public Window newWindow(WindowSpec spec) { - List reported = server.run(spec.argv(state.id().value(), CREATED.template(), server.version())) + List reported = server.run( + snapshot, spec.argv(state.id().value(), CREATED.template(), server.version())) .stdout(); - ServerSnapshot fresh = server.snapshot(); + ServerSnapshot fresh = server.refresh(snapshot); if (reported.isEmpty()) { // Only reuseExisting gets here: tmux selects the window it already had and reports // nothing, so the answer has to come from a lookup. See docs/spikes/14. @@ -181,6 +196,7 @@ public Window newWindow(WindowSpec spec) { public String expand(String format) { Objects.requireNonNull(format, "format"); List reported = server.run( + snapshot, List.of("display-message", "-p", "-t", state.id().value(), format)) .stdout(); return reported.isEmpty() ? "" : reported.get(0); @@ -188,13 +204,13 @@ public String expand(String format) { /** Renames this session and returns a handle on it as it is now. */ public Session rename(String name) { - server.run(List.of("rename-session", "-t", state.id().value(), name)); + server.run(snapshot, List.of("rename-session", "-t", state.id().value(), name)); return refresh(); } /** Ends this session. Every window in it goes with it. */ public void kill() { - server.run(List.of("kill-session", "-t", state.id().value())); + server.run(snapshot, List.of("kill-session", "-t", state.id().value())); } /** @@ -203,7 +219,7 @@ public void kill() { * @throws ObjectDoesNotExist if the session is gone */ public Session refresh() { - ServerSnapshot fresh = server.snapshot(); + ServerSnapshot fresh = server.refresh(snapshot); return fresh.session(state.id()) .map(session -> new Session(server, fresh, session)) .orElseThrow(() -> new ObjectDoesNotExist("session " + state.id() + " no longer exists")); @@ -212,13 +228,13 @@ public Session refresh() { @Override public boolean equals(Object other) { return other instanceof Session that - && server.identity().equals(that.server.identity()) + && server.identity(snapshot).equals(that.server.identity(that.snapshot)) && state.id().equals(that.state.id()); } @Override public int hashCode() { - return Objects.hash(server.identity(), state.id()); + return Objects.hash(server.identity(snapshot), state.id()); } @Override diff --git a/libtmux/src/main/java/io/github/libtmux/TargetIds.java b/libtmux/src/main/java/io/github/libtmux/TargetIds.java index 0270372..1eb6fcb 100644 --- a/libtmux/src/main/java/io/github/libtmux/TargetIds.java +++ b/libtmux/src/main/java/io/github/libtmux/TargetIds.java @@ -15,7 +15,12 @@ private TargetIds() {} */ static void require(String value, char sigil, String kind) { Objects.requireNonNull(value, "value"); - if (value.length() < 2 || value.charAt(0) != sigil) { + boolean valid = value.length() >= 2 && value.charAt(0) == sigil; + for (int index = 1; valid && index < value.length(); index++) { + char digit = value.charAt(index); + valid = digit >= '0' && digit <= '9'; + } + if (!valid) { throw new IllegalArgumentException( "not a " + kind + " id, expected " + sigil + " followed by digits: " + value); } diff --git a/libtmux/src/main/java/io/github/libtmux/TmuxEnvironment.java b/libtmux/src/main/java/io/github/libtmux/TmuxEnvironment.java index c4433cb..a35b4c0 100644 --- a/libtmux/src/main/java/io/github/libtmux/TmuxEnvironment.java +++ b/libtmux/src/main/java/io/github/libtmux/TmuxEnvironment.java @@ -72,22 +72,25 @@ public static Optional of(Map environment) { if (lastComma < 0 || firstOfPair < 0) { return Optional.empty(); } - long pid; - try { - pid = Long.parseLong(tmux.substring(firstOfPair + 1, lastComma)); - } catch (NumberFormatException e) { + String socketField = tmux.substring(0, firstOfPair); + String sessionField = tmux.substring(lastComma + 1); + if (socketField.isEmpty() || sessionField.isEmpty()) { return Optional.empty(); } - String sessionField = tmux.substring(lastComma + 1); - if (sessionField.isEmpty()) { + try { + long pid = Long.parseLong(tmux.substring(firstOfPair + 1, lastComma)); + if (pid <= 0) { + return Optional.empty(); + } + // tmux writes the session number bare, while every id elsewhere carries its sigil. + // Without this the id would never equal one read back from a listing. + SessionId session = new SessionId(sessionField.startsWith("$") ? sessionField : "$" + sessionField); + String paneField = environment.get("TMUX_PANE"); + PaneId pane = paneField == null || paneField.isEmpty() ? null : new PaneId(paneField); + return Optional.of(new TmuxEnvironment(Path.of(socketField), pid, session, pane)); + } catch (IllegalArgumentException e) { return Optional.empty(); } - // tmux writes the session number bare, while every id elsewhere carries its sigil. Without - // this the id would never equal one read back from a listing. - SessionId session = new SessionId(sessionField.startsWith("$") ? sessionField : "$" + sessionField); - String paneField = environment.get("TMUX_PANE"); - PaneId pane = paneField == null || paneField.isEmpty() ? null : new PaneId(paneField); - return Optional.of(new TmuxEnvironment(Path.of(tmux.substring(0, firstOfPair)), pid, session, pane)); } /** The socket the server is listening on. */ diff --git a/libtmux/src/main/java/io/github/libtmux/Window.java b/libtmux/src/main/java/io/github/libtmux/Window.java index 30722cd..d65dd26 100644 --- a/libtmux/src/main/java/io/github/libtmux/Window.java +++ b/libtmux/src/main/java/io/github/libtmux/Window.java @@ -80,7 +80,7 @@ public Optional activePane() { /** Makes this the active window of its session. */ public void select() { - server.run(List.of("select-window", "-t", target())); + server.run(snapshot, List.of("select-window", "-t", linkTarget())); } /** The server this window lives on. */ @@ -88,6 +88,10 @@ public Server server() { return server; } + ServerSnapshot snapshot() { + return snapshot; + } + /** The session this link belongs to. A pure read of the capture. */ public Session session() { return snapshot.session(state.context().session()) @@ -97,12 +101,12 @@ public Session session() { /** This window's own hooks, which every link to it shares. */ public Hooks hooks() { - return Hooks.window(server, id()); + return Hooks.window(server, snapshot, id()); } /** This window's own options, which every link to it shares. */ public Options options() { - return Options.window(server, id()); + return Options.window(server, snapshot, id()); } /** This link's panes, in tmux's order. A pure read of the capture. */ @@ -141,7 +145,7 @@ public Pane split(Consumer configure) { * @throws UnsupportedTmuxVersion if the spec asks for something this server does not have */ public Pane split(SplitSpec spec) { - return Pane.created(server, spec.argv(target(), Pane.createdFormat(), server.version())); + return Pane.created(server, snapshot, spec.argv(target(), Pane.createdFormat(), server.version())); } /** @@ -154,7 +158,7 @@ public Pane split(SplitSpec spec) { */ public String expand(String format) { Objects.requireNonNull(format, "format"); - List reported = server.run(List.of("display-message", "-p", "-t", target(), format)) + List reported = server.run(snapshot, List.of("display-message", "-p", "-t", linkTarget(), format)) .stdout(); return reported.isEmpty() ? "" : reported.get(0); } @@ -167,13 +171,17 @@ public String expand(String format) { * both. Unlike a session name, a window name is never rewritten. */ public Window rename(String name) { - server.run(List.of("rename-window", "-t", target(), name)); + server.run(snapshot, List.of("rename-window", "-t", target(), name)); return refresh(); } /** Links this window into another session, so one window sits in both. */ public void linkTo(Session session) { - server.run(List.of("link-window", "-s", target(), "-t", session.id().value())); + Objects.requireNonNull(session, "session"); + server.requireSameIncarnation(snapshot, session.server(), session.snapshot()); + server.run( + snapshot, + List.of("link-window", "-s", target(), "-t", session.id().value())); } /** @@ -182,17 +190,21 @@ public void linkTo(Session session) { * @throws LibTmuxException if this is the window's only link, which tmux refuses to remove */ public void unlink() { - server.run(List.of("unlink-window", "-t", target())); + server.run(snapshot, List.of("unlink-window", "-t", linkTarget())); } /** Moves this window into another session. */ public void moveTo(Session session) { - server.run(List.of("move-window", "-s", target(), "-t", session.id().value())); + Objects.requireNonNull(session, "session"); + server.requireSameIncarnation(snapshot, session.server(), session.snapshot()); + server.run( + snapshot, + List.of("move-window", "-s", linkTarget(), "-t", session.id().value())); } /** Rotates the panes within this window. */ public void rotate() { - server.run(List.of("rotate-window", "-t", target())); + server.run(snapshot, List.of("rotate-window", "-t", target())); } /** @@ -202,16 +214,13 @@ public void rotate() { */ public void selectLayout(Layout layout) { Objects.requireNonNull(layout, "layout"); - TmuxVersion running = server.version(); - if (!running.atLeast(layout.since())) { - throw new UnsupportedTmuxVersion("the " + layout + " layout", layout.since(), running); - } - server.run(List.of("select-layout", "-t", target(), layout.tmuxName())); + layout.requireSupported(server.version()); + server.run(snapshot, List.of("select-layout", "-t", target(), layout.tmuxName())); } /** Moves to the next built-in layout, as tmux's own binding does. */ public void nextLayout() { - server.run(List.of("next-layout", "-t", target())); + server.run(snapshot, List.of("next-layout", "-t", target())); } /** @@ -226,40 +235,12 @@ public void nextLayout() { */ public void applyLayout(String layout) { Objects.requireNonNull(layout, "layout"); - if (!isTmuxLayout(layout)) { - throw new IllegalArgumentException("not a layout tmux wrote: " + layout); - } - server.run(List.of("select-layout", "-t", target(), layout)); - } - - /** - * Whether a string carries the checksum tmux puts on a layout it wrote. - * - *

tmux prefixes the arrangement with four hex digits and a comma, summing the rest with a - * rotate-and-add over 16 bits. Recomputing it is the whole check: a string that passes is one - * tmux produced, and 3.3a is safe to hand it to. - */ - private static boolean isTmuxLayout(String layout) { - if (layout.length() < 6 || layout.charAt(4) != ',') { - return false; - } - int declared; - try { - declared = Integer.parseInt(layout.substring(0, 4), 16); - } catch (NumberFormatException notHex) { - return false; - } - int checksum = 0; - for (int i = 5; i < layout.length(); i++) { - checksum = ((checksum >> 1) + ((checksum & 1) << 15)) & 0xffff; - checksum = (checksum + layout.charAt(i)) & 0xffff; - } - return checksum == declared; + server.run(snapshot, List.of("select-layout", "-t", target(), Layouts.requireSerialized(layout))); } /** Kills what is running in this window and starts it again. */ public void respawn() { - server.run(List.of("respawn-window", "-k", "-t", target())); + server.run(snapshot, List.of("respawn-window", "-k", "-t", target())); } /** @@ -269,12 +250,12 @@ public void respawn() { * reports that it has no current client. */ public void displayPopup(String shellCommand) { - server.run(List.of("display-popup", "-E", "-t", target(), shellCommand)); + server.run(snapshot, List.of("display-popup", "-E", "-t", target(), shellCommand)); } /** Closes this window. */ public void kill() { - server.run(List.of("kill-window", "-t", target())); + server.run(snapshot, List.of("kill-window", "-t", target())); } /** @@ -283,7 +264,7 @@ public void kill() { * @throws ObjectDoesNotExist if this window is no longer linked here */ public Window refresh() { - ServerSnapshot fresh = server.snapshot(); + ServerSnapshot fresh = server.refresh(snapshot); return fresh.window(state.context()) .map(window -> new Window(server, fresh, window)) .orElseThrow(() -> new ObjectDoesNotExist("window " + id() + " no longer exists here")); @@ -294,16 +275,21 @@ private String target() { return state.context().window().value(); } + /** Addresses this exact link, even when its underlying window appears twice in one session. */ + private String linkTarget() { + return state.context().session().value() + ":" + state.context().index().value(); + } + @Override public boolean equals(Object other) { return other instanceof Window that - && server.identity().equals(that.server.identity()) + && server.identity(snapshot).equals(that.server.identity(that.snapshot)) && state.context().equals(that.state.context()); } @Override public int hashCode() { - return Objects.hash(server.identity(), state.context()); + return Objects.hash(server.identity(snapshot), state.context()); } @Override diff --git a/libtmux/src/main/java/io/github/libtmux/snapshot/ServerSnapshot.java b/libtmux/src/main/java/io/github/libtmux/snapshot/ServerSnapshot.java index 4f3d134..be98420 100644 --- a/libtmux/src/main/java/io/github/libtmux/snapshot/ServerSnapshot.java +++ b/libtmux/src/main/java/io/github/libtmux/snapshot/ServerSnapshot.java @@ -1,6 +1,10 @@ package io.github.libtmux.snapshot; +import io.github.libtmux.PaneId; import io.github.libtmux.SessionId; +import io.github.libtmux.TmuxVersion; +import io.github.libtmux.WindowId; +import io.github.libtmux.WindowIndex; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; @@ -9,6 +13,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.OptionalLong; import java.util.function.Function; /** @@ -22,6 +27,8 @@ public final class ServerSnapshot { private final Instant capturedAt; + private final OptionalLong serverPid; + private final Optional serverVersion; private final List sessions; private final List windows; private final List panes; @@ -29,34 +36,44 @@ public final class ServerSnapshot { private final Map sessionsById; private final Map> windowsBySession; + private final Map windowsByContext; private final Map> panesByContext; private ServerSnapshot( Instant capturedAt, + OptionalLong serverPid, + Optional serverVersion, List sessions, List windows, List panes, List clients) { this.capturedAt = capturedAt; + this.serverPid = serverPid; + this.serverVersion = serverVersion; this.sessions = sessions; this.windows = windows; this.panes = panes; this.clients = clients; - Map byId = new LinkedHashMap<>(); - for (SessionState session : sessions) { - byId.put(session.id(), session); - } - this.sessionsById = Collections.unmodifiableMap(byId); + this.sessionsById = unique(sessions, SessionState::id, "session"); this.windowsBySession = group(windows, window -> window.context().session()); + this.windowsByContext = unique(windows, WindowState::context, "window context"); + unique( + windows, + window -> new WindowSlot( + window.context().session(), window.context().index()), + "window slot"); this.panesByContext = group(panes, PaneState::context); + unique(panes, pane -> new PaneKey(pane.context(), pane.id()), "pane context"); + unique(panes, pane -> new PaneSlot(pane.context(), pane.index()), "pane slot"); + requirePaneOwnership(panes); } /** * Assembles a capture, checking that the listings agree with each other. * - * @throws IllegalArgumentException if a pane was reported under a window the capture never saw, - * which means the listings were taken across a change and do not describe one hierarchy + * @throws IllegalArgumentException if the listings contain duplicate identities or disagree on + * the captured hierarchy */ public static ServerSnapshot of( Instant capturedAt, @@ -64,9 +81,62 @@ public static ServerSnapshot of( List windows, List panes, List clients) { + return of(capturedAt, OptionalLong.empty(), Optional.empty(), sessions, windows, panes, clients); + } + + /** Assembles a capture tied to the live tmux process that produced it. */ + public static ServerSnapshot of( + Instant capturedAt, + long serverPid, + List sessions, + List windows, + List panes, + List clients) { + if (serverPid < 1) { + throw new IllegalArgumentException("serverPid is not positive: " + serverPid); + } + return of(capturedAt, OptionalLong.of(serverPid), Optional.empty(), sessions, windows, panes, clients); + } + + /** Assembles a capture tied to the live tmux process and version that produced it. */ + public static ServerSnapshot of( + Instant capturedAt, + long serverPid, + TmuxVersion serverVersion, + List sessions, + List windows, + List panes, + List clients) { + if (serverPid < 1) { + throw new IllegalArgumentException("serverPid is not positive: " + serverPid); + } + return of( + capturedAt, + OptionalLong.of(serverPid), + Optional.of(Objects.requireNonNull(serverVersion, "serverVersion")), + sessions, + windows, + panes, + clients); + } + + private static ServerSnapshot of( + Instant capturedAt, + OptionalLong serverPid, + Optional serverVersion, + List sessions, + List windows, + List panes, + List clients) { Objects.requireNonNull(capturedAt, "capturedAt"); ServerSnapshot snapshot = new ServerSnapshot( - capturedAt, List.copyOf(sessions), List.copyOf(windows), List.copyOf(panes), List.copyOf(clients)); + capturedAt, + serverPid, + serverVersion, + List.copyOf(sessions), + List.copyOf(windows), + List.copyOf(panes), + List.copyOf(clients)); snapshot.requireClosed(); return snapshot; } @@ -79,8 +149,7 @@ public static ServerSnapshot of( */ private void requireClosed() { for (PaneState pane : panes) { - if (!panesByContext.containsKey(pane.context()) - || !windowsBySession.containsKey(pane.context().session())) { + if (!windowsByContext.containsKey(pane.context())) { throw new IllegalArgumentException("pane %s was captured under window %s, which no listing saw" .formatted(pane.id(), pane.context().window())); } @@ -90,6 +159,28 @@ private void requireClosed() { throw new IllegalArgumentException("window %s was captured under session %s, which no listing saw" .formatted(window.context().window(), window.context().session())); } + int capturedPanes = + panesByContext.getOrDefault(window.context(), List.of()).size(); + if (window.panes() != capturedPanes) { + throw new IllegalArgumentException("window %s reported %d panes but the listing captured %d" + .formatted(window.context().window(), window.panes(), capturedPanes)); + } + } + for (SessionState session : sessions) { + int capturedWindows = + windowsBySession.getOrDefault(session.id(), List.of()).size(); + if (session.windows() != capturedWindows) { + throw new IllegalArgumentException("session %s reported %d windows but the listing captured %d" + .formatted(session.id(), session.windows(), capturedWindows)); + } + } + for (ClientState client : clients) { + client.session().ifPresent(session -> { + if (!sessionsById.containsKey(session)) { + throw new IllegalArgumentException("client %s was captured under session %s, which no listing saw" + .formatted(client.name(), session)); + } + }); } } @@ -98,6 +189,16 @@ public Instant capturedAt() { return capturedAt; } + /** The tmux process that produced this capture, absent when assembled from detached state. */ + public OptionalLong serverPid() { + return serverPid; + } + + /** The tmux version that produced this capture, absent when assembled from detached state. */ + public Optional serverVersion() { + return serverVersion; + } + /** Every session, in tmux's order. */ public List sessions() { return sessions; @@ -130,9 +231,7 @@ public Optional session(String name) { /** The winlink at this exact position, if the capture saw it. */ public Optional window(WindowContext context) { - return windows.stream() - .filter(window -> window.context().equals(context)) - .findFirst(); + return Optional.ofNullable(windowsByContext.get(context)); } /** The winlinks in one session, in order. Empty when the capture never saw that session. */ @@ -147,8 +246,9 @@ public List panesOf(WindowContext context) { @Override public String toString() { - return "ServerSnapshot[capturedAt=" + capturedAt + ", sessions=" + sessions.size() + ", windows=" - + windows.size() + ", panes=" + panes.size() + ", clients=" + clients.size() + "]"; + return "ServerSnapshot[capturedAt=" + capturedAt + ", serverPid=" + serverPid + ", sessions=" + + sessions.size() + ", windows=" + windows.size() + ", panes=" + panes.size() + ", clients=" + + clients.size() + "]"; } private static Map> group(List values, Function key) { @@ -160,4 +260,33 @@ private static Map> group(List values, Function key) grouped.replaceAll((unused, group) -> Collections.unmodifiableList(group)); return Collections.unmodifiableMap(grouped); } + + private static Map unique(List values, Function key, String kind) { + Map indexed = new LinkedHashMap<>(); + for (V value : values) { + K identity = key.apply(value); + if (indexed.putIfAbsent(identity, value) != null) { + throw new IllegalArgumentException("duplicate " + kind + " '" + identity + "'"); + } + } + return Collections.unmodifiableMap(indexed); + } + + private static void requirePaneOwnership(List panes) { + Map windows = new LinkedHashMap<>(); + for (PaneState pane : panes) { + WindowId window = pane.context().window(); + WindowId previous = windows.putIfAbsent(pane.id(), window); + if (previous != null && !previous.equals(window)) { + throw new IllegalArgumentException( + "pane %s was captured under both window %s and %s".formatted(pane.id(), previous, window)); + } + } + } + + private record PaneKey(WindowContext context, PaneId id) {} + + private record PaneSlot(WindowContext context, int index) {} + + private record WindowSlot(SessionId session, WindowIndex index) {} } diff --git a/libtmux/src/main/java/io/github/libtmux/transport/ControlTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/ControlTransport.java deleted file mode 100644 index 5f6ec46..0000000 --- a/libtmux/src/main/java/io/github/libtmux/transport/ControlTransport.java +++ /dev/null @@ -1,232 +0,0 @@ -package io.github.libtmux.transport; - -import io.github.libtmux.ExecutionMode; -import io.github.libtmux.LibTmuxException; -import io.github.libtmux.ServerConfig; -import io.github.libtmux.SessionId; -import io.github.libtmux.batch.OperationOutcome; -import io.github.libtmux.control.ControlClient; -import io.github.libtmux.control.ControlReply; -import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.locks.ReentrantLock; -import org.jspecify.annotations.Nullable; - -/** - * Carries every command over one persistent tmux client, in control mode. - * - *

The {@link ExecutionMode#CONTROL} carrier. One process serves the life of the server rather - * than one per command. - * - *

Control mode attaches to a session, so there is nothing to attach to until a session exists. - * Until then this delegates to a process transport, and attaches on the first command issued once - * tmux has a session. The seam is invisible to a caller: both paths answer with a - * {@link CommandResult}, and the only observable difference is how many processes were started. - * - *

Blocking happens on a {@link ReentrantLock} rather than in a {@code synchronized} block, so a - * virtual thread waiting here releases its carrier. - */ -public final class ControlTransport implements TmuxTransport { - - private final ServerConfig config; - private final TmuxTransport bootstrap; - private final ReentrantLock attaching = new ReentrantLock(); - private final AtomicBoolean closed = new AtomicBoolean(); - private volatile @Nullable ControlClient client; - - /** - * @param config which server to reach, and how - * @param bootstrap carries commands until a session exists to attach to, and is closed with this - */ - public ControlTransport(ServerConfig config, TmuxTransport bootstrap) { - this.config = config; - this.bootstrap = bootstrap; - } - - @Override - public CommandResult execute(CommandRequest request) { - requireOpen(); - ControlClient attached = attachedClient(); - if (attached == null || carriedByProcess(request)) { - return bootstrap.execute(request); - } - try { - return asResult(attached.send(request.argv(), request.timeout())); - } catch (LibTmuxException failed) { - // A control client ends with the server it attached to, and a command sent afterwards - // fails on the write — before tmux saw a complete line, so nothing was applied. A - // carrier that has no client left is the state this transport already handles: drop it - // and answer through a process, which is what happens before one is ever attached. - // - // Only when the client has actually gone. The same exception covers failures a live - // client can raise, and re-running one of those over a process could apply it twice. - if (attached.isAlive()) { - throw failed; - } - discard(attached); - return bootstrap.execute(request); - } - } - - /** Forgets a client that has ended, so the next command attaches again or falls back. */ - private void discard(ControlClient dead) { - attaching.lock(); - try { - if (client == dead) { - client = null; - } - } finally { - attaching.unlock(); - } - dead.close(); - } - - /** - * Commands tmux answers by running or waiting for something else, and their aliases. - * - *

Each one makes tmux do work of its own — run a guarded command, read a file of them, run a - * hook's, block on a channel — and control mode reports that work in the same stream as the - * replies. Those extra blocks belong to no request this client sent. - */ - private static final Set DEFERRING = Set.of( - "if-shell", - "if", - "run-shell", - "run", - "source-file", - "source", - "wait-for", - "wait", - "confirm-before", - "confirm"); - - /** - * Commands that end the client carrying them. - * - *

A control client is a client of the server it is talking to, so a command that ends the - * server ends the connection the reply was going to arrive on. Sent down the client, it races - * its own effect and fails to write as often as it succeeds. - */ - private static final Set SELF_DESTROYING = Set.of("kill-server"); - - /** - * Whether this request must go over a process rather than the control client. - * - *

Control mode frames a reply per command, not per line: a line holding two commands comes - * back as two {@code %begin}/{@code %end} blocks. A client that expects one would take the first - * and leave the second to be misread as the answer to whatever it sends next — corruption rather - * than a failure, and silent. A capture is where it surfaces, as a window belonging to a session - * the sessions listing never mentioned, because the empty reply to something else was read as - * that listing. - * - *

So a request goes over a process whenever the stream cannot be trusted after it. Three - * kinds cannot. A group says so in its argv, by tmux's own rule rather than by a semicolon - * standing alone — see {@link ControlClient#isCommandGroup}. A command that makes tmux run or - * await something else does not say so, and is recognised by name — including {@code set-hook - * -R}, which runs a hook's commands rather than recording them. And a command that ends the - * client carrying it can only race its own effect. - * - *

A group is one invocation either way, and the rest are rare enough that a process costs - * nothing worth having. Nothing is lost but the illusion that control mode carried it. - */ - private static boolean carriedByProcess(CommandRequest request) { - List argv = request.argv(); - if (argv.isEmpty()) { - return false; - } - if (ControlClient.isCommandGroup(argv)) { - return true; - } - String name = argv.get(0); - return DEFERRING.contains(name) - || SELF_DESTROYING.contains(name) - || ("set-hook".equals(name) && argv.contains("-R")); - } - - /** - * The control client, attaching on first use, or null while there is no session to attach to. - * - *

Asked of the bootstrap transport rather than of a {@code Server}, because a transport is - * what a server is built on and cannot ask one back. - * - *

Closing is checked on both sides of the attach rather than only at the door. Finding a - * session takes a command of its own, so a close can land after this began and before a client - * exists — and a close cannot release a client that was not there to be seen. Whoever attached - * it is therefore the one that has to release it. - */ - private @Nullable ControlClient attachedClient() { - ControlClient existing = client; - if (existing != null) { - return existing; - } - attaching.lock(); - try { - if (client != null) { - return client; - } - requireOpen(); - SessionId session = firstSession(); - if (session == null) { - return null; - } - requireOpen(); - ControlClient attached = ControlClient.attach(config, session); - if (closed.get()) { - attached.close(); - throw new IllegalStateException("transport is closed"); - } - client = attached; - return attached; - } finally { - attaching.unlock(); - } - } - - private void requireOpen() { - if (closed.get()) { - throw new IllegalStateException("transport is closed"); - } - } - - private @Nullable SessionId firstSession() { - CommandResult listed = bootstrap.execute(new CommandRequest( - config.endpointCommand(), List.of("list-sessions", "-F", "#{session_id}"), config.defaultTimeout())); - if (!listed.succeeded() || listed.stdout().isEmpty()) { - return null; - } - return new SessionId(listed.stdout().get(0)); - } - - /** - * Puts a control reply into the shape every carrier answers with. - * - *

Control mode has no exit status and no separate error channel, so one is synthesised: a - * completed operation is zero, anything else is one, and its lines are reported as the error - * channel. Nothing in the entity layer reads either — see {@code docs/spikes/19} — so this is - * only visible to a caller who asked for process detail on purpose. - */ - private static CommandResult asResult(ControlReply reply) { - if (reply.outcome() == OperationOutcome.COMPLETE) { - return new CommandResult(0, reply.lines(), List.of()); - } - return new CommandResult(1, List.of(), reply.lines()); - } - - @Override - public String realm() { - return bootstrap.realm(); - } - - @Override - public void close() { - if (!closed.compareAndSet(false, true)) { - return; - } - ControlClient attached = client; - if (attached != null) { - attached.close(); - } - bootstrap.close(); - } -} diff --git a/libtmux/src/main/java/io/github/libtmux/transport/VirtualThreadTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/VirtualThreadTransport.java deleted file mode 100644 index b09831d..0000000 --- a/libtmux/src/main/java/io/github/libtmux/transport/VirtualThreadTransport.java +++ /dev/null @@ -1,93 +0,0 @@ -package io.github.libtmux.transport; - -import io.github.libtmux.ExecutionMode; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Runs each command on a virtual thread, and waits for it there. - * - *

The {@link ExecutionMode#VIRTUAL} carrier. It wraps another carrier rather than reaching tmux - * itself, so the command travels exactly as it would have; what changes is which thread is parked - * while tmux answers. - * - *

What this buys, and what it does not. The caller still blocks: the work is - * handed to a virtual thread and joined, so nothing becomes asynchronous. What moves is where the - * parking happens. A caller running on a small pool of platform threads — a servlet container, a - * fixed executor — keeps those threads free while tmux is slow, at the cost of one virtual thread - * per command, which is cheap. - * - *

A caller already on a virtual thread gains nothing from this and should not select it. The - * ordinary carriers are safe to call from one: they block on - * {@link java.util.concurrent.locks.ReentrantLock} rather than inside {@code synchronized}, so a - * blocked call releases its carrier. That is a property of the transports rather than a mode, and - * {@code CarrierStarvationTest} keeps it true. - */ -public final class VirtualThreadTransport implements TmuxTransport { - - private final TmuxTransport delegate; - private final AtomicBoolean closed = new AtomicBoolean(); - - /** - * @param delegate the carrier that actually reaches tmux, closed with this one - */ - public VirtualThreadTransport(TmuxTransport delegate) { - this.delegate = delegate; - } - - @Override - public CommandResult execute(CommandRequest request) { - if (closed.get()) { - throw new IllegalStateException("transport is closed"); - } - AtomicReference answered = new AtomicReference<>(); - // Every Throwable, not only the ones a caller was expecting. An Error left behind would kill - // the worker with nothing recorded, and the join would then return an answer of null. - AtomicReference failed = new AtomicReference<>(); - Thread worker = Thread.ofVirtual().name("libtmux-virtual").start(() -> { - try { - answered.set(delegate.execute(request)); - } catch (Throwable e) { - failed.set(e); - } - }); - try { - worker.join(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new TmuxTransportException("interrupted while waiting for tmux", DispatchOutcome.UNKNOWN, e); - } - // Rethrown as it was, so a caller sees the same failure and the same dispatch certainty it - // would have seen without this carrier in the way. execute declares no checked exception, so - // the first two arms are the whole of what a conforming delegate can throw. - Throwable thrown = failed.get(); - if (thrown instanceof RuntimeException runtime) { - throw runtime; - } - if (thrown instanceof Error error) { - throw error; - } - if (thrown != null) { - throw new TmuxTransportException("tmux could not be run to completion", DispatchOutcome.UNKNOWN, thrown); - } - CommandResult result = answered.get(); - if (result == null) { - // A delegate that answers null is not one this library wrote. Refused here rather than - // handed on, because a null crossing into annotated code fails somewhere far less clear. - throw new TmuxTransportException("the carrier beneath answered nothing", DispatchOutcome.UNKNOWN, null); - } - return result; - } - - @Override - public String realm() { - return delegate.realm(); - } - - @Override - public void close() { - if (closed.compareAndSet(false, true)) { - delegate.close(); - } - } -} diff --git a/libtmux/src/test/java/io/github/libtmux/ExecutionModeTest.java b/libtmux/src/test/java/io/github/libtmux/ExecutionModeTest.java deleted file mode 100644 index 51b3e4c..0000000 --- a/libtmux/src/test/java/io/github/libtmux/ExecutionModeTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package io.github.libtmux; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.Map; -import java.util.Optional; -import java.util.Properties; -import org.junit.jupiter.api.Test; - -/** Choosing a carrier from outside the program that uses one. */ -final class ExecutionModeTest { - - @Test - void aPropertyNamesTheCarrier() { - assertEquals(Optional.of(ExecutionMode.CONTROL), ExecutionMode.of(withMode("control"), Map.of())); - } - - @Test - void anEnvironmentVariableNamesTheCarrier() { - assertEquals( - Optional.of(ExecutionMode.VIRTUAL), - ExecutionMode.of(new Properties(), Map.of("LIBTMUX_MODE", "virtual"))); - } - - @Test - void aPropertyBeatsTheEnvironment() { - assertEquals( - Optional.of(ExecutionMode.DIRECT), - ExecutionMode.of(withMode("direct"), Map.of("LIBTMUX_MODE", "control")), - "a flag passed to this JVM is more specific than the environment it inherited"); - } - - @Test - void sayingNothingIsNotADecision() { - assertEquals(Optional.empty(), ExecutionMode.of(new Properties(), Map.of())); - } - - @Test - void anEmptyValueIsNotADecision() { - assertEquals( - Optional.empty(), - ExecutionMode.of(new Properties(), Map.of("LIBTMUX_MODE", " ")), - "an unset variable is commonly spelled as an empty one"); - } - - @Test - void anOperatorMayTypeItHowever() { - assertEquals(Optional.of(ExecutionMode.CONTROL), ExecutionMode.of(withMode(" Control "), Map.of())); - } - - @Test - void aMisspelledPropertyIsLoudRatherThanSilentlyIgnored() { - IllegalArgumentException thrown = - assertThrows(IllegalArgumentException.class, () -> ExecutionMode.of(withMode("contro"), Map.of())); - - String message = String.valueOf(thrown.getMessage()); - assertTrue(message.contains("libtmux.mode"), message); - assertTrue(message.contains("contro"), message); - assertTrue(message.contains("CONTROL"), message); - } - - @Test - void aMisspelledEnvironmentValueNamesTheVariableToFix() { - IllegalArgumentException thrown = assertThrows( - IllegalArgumentException.class, - () -> ExecutionMode.of(new Properties(), Map.of("LIBTMUX_MODE", "controll"))); - - String message = String.valueOf(thrown.getMessage()); - assertTrue(message.contains("LIBTMUX_MODE"), message); - } - - private static Properties withMode(String mode) { - Properties properties = new Properties(); - properties.setProperty("libtmux.mode", mode); - return properties; - } -} diff --git a/libtmux/src/test/java/io/github/libtmux/HandleTest.java b/libtmux/src/test/java/io/github/libtmux/HandleTest.java index bed723d..b3e6428 100644 --- a/libtmux/src/test/java/io/github/libtmux/HandleTest.java +++ b/libtmux/src/test/java/io/github/libtmux/HandleTest.java @@ -1,11 +1,14 @@ package io.github.libtmux; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.format.RowFormat; +import io.github.libtmux.internal.CommandStrings; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; import io.github.libtmux.transport.TmuxTransport; @@ -127,7 +130,7 @@ void aHandleReachesOnlyItsOwnChildren() { List.of(new PaneId("%1"), new PaneId("%2")), inAlpha.panes().stream().map(Pane::id).toList()); assertEquals( - List.of(new PaneId("%3")), + List.of(new PaneId("%1"), new PaneId("%2")), inBeta.panes().stream().map(Pane::id).toList()); } } @@ -149,6 +152,71 @@ void aHandleKnowsWhichServerItCameFrom() { } } + @Test + void operationsRejectHandlesFromAnotherServerBeforeDispatch() { + CountingTransport localTransport = new CountingTransport("alpha"); + try (Server local = Server.using(config(ServerEndpoint.namedSocket("fixture")), localTransport); + Server foreign = canned(ServerEndpoint.namedSocket("elsewhere"))) { + Session localSession = local.sessions().get(0); + Window localWindow = localSession.windows().get(0); + Pane localPane = localWindow.panes().get(0); + Client localClient = local.clients().get(0); + Session foreignSession = foreign.sessions().get(0); + Window foreignWindow = foreignSession.windows().get(0); + Pane foreignPane = foreignWindow.panes().get(0); + int captured = localTransport.calls.get(); + + assertAll( + () -> assertThrows(IllegalArgumentException.class, () -> localSession.selectWindow(foreignWindow)), + () -> assertThrows(IllegalArgumentException.class, () -> localWindow.linkTo(foreignSession)), + () -> assertThrows(IllegalArgumentException.class, () -> localWindow.moveTo(foreignSession)), + () -> assertThrows(IllegalArgumentException.class, () -> localPane.swapWith(foreignPane)), + () -> assertThrows(IllegalArgumentException.class, () -> localPane.joinTo(foreignWindow)), + () -> assertThrows(IllegalArgumentException.class, () -> localClient.switchTo(foreignSession))); + assertEquals(captured, localTransport.calls.get(), "a refused handle must never reach tmux"); + } + } + + @Test + void selectingAWindowIsScopedToTheReceivingSession() { + CountingTransport transport = new CountingTransport("alpha"); + try (Server server = Server.using(config(ServerEndpoint.namedSocket("fixture")), transport)) { + Session alpha = server.sessions().get(0); + Session beta = server.sessions().get(1); + Window linkedIntoAlpha = alpha.windows().get(0); + Window onlyInBeta = beta.windows().get(1); + + assertThrows(IllegalArgumentException.class, () -> alpha.selectWindow(onlyInBeta)); + + alpha.selectWindow(linkedIntoAlpha); + assertEquals(CommandStrings.stringify(List.of("select-window", "-t", "$0:0")), last(transport)); + } + } + + @Test + void linkSpecificOperationsKeepTheCapturedSessionAndIndex() { + CountingTransport transport = new CountingTransport("alpha"); + try (Server server = Server.using(config(ServerEndpoint.namedSocket("fixture")), transport)) { + Session alpha = server.sessions().get(0); + Session beta = server.sessions().get(1); + Window secondLink = beta.windows().get(0); + + secondLink.select(); + assertEquals(CommandStrings.stringify(List.of("select-window", "-t", "$1:3")), last(transport)); + + secondLink.expand("#{window_index}"); + assertEquals( + CommandStrings.stringify(List.of("display-message", "-p", "-t", "$1:3", "#{window_index}")), + last(transport)); + + secondLink.unlink(); + assertEquals(CommandStrings.stringify(List.of("unlink-window", "-t", "$1:3")), last(transport)); + + secondLink.moveTo(alpha); + assertEquals(CommandStrings.stringify(List.of("move-window", "-s", "$1:3", "-t", "$0")), last(transport)); + } + } + // ------------------------------------------------------------------------------- fixtures private static ServerConfig config(ServerEndpoint endpoint) { @@ -167,6 +235,10 @@ private static Server canned(ServerEndpoint endpoint) { return Server.using(config(endpoint), new CountingTransport("alpha")); } + private static String last(CountingTransport transport) { + return transport.requests.get(transport.requests.size() - 1).argv().get(3); + } + /** * Answers the four listings from fixed rows, and counts what it was asked. A window linked into * two sessions is the shape that matters, so it is what the rows describe. @@ -174,6 +246,7 @@ private static Server canned(ServerEndpoint endpoint) { private static final class CountingTransport implements TmuxTransport { private final AtomicInteger calls = new AtomicInteger(); + private final List requests = new ArrayList<>(); private final String firstSessionName; CountingTransport(String firstSessionName) { @@ -183,6 +256,7 @@ private static final class CountingTransport implements TmuxTransport { @Override public CommandResult execute(CommandRequest request) { calls.incrementAndGet(); + requests.add(request); String command = request.argv().get(0); return new CommandResult(0, rows(command), List.of()); } @@ -192,11 +266,12 @@ private List rows(String command) { switch (command) { case "list-sessions" -> { rows.add(row("$0", firstSessionName, "1", "1")); - rows.add(row("$1", "beta", "0", "1")); + rows.add(row("$1", "beta", "0", "2")); } case "list-windows" -> { rows.add(row("$0", "@7", "0", "editor", "1", "2", "1", "80", "24", "layout")); rows.add(row("$1", "@7", "3", "editor", "0", "2", "1", "80", "24", "layout")); + rows.add(row("$1", "@8", "4", "logs", "1", "1", "0", "80", "24", "layout")); } case "list-panes" -> { rows.add(row( @@ -205,13 +280,18 @@ private List rows(String command) { rows.add(row( "$0", "@7", "0", "%2", "1", "0", "zsh", "80", "24", "t", "/tmp", "12", "1", "1", "1", "1")); rows.add(row( - "$1", "@7", "3", "%3", "0", "1", "nvim", "80", "24", "t", "/tmp", "13", "1", "1", "1", + "$1", "@7", "3", "%1", "0", "1", "nvim", "80", "24", "t", "/tmp", "11", "1", "1", "1", + "1")); + rows.add(row( + "$1", "@7", "3", "%2", "1", "0", "zsh", "80", "24", "t", "/tmp", "12", "1", "1", "1", "1")); + rows.add(row( + "$1", "@8", "4", "%3", "0", "1", "tail", "80", "24", "t", "/tmp", "13", "1", "1", "1", "1")); } case "list-clients" -> rows.add(row("/dev/pts/3", "$0")); // Reported as 3.6 so the snapshot uses the format without pane_floating_flag, // which is what these fixed rows describe. - case "display-message" -> rows.add("3.6"); + case "display-message" -> rows.add(row("4242", "3.6")); default -> { // Any other command is an operation, not a listing. } diff --git a/libtmux/src/test/java/io/github/libtmux/ServerConfigTest.java b/libtmux/src/test/java/io/github/libtmux/ServerConfigTest.java index cd8a2c8..ec2048e 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerConfigTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerConfigTest.java @@ -3,13 +3,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.nio.file.Path; import java.time.Duration; import java.util.Optional; -import java.util.function.Supplier; -import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; /** What a server needs to know before it runs anything. */ @@ -93,44 +90,6 @@ void aConfigFileIsOmittedRatherThanGuessedAt() { assertEquals("[tmux, -L, fixture]", config.endpointCommand().toString()); } - @Test - void aCarrierNamedInCodeBeatsOneNamedOutsideIt() { - ExecutionMode chosen = withProperty( - "CONTROL", - () -> ServerConfig.builder().mode(ExecutionMode.DIRECT).build().mode()); - - assertEquals(ExecutionMode.DIRECT, chosen, "code that names a carrier has said more than an ambient setting"); - } - - @Test - void aPropertyChoosesTheCarrierWhenNothingInCodeDoes() { - ExecutionMode chosen = - withProperty("CONTROL", () -> ServerConfig.builder().build().mode()); - - assertEquals(ExecutionMode.CONTROL, chosen); - } - - @Test - void theCarrierIsDirectWhenNothingChoosesOne() { - assumeTrue(System.getenv(ExecutionMode.VARIABLE) == null, "this shell has already chosen a carrier"); - - ExecutionMode chosen = - withProperty(null, () -> ServerConfig.builder().build().mode()); - - assertEquals(ExecutionMode.DIRECT, chosen); - } - - @Test - void copyingAConfigKeepsTheCarrierItAlreadyResolved() { - ServerConfig ambient = - withProperty("CONTROL", () -> ServerConfig.builder().build()); - - assertEquals( - ExecutionMode.CONTROL, - ambient.toBuilder().build().mode(), - "a copy that re-read the property would differ from what it copied"); - } - @Test void invalidChoicesAreRejectedWhileTheyCanStillBeFixed() { assertThrows( @@ -146,23 +105,4 @@ void invalidChoicesAreRejectedWhileTheyCanStillBeFixed() { .defaultTimeout(Duration.ofSeconds(-1)) .build()); } - - /** Runs the body with the mode property set as asked, and puts back whatever was there before. */ - private static T withProperty(@Nullable String value, Supplier body) { - String previous = System.getProperty(ExecutionMode.PROPERTY); - if (value == null) { - System.clearProperty(ExecutionMode.PROPERTY); - } else { - System.setProperty(ExecutionMode.PROPERTY, value); - } - try { - return body.get(); - } finally { - if (previous == null) { - System.clearProperty(ExecutionMode.PROPERTY); - } else { - System.setProperty(ExecutionMode.PROPERTY, previous); - } - } - } } diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 241d0ac..0475951 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -5,12 +5,16 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.github.libtmux.format.RowFormat; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.DispatchOutcome; import io.github.libtmux.transport.TmuxTransport; +import io.github.libtmux.transport.TmuxTransportException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -154,6 +158,57 @@ void operationsAfterCloseAreRejected(@TempDir Path directory) throws IOException assertThrows(IllegalStateException.class, () -> server.cmd("list-sessions")); } + @Test + void aWaitPropagatesTransportFailuresThatAreNotItsDeadline(@TempDir Path directory) throws IOException { + TmuxTransportException failure = new TmuxTransportException("pipe failed", DispatchOutcome.UNKNOWN, null); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + if (request.argv().contains("wait-for")) { + throw failure; + } + return new CommandResult(0, List.of("4242"), List.of()); + } + + @Override + public void close() {} + }; + + try (Server server = Server.using(config(directory), transport)) { + assertSame( + failure, + assertThrows( + TmuxTransportException.class, + () -> server.waitFor("channel", java.time.Duration.ofSeconds(1)))); + } + } + + @Test + void malformedOrInconsistentListingsRespectStrictAndLenientBoundaries(@TempDir Path directory) throws IOException { + String separator = RowFormat.of("field").separator(); + for (String sessionRow : List.of( + String.join(separator, "$0", "alpha", "maybe", "0"), + String.join(separator, "$0", "alpha", "1", "not-a-number"), + String.join(separator, "$0", "alpha", "1", "1"))) { + try (Server server = Server.using(config(directory), new SnapshotTransport(sessionRow))) { + LibTmuxException failure = assertThrows(LibTmuxException.class, server::snapshot); + + assertTrue(failure.getCause() instanceof IllegalArgumentException, failure.toString()); + assertEquals(List.of(), server.sessions(), "lenient listings collapse hydration failures to empty"); + } + } + } + + @Test + void moreThanOneAttachedClientStillMeansTheSessionIsAttached(@TempDir Path directory) throws IOException { + String separator = RowFormat.of("field").separator(); + String sessionRow = String.join(separator, "$0", "alpha", "2", "0"); + + try (Server server = Server.using(config(directory), new SnapshotTransport(sessionRow))) { + assertTrue(server.snapshot().sessions().get(0).attached()); + } + } + // -------------------------------------------------------------------------------- builders @Test @@ -267,4 +322,21 @@ public void close() { closes.incrementAndGet(); } } + + private record SnapshotTransport(String sessionRow) implements TmuxTransport { + + @Override + public CommandResult execute(CommandRequest request) { + return switch (request.argv().get(0)) { + case "list-sessions" -> new CommandResult(0, List.of(sessionRow), List.of()); + case "display-message" -> + new CommandResult( + 0, List.of(String.join(RowFormat.of("field").separator(), "4242", "3.6")), List.of()); + default -> new CommandResult(0, List.of(), List.of()); + }; + } + + @Override + public void close() {} + } } diff --git a/libtmux/src/test/java/io/github/libtmux/TargetIdTest.java b/libtmux/src/test/java/io/github/libtmux/TargetIdTest.java index 1d5ddd2..082f362 100644 --- a/libtmux/src/test/java/io/github/libtmux/TargetIdTest.java +++ b/libtmux/src/test/java/io/github/libtmux/TargetIdTest.java @@ -45,6 +45,13 @@ void aSigilAloneIdentifiesNothing() { assertThrows(IllegalArgumentException.class, () -> new PaneId("%")); } + @Test + void anIdSuffixContainsDigitsOnly() { + assertThrows(IllegalArgumentException.class, () -> new SessionId("$abc")); + assertThrows(IllegalArgumentException.class, () -> new WindowId("@-1")); + assertThrows(IllegalArgumentException.class, () -> new PaneId("%1x")); + } + @Test void twoIdsOfDifferentKindsCannotCompareEqual() { assertNotEquals(new SessionId("$1").value(), new WindowId("@1").value()); diff --git a/libtmux/src/test/java/io/github/libtmux/TmuxEnvironmentTest.java b/libtmux/src/test/java/io/github/libtmux/TmuxEnvironmentTest.java index efed1b5..a73ddb4 100644 --- a/libtmux/src/test/java/io/github/libtmux/TmuxEnvironmentTest.java +++ b/libtmux/src/test/java/io/github/libtmux/TmuxEnvironmentTest.java @@ -73,6 +73,10 @@ void anUnreadableValueIsAbsentRatherThanAFailure() { assertTrue(TmuxEnvironment.of(Map.of("TMUX", "nonsense")).isEmpty()); assertTrue(TmuxEnvironment.of(Map.of("TMUX", "/tmp/s,notapid,0")).isEmpty()); assertTrue(TmuxEnvironment.of(Map.of("TMUX", "/tmp/s,1,")).isEmpty()); + assertTrue(TmuxEnvironment.of(Map.of("TMUX", "/tmp/s,1,abc")).isEmpty()); + assertTrue(TmuxEnvironment.of(Map.of("TMUX", "/tmp/s,1,0", "TMUX_PANE", "%x")) + .isEmpty()); + assertTrue(TmuxEnvironment.of(Map.of("TMUX", "bad\0path,1,0")).isEmpty()); } /** A process can inherit TMUX without TMUX_PANE, and still knows which server it is on. */ diff --git a/libtmux/src/test/java/io/github/libtmux/snapshot/ServerSnapshotTest.java b/libtmux/src/test/java/io/github/libtmux/snapshot/ServerSnapshotTest.java index bbef483..3632f2e 100644 --- a/libtmux/src/test/java/io/github/libtmux/snapshot/ServerSnapshotTest.java +++ b/libtmux/src/test/java/io/github/libtmux/snapshot/ServerSnapshotTest.java @@ -49,45 +49,18 @@ private static ServerSnapshot linked() { new WindowState(IN_ALPHA, "editor", true, 2, true, SIZE, "layout"), new WindowState(IN_BETA, "editor", false, 2, true, SIZE, "layout")), List.of( - new PaneState( - IN_ALPHA, - new PaneId("%1"), - 0, - true, - "nvim", - SIZE, - "t", - PATH, - 1L, - EDGES, - Optional.of(false)), - new PaneState( - IN_ALPHA, - new PaneId("%2"), - 1, - false, - "zsh", - SIZE, - "t", - PATH, - 1L, - EDGES, - Optional.of(false)), - new PaneState( - IN_BETA, - new PaneId("%1"), - 0, - true, - "nvim", - SIZE, - "t", - PATH, - 1L, - EDGES, - Optional.of(false))), + pane(IN_ALPHA, "%1", 0, true, "nvim"), + pane(IN_ALPHA, "%2", 1, false, "zsh"), + pane(IN_BETA, "%1", 0, true, "nvim"), + pane(IN_BETA, "%2", 1, false, "zsh")), List.of(new ClientState("/dev/pts/3", Optional.of(ALPHA)))); } + private static PaneState pane(WindowContext context, String id, int index, boolean active, String command) { + return new PaneState( + context, new PaneId(id), index, active, command, SIZE, "t", PATH, 1L, EDGES, Optional.of(false)); + } + @Test void aLinkedWindowIsOneWindowAndTwoPositions() { ServerSnapshot snapshot = linked(); @@ -111,7 +84,7 @@ void everyRelationIsReadableFromTheCaptureAlone() { assertEquals( List.of(new PaneId("%1"), new PaneId("%2")), snapshot.panesOf(IN_ALPHA).stream().map(PaneState::id).toList()); - assertEquals(1, snapshot.panesOf(IN_BETA).size(), "the other link has its own panes"); + assertEquals(2, snapshot.panesOf(IN_BETA).size(), "tmux lists each pane under each link"); } @Test @@ -163,6 +136,79 @@ void clientsAreCapturedWithWhateverTheyAreAttachedTo() { assertEquals(Optional.of(ALPHA), snapshot.clients().get(0).session()); } + @Test + void duplicateHierarchyKeysAreRejected() { + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of( + new SessionState(ALPHA, "alpha", false, 0), + new SessionState(ALPHA, "duplicate", false, 0)), + List.of(), + List.of(), + List.of())); + + WindowState duplicate = new WindowState(IN_ALPHA, "editor", true, 0, false, SIZE, "layout"); + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of(new SessionState(ALPHA, "alpha", false, 2)), + List.of(duplicate, duplicate), + List.of(), + List.of())); + + PaneState repeated = pane(IN_ALPHA, "%1", 0, true, "nvim"); + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of(new SessionState(ALPHA, "alpha", false, 1)), + List.of(new WindowState(IN_ALPHA, "editor", true, 2, false, SIZE, "layout")), + List.of(repeated, repeated), + List.of())); + } + + @Test + void duplicateLogicalSlotsAndPaneOwnershipAreRejected() { + WindowContext alternateWindow = new WindowContext(ALPHA, new WindowIndex(0), new WindowId("@8")); + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of(new SessionState(ALPHA, "alpha", false, 2)), + List.of( + new WindowState(IN_ALPHA, "one", false, 0, false, SIZE, "layout"), + new WindowState(alternateWindow, "two", false, 0, false, SIZE, "layout")), + List.of(), + List.of()), + "one session index cannot name two windows"); + + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of(new SessionState(ALPHA, "alpha", false, 1)), + List.of(new WindowState(IN_ALPHA, "one", false, 2, false, SIZE, "layout")), + List.of(pane(IN_ALPHA, "%1", 0, true, "nvim"), pane(IN_ALPHA, "%2", 0, false, "zsh")), + List.of()), + "one window index cannot name two panes"); + + WindowContext secondWindow = new WindowContext(ALPHA, new WindowIndex(1), new WindowId("@8")); + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of(new SessionState(ALPHA, "alpha", false, 2)), + List.of( + new WindowState(IN_ALPHA, "one", false, 1, false, SIZE, "layout"), + new WindowState(secondWindow, "two", false, 1, false, SIZE, "layout")), + List.of(pane(IN_ALPHA, "%1", 0, true, "nvim"), pane(secondWindow, "%1", 0, true, "nvim")), + List.of()), + "one global pane id cannot belong to two underlying windows"); + } + @Test void aPaneWhoseWindowWasNeverCapturedIsARejectedCapture() { WindowContext orphan = new WindowContext(new SessionId("$5"), new WindowIndex(0), new WindowId("@5")); @@ -189,6 +235,62 @@ void aPaneWhoseWindowWasNeverCapturedIsARejectedCapture() { "a pane under no captured window means the listings disagreed"); } + @Test + void aPaneNeedsItsExactWindowRatherThanAnyWindowInTheSession() { + WindowContext orphan = new WindowContext(ALPHA, new WindowIndex(9), new WindowId("@9")); + + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of(new SessionState(ALPHA, "alpha", true, 1)), + List.of(new WindowState(IN_ALPHA, "editor", true, 0, false, SIZE, "layout")), + List.of(new PaneState( + orphan, + new PaneId("%9"), + 0, + true, + "zsh", + SIZE, + "t", + PATH, + 1L, + EDGES, + Optional.empty())), + List.of())); + } + + @Test + void declaredChildCountsMustMatchTheCapturedHierarchy() { + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, List.of(new SessionState(ALPHA, "alpha", true, 1)), List.of(), List.of(), List.of()), + "a session cannot claim a window absent from the listing"); + + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of(new SessionState(ALPHA, "alpha", true, 1)), + List.of(new WindowState(IN_ALPHA, "editor", true, 1, false, SIZE, "layout")), + List.of(), + List.of()), + "a window cannot claim a pane absent from the listing"); + } + + @Test + void anAttachedClientNeedsItsCapturedSession() { + assertThrows( + IllegalArgumentException.class, + () -> ServerSnapshot.of( + WHEN, + List.of(), + List.of(), + List.of(), + List.of(new ClientState("/dev/pts/3", Optional.of(ALPHA))))); + } + @Test void aRejectedCaptureNamesWhatDisagreed() { IllegalArgumentException refused = assertThrows( diff --git a/libtmux/src/test/java/io/github/libtmux/transport/ControlTransportTest.java b/libtmux/src/test/java/io/github/libtmux/transport/ControlTransportTest.java deleted file mode 100644 index 0d0e91b..0000000 --- a/libtmux/src/test/java/io/github/libtmux/transport/ControlTransportTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.libtmux.transport; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import io.github.libtmux.ServerConfig; -import io.github.libtmux.ServerEndpoint; -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; - -/** What the control carrier owes a caller that closes it while it is still working something out. */ -final class ControlTransportTest { - - private static final ServerConfig CONFIG = ServerConfig.builder() - // Nothing here may reach a real tmux. A control client that did start would have to be - // waited for, and this test is about the one that must never be started. - .binary("libtmux-no-such-tmux-binary") - .endpoint(ServerEndpoint.socketPath(Path.of("/tmp/libtmux-java-dev/unit/s"))) - .build(); - - private static CommandRequest request() { - return new CommandRequest(CONFIG.endpointCommand(), List.of("list-windows"), Duration.ofSeconds(5)); - } - - /** - * Attaching takes two steps — find a session, then attach to it — and a transport can be closed - * between them. That close cannot see a client which does not exist yet, so the attach is what - * has to notice; otherwise it leaves a tmux client and its reader thread behind with nothing - * holding either. - * - *

The close is driven from inside the session lookup, which is exactly where it would have to - * land for the race to happen at all. - */ - @Test - void aCloseLandingMidAttachLeavesNoClientBehind() { - AtomicReference holder = new AtomicReference<>(); - TmuxTransport bootstrap = new TmuxTransport() { - @Override - public CommandResult execute(CommandRequest ignored) { - ControlTransport transport = holder.get(); - if (transport != null) { - transport.close(); - } - return new CommandResult(0, List.of("$0"), List.of()); - } - - @Override - public void close() {} - }; - ControlTransport transport = new ControlTransport(CONFIG, bootstrap); - holder.set(transport); - - IllegalStateException refused = assertThrows(IllegalStateException.class, () -> transport.execute(request())); - String reason = String.valueOf(refused.getMessage()); - - assertTrue( - reason.contains("closed"), - "a closed transport says so, rather than failing at starting a client it should never " - + "have started: " + reason); - } -} diff --git a/libtmux/src/test/java/io/github/libtmux/transport/VirtualThreadTransportTest.java b/libtmux/src/test/java/io/github/libtmux/transport/VirtualThreadTransportTest.java deleted file mode 100644 index a886475..0000000 --- a/libtmux/src/test/java/io/github/libtmux/transport/VirtualThreadTransportTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package io.github.libtmux.transport; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.time.Duration; -import java.util.List; -import org.junit.jupiter.api.Test; - -/** The virtual-thread carrier moves where a call parks; it must not change what the call answers. */ -final class VirtualThreadTransportTest { - - private static final CommandRequest REQUEST = - new CommandRequest(List.of("tmux"), List.of("list-windows"), Duration.ofSeconds(5)); - - private record Fixed(CommandResult answer) implements TmuxTransport { - @Override - public CommandResult execute(CommandRequest ignored) { - return answer; - } - - @Override - public void close() {} - } - - private record Throwing(RuntimeException failure) implements TmuxTransport { - @Override - public CommandResult execute(CommandRequest ignored) { - throw failure; - } - - @Override - public void close() {} - } - - /** The worker runs the delegate; what the delegate answers is what the caller must get back. */ - @Test - void theDelegatesAnswerIsTheCallersAnswer() { - CommandResult answer = new CommandResult(0, List.of("one"), List.of()); - try (VirtualThreadTransport transport = new VirtualThreadTransport(new Fixed(answer))) { - assertEquals(answer, transport.execute(REQUEST)); - } - } - - @Test - void aFailureCrossesTheWorkerUnchanged() { - TmuxTransportException failure = - new TmuxTransportException("tmux exceeded its deadline", DispatchOutcome.UNKNOWN, null); - try (VirtualThreadTransport transport = new VirtualThreadTransport(new Throwing(failure))) { - assertEquals(failure, assertThrows(TmuxTransportException.class, () -> transport.execute(REQUEST))); - } - } - - /** - * An {@code Error} is not a {@code RuntimeException}, so a carrier that only rescues the latter - * loses it: the worker dies with nothing recorded and the join returns normally. What the caller - * then receives is no failure and no result — a null the whole library is declared not to have. - */ - @Test - void anErrorInTheWorkerReachesTheCallerRatherThanBecomingANullResult() { - TmuxTransport erroring = new TmuxTransport() { - @Override - public CommandResult execute(CommandRequest ignored) { - throw new UnknownError("the worker died"); - } - - @Override - public void close() {} - }; - try (VirtualThreadTransport transport = new VirtualThreadTransport(erroring)) { - assertEquals( - "the worker died", - assertThrows(UnknownError.class, () -> transport.execute(REQUEST)) - .getMessage()); - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 89da86c..a6490d2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,7 +23,6 @@ include("libtmux-workspace") include("libtmux-mcp") // Internal: exercised by the build, never released. -include("benchmarks") include("docs-tests") include("examples") include("integration-tests") From f0903ab6b87b4037a41dfb7d4bc01b4b63f91bb0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:06:50 -0500 Subject: [PATCH 04/77] Workspace(fix[apply]): Validate before effects why: Invalid or unsupported workspace input could create a partial session, and an uncertain creation reply left no exact rollback target. what: - Split parsing from application - Validate names, layouts, topology, and tmux version before creation - Create under a unique staging name and roll back that exact session --- .../github/libtmux/workspace/Workspace.java | 3 + .../libtmux/workspace/WorkspaceApplier.java | 127 ++++++++ .../libtmux/workspace/WorkspaceBuilder.java | 105 +------ .../libtmux/workspace/WorkspaceParser.java | 147 +++++++++ .../workspace/WorkspaceBuilderTest.java | 295 +++++++++++++++++- 5 files changed, 571 insertions(+), 106 deletions(-) create mode 100644 libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceApplier.java create mode 100644 libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceParser.java diff --git a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/Workspace.java b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/Workspace.java index 9be2169..4a120a7 100644 --- a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/Workspace.java +++ b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/Workspace.java @@ -15,6 +15,9 @@ public record Workspace(String sessionName, List windows) { if (sessionName.isEmpty()) { throw new IllegalArgumentException("the workspace has no session name"); } + if (sessionName.indexOf('.') >= 0 || sessionName.indexOf(':') >= 0) { + throw new IllegalArgumentException("workspace session names cannot contain '.' or ':': " + sessionName); + } if (windows.isEmpty()) { throw new IllegalArgumentException("the workspace has no windows"); } diff --git a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceApplier.java b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceApplier.java new file mode 100644 index 0000000..44400e6 --- /dev/null +++ b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceApplier.java @@ -0,0 +1,127 @@ +package io.github.libtmux.workspace; + +import io.github.libtmux.Layout; +import io.github.libtmux.Layouts; +import io.github.libtmux.LibTmuxException; +import io.github.libtmux.Pane; +import io.github.libtmux.Server; +import io.github.libtmux.Session; +import io.github.libtmux.Window; +import io.github.libtmux.transport.CommandResult; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Applies a validated workspace as one session, cleaning it up after any failure. */ +final class WorkspaceApplier { + + private WorkspaceApplier() {} + + static Session build(Server server, Workspace workspace) { + validate(server, workspace); + String staging = "libtmux-ws-" + UUID.randomUUID(); + Session session = null; + try { + session = server.newSession(staging); + session = session.rename(workspace.sessionName()); + List windows = createTopology(session, workspace.windows()); + runCommands(windows); + return session.refresh(); + } catch (RuntimeException | Error failure) { + if (session == null) { + cleanupStaging(server, staging, failure); + } else { + try { + session.kill(); + } catch (RuntimeException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + throw failure; + } + } + + private static void validate(Server server, Workspace workspace) { + for (WindowSpec window : workspace.windows()) { + window.layout().ifPresent(value -> { + Layouts.require(value); + builtIn(value).ifPresent(layout -> layout.requireSupported(server.version())); + }); + } + } + + private static void cleanupStaging(Server server, String staging, Throwable failure) { + try { + CommandResult cleanup = server.cmd("kill-session", "-t", "=" + staging); + if (!cleanup.succeeded() && cleanup.stderr().stream().noneMatch(WorkspaceApplier::alreadyAbsent)) { + failure.addSuppressed(new LibTmuxException( + "could not clean up staging session: " + String.join("; ", cleanup.stderr()))); + } + } catch (RuntimeException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + + private static boolean alreadyAbsent(String message) { + return message.contains("can't find session") + || message.contains("no server running") + || message.contains("server exited unexpectedly"); + } + + private static List createTopology(Session session, List specs) { + List windows = new ArrayList<>(specs.size()); + for (int index = 0; index < specs.size(); index++) { + WindowSpec spec = specs.get(index); + Window window = index == 0 ? firstWindow(session, spec.name()) : session.newWindow(spec.name()); + for (int pane = 1; pane < spec.panes().size(); pane++) { + window.split(); + } + applyLayout(window, spec.layout()); + List panes = window.refresh().panes(); + requirePaneCount(panes, spec); + windows.add(new BuiltWindow(spec, panes)); + } + return List.copyOf(windows); + } + + private static Window firstWindow(Session session, String name) { + Window window = session.windows().getFirst(); + return name.isEmpty() ? window : window.rename(name); + } + + private static void applyLayout(Window window, Optional layout) { + layout.ifPresent( + value -> builtIn(value).ifPresentOrElse(window::selectLayout, () -> window.applyLayout(value))); + } + + private static Optional builtIn(String layout) { + for (Layout candidate : Layout.values()) { + if (candidate.tmuxName().equals(layout)) { + return Optional.of(candidate); + } + } + return Optional.empty(); + } + + private static void runCommands(List windows) { + for (BuiltWindow window : windows) { + WindowSpec spec = window.specification(); + List panes = window.panes(); + for (int paneIndex = 0; paneIndex < panes.size(); paneIndex++) { + for (String command : spec.panes().get(paneIndex).commands()) { + panes.get(paneIndex).sendLine(command); + } + } + } + } + + private static void requirePaneCount(List panes, WindowSpec spec) { + if (panes.size() != spec.panes().size()) { + throw new IllegalStateException("window '" + spec.name() + "' has " + panes.size() + " panes; expected " + + spec.panes().size()); + } + } + + private record BuiltWindow(WindowSpec specification, List panes) {} +} diff --git a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceBuilder.java b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceBuilder.java index 9672bde..ec84999 100644 --- a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceBuilder.java +++ b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceBuilder.java @@ -1,20 +1,11 @@ package io.github.libtmux.workspace; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import io.github.libtmux.Layouts; -import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.Session; -import io.github.libtmux.Window; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; /** * Builds a tmux session from a written description. @@ -27,8 +18,6 @@ */ public final class WorkspaceBuilder { - private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); - private WorkspaceBuilder() {} /** Reads a workspace from a file. */ @@ -47,63 +36,7 @@ public static Workspace read(Path file) { * layout name tmux would not recognise */ public static Workspace parse(String yaml) { - JsonNode root; - try { - root = YAML.readTree(yaml); - } catch (IOException e) { - throw new IllegalArgumentException("the workspace is not readable YAML", e); - } - if (root == null || !root.isObject()) { - throw new IllegalArgumentException("the workspace is not a mapping"); - } - List windows = new ArrayList<>(); - for (JsonNode window : root.path("windows")) { - windows.add(window(window)); - } - return new Workspace(root.path("session_name").asText(""), windows); - } - - private static WindowSpec window(JsonNode window) { - String name = window.path("window_name").asText(""); - JsonNode layout = window.get("layout"); - List panes = new ArrayList<>(); - for (JsonNode pane : window.path("panes")) { - panes.add(pane(pane)); - } - if (panes.isEmpty()) { - // tmux cannot make a window without a pane, so an unstated pane means the default one. - panes.add(new PaneSpec(List.of())); - } - return new WindowSpec( - name, - layout == null || layout.isNull() ? Optional.empty() : Optional.of(Layouts.require(layout.asText())), - panes); - } - - /** A pane is a bare command, a list of commands, or a mapping carrying {@code shell_command}. */ - private static PaneSpec pane(JsonNode pane) { - if (pane.isTextual()) { - return new PaneSpec(List.of(pane.asText())); - } - if (pane.isArray()) { - return new PaneSpec(texts(pane)); - } - JsonNode command = pane.path("shell_command"); - if (command.isTextual()) { - return new PaneSpec(List.of(command.asText())); - } - return new PaneSpec(texts(command)); - } - - private static List texts(JsonNode node) { - List values = new ArrayList<>(); - for (JsonNode element : node) { - values.add( - element.isTextual() - ? element.asText() - : element.path("shell_command").asText("")); - } - return values; + return WorkspaceParser.parse(yaml); } /** @@ -112,40 +45,6 @@ private static List texts(JsonNode node) { * @return the session, from a capture taken once everything exists */ public static Session build(Server server, Workspace workspace) { - server.run(List.of("new-session", "-d", "-s", workspace.sessionName())); - Session session = server.sessions().stream() - .filter(candidate -> candidate.name().equals(workspace.sessionName())) - .findFirst() - .orElseThrow(() -> new IllegalStateException("the session just created is not there")); - - List windows = workspace.windows(); - for (int index = 0; index < windows.size(); index++) { - WindowSpec spec = windows.get(index); - // tmux made the first window with the session, so it is renamed rather than added. - Window window = index == 0 - ? session.refresh().windows().get(0).rename(spec.name()) - : session.refresh().newWindow(spec.name()); - fill(window, spec); - } - return session.refresh(); - } - - private static void fill(Window window, WindowSpec spec) { - Window built = window; - for (int index = 1; index < spec.panes().size(); index++) { - built.split(); - built = built.refresh(); - } - Window arranged = built; - spec.layout() - .ifPresent(layout -> arranged.server() - .run(List.of("select-layout", "-t", arranged.id().value(), layout))); - - List panes = arranged.refresh().panes(); - for (int index = 0; index < spec.panes().size() && index < panes.size(); index++) { - for (String command : spec.panes().get(index).commands()) { - panes.get(index).sendLine(command); - } - } + return WorkspaceApplier.build(server, workspace); } } diff --git a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceParser.java b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceParser.java new file mode 100644 index 0000000..90b482a --- /dev/null +++ b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceParser.java @@ -0,0 +1,147 @@ +package io.github.libtmux.workspace; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MappingIterator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.github.libtmux.Layouts; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +/** Converts the supported YAML shape into immutable workspace values. */ +final class WorkspaceParser { + + private static final ObjectMapper YAML = new ObjectMapper(YAMLFactory.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build()); + private static final Set ROOT_FIELDS = Set.of("session_name", "windows"); + private static final Set WINDOW_FIELDS = Set.of("window_name", "layout", "panes"); + private static final Set PANE_FIELDS = Set.of("shell_command"); + + private WorkspaceParser() {} + + static Workspace parse(String yaml) { + List documents; + try (MappingIterator values = YAML.readerFor(JsonNode.class).readValues(yaml)) { + documents = values.readAll(); + } catch (JsonProcessingException e) { + String detail = e.getOriginalMessage(); + if (detail.toLowerCase(Locale.ROOT).contains("duplicate")) { + detail = "duplicate key: " + detail; + } + throw new IllegalArgumentException("the workspace is not readable YAML: " + detail, e); + } catch (IOException e) { + throw new IllegalArgumentException("the workspace is not readable YAML", e); + } + if (documents.size() != 1) { + throw new IllegalArgumentException("the workspace must contain exactly one YAML document"); + } + + JsonNode root = object(documents.getFirst(), "$"); + rejectUnknown(root, ROOT_FIELDS, "$"); + String sessionName = text(root.get("session_name"), "$.session_name"); + JsonNode windowNodes = array(root.get("windows"), "$.windows"); + List windows = new ArrayList<>(); + for (int index = 0; index < windowNodes.size(); index++) { + windows.add(window(windowNodes.get(index), "$.windows[" + index + "]")); + } + return new Workspace(sessionName, windows); + } + + private static WindowSpec window(JsonNode node, String path) { + JsonNode window = object(node, path); + rejectUnknown(window, WINDOW_FIELDS, path); + String name = + optionalText(window.get("window_name"), path + ".window_name").orElse(""); + Optional layout = + optionalText(window.get("layout"), path + ".layout").map(Layouts::require); + + JsonNode paneNodes = window.get("panes"); + List panes = new ArrayList<>(); + if (paneNodes == null) { + panes.add(new PaneSpec(List.of())); + } else { + JsonNode paneArray = array(paneNodes, path + ".panes"); + for (int index = 0; index < paneArray.size(); index++) { + panes.add(pane(paneArray.get(index), path + ".panes[" + index + "]")); + } + if (panes.isEmpty()) { + panes.add(new PaneSpec(List.of())); + } + } + return new WindowSpec(name, layout, panes); + } + + private static PaneSpec pane(JsonNode node, String path) { + if (node.isTextual()) { + return new PaneSpec(List.of(node.textValue())); + } + if (node.isArray()) { + return new PaneSpec(texts(node, path)); + } + JsonNode pane = object(node, path); + rejectUnknown(pane, PANE_FIELDS, path); + JsonNode command = pane.get("shell_command"); + if (command == null) { + throw malformed(path + ".shell_command", "is required"); + } + if (command.isTextual()) { + return new PaneSpec(List.of(command.textValue())); + } + return new PaneSpec(texts(array(command, path + ".shell_command"), path + ".shell_command")); + } + + private static List texts(JsonNode nodes, String path) { + List values = new ArrayList<>(); + for (int index = 0; index < nodes.size(); index++) { + values.add(text(nodes.get(index), path + "[" + index + "]")); + } + return values; + } + + private static JsonNode object(JsonNode node, String path) { + if (node == null || !node.isObject()) { + throw malformed(path, "must be a mapping"); + } + return node; + } + + private static JsonNode array(JsonNode node, String path) { + if (node == null || !node.isArray()) { + throw malformed(path, "must be a list"); + } + return node; + } + + private static String text(JsonNode node, String path) { + if (node == null || !node.isTextual()) { + throw malformed(path, "must be text"); + } + return node.textValue(); + } + + private static Optional optionalText(JsonNode node, String path) { + if (node == null || node.isNull()) { + return Optional.empty(); + } + return Optional.of(text(node, path)); + } + + private static void rejectUnknown(JsonNode object, Set allowed, String path) { + object.fieldNames().forEachRemaining(field -> { + if (!allowed.contains(field)) { + throw malformed(path + "." + field, "is not supported"); + } + }); + } + + private static IllegalArgumentException malformed(String path, String problem) { + return new IllegalArgumentException(path + " " + problem); + } +} diff --git a/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java b/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java index b69dfcf..19fe709 100644 --- a/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java +++ b/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java @@ -1,18 +1,33 @@ package io.github.libtmux.workspace; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.Pane; import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.ServerEndpoint; import io.github.libtmux.Session; +import io.github.libtmux.UnsupportedTmuxVersion; import io.github.libtmux.Window; +import io.github.libtmux.format.RowFormat; import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.TmuxTransport; +import io.github.libtmux.transport.TmuxTransportException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; /** Building a session from a written description, and refusing to build one tmux would not survive. */ @ExtendWith(TmuxExtension.class) @@ -98,7 +113,14 @@ void aLayoutTmuxWouldNotRecogniseIsRefusedWhileItIsStillText() { @Test void everyLayoutTmuxDoesRecogniseIsAccepted() { - for (String layout : List.of("even-horizontal", "even-vertical", "main-horizontal", "main-vertical", "tiled")) { + for (String layout : List.of( + "even-horizontal", + "even-vertical", + "main-horizontal", + "main-vertical", + "tiled", + "main-horizontal-mirrored", + "main-vertical-mirrored")) { assertEquals( Optional.of(layout), WorkspaceBuilder.parse("session_name: s\nwindows:\n - window_name: w\n layout: " + layout) @@ -107,15 +129,52 @@ void everyLayoutTmuxDoesRecogniseIsAccepted() { .layout()); } assertEquals( - Optional.of("bb62,80x24,0,0{40x24,0,0,1,39x24,41,0,2}"), + Optional.of("8205,80x24,0,0{40x24,0,0,0,39x24,41,0,1}"), WorkspaceBuilder.parse( - "session_name: s\nwindows:\n - window_name: w\n layout: 'bb62,80x24,0,0{40x24,0,0,1,39x24,41,0,2}'") + "session_name: s\nwindows:\n - window_name: w\n layout: '8205,80x24,0,0{40x24,0,0,0,39x24,41,0,1}'") .windows() .get(0) .layout(), "a serialized layout is a layout too"); } + @Test + void aSerializedLayoutWithTheWrongChecksumIsRefusedBeforeTmuxSeesIt() { + IllegalArgumentException refused = + assertThrows(IllegalArgumentException.class, () -> WorkspaceBuilder.parse(""" + session_name: dangerous + windows: + - window_name: one + layout: '0000,80x24,0,0,1' + """)); + + assertTrue(String.valueOf(refused.getMessage()).contains("0000,80x24,0,0,1")); + } + + @Test + void aChecksumDoesNotMakeArbitraryTextALayout() { + String malformed = serialized("not-a-layout"); + + IllegalArgumentException refused = assertThrows( + IllegalArgumentException.class, + () -> WorkspaceBuilder.parse("session_name: dangerous\nwindows:\n - layout: '" + malformed + "'\n")); + + assertTrue(String.valueOf(refused.getMessage()).contains(malformed)); + } + + @Test + void serializedLayoutsUseTmuxsAsciiNumberGrammar() { + String unicodeBody = serialized("١x1,0,0"); + String unicodeChecksum = "٨٢٠٥,80x24,0,0{40x24,0,0,0,39x24,41,0,1}"; + + assertThrows( + IllegalArgumentException.class, + () -> WorkspaceBuilder.parse("session_name: s\nwindows:\n - layout: '" + unicodeBody + "'\n")); + assertThrows( + IllegalArgumentException.class, + () -> WorkspaceBuilder.parse("session_name: s\nwindows:\n - layout: '" + unicodeChecksum + "'\n")); + } + @Test void aDescriptionTmuxCouldNotBuildIsRejected() { assertThrows(IllegalArgumentException.class, () -> WorkspaceBuilder.parse("windows: []")); @@ -123,6 +182,91 @@ void aDescriptionTmuxCouldNotBuildIsRejected() { assertThrows(IllegalArgumentException.class, () -> WorkspaceBuilder.parse("- not: a mapping")); } + @Test + void sessionNamesExcludeTmuxTargetDelimiters() { + for (String name : List.of("with.dot", "with:colon")) { + assertThrows( + IllegalArgumentException.class, + () -> WorkspaceBuilder.parse("session_name: '" + name + "'\nwindows:\n - window_name: one\n")); + assertThrows( + IllegalArgumentException.class, + () -> new Workspace( + name, List.of(new WindowSpec("one", Optional.empty(), List.of(new PaneSpec(List.of())))))); + } + } + + @Test + void malformedShapesNameTheExactYamlPath() { + List malformed = List.of( + new BadWorkspace(""" + session_name: typo + windows: + - window_name: one + panes: + - shell_commmand: echo lost + """, "$.windows[0].panes[0].shell_commmand"), + new BadWorkspace(""" + session_name: number + windows: + - window_name: one + panes: + - shell_command: 42 + """, "$.windows[0].panes[0].shell_command"), + new BadWorkspace(""" + session_name: list + windows: + - window_name: one + panes: + - [echo valid, 42] + """, "$.windows[0].panes[0][1]"), + new BadWorkspace(""" + session_name: mapping + windows: + - window_name: one + panes: {shell_command: echo misplaced} + """, "$.windows[0].panes"), + new BadWorkspace(""" + session_name: mapping + windows: {window_name: misplaced} + """, "$.windows"), + new BadWorkspace(""" + session_name: unknown + windows: + - window_name: one + extra: true + """, "$.extra")); + + for (BadWorkspace bad : malformed) { + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> WorkspaceBuilder.parse(bad.yaml())); + assertTrue(String.valueOf(failure.getMessage()).contains(bad.path()), failure.getMessage()); + } + } + + @Test + void duplicateKeysAndTrailingDocumentsAreRejected() { + IllegalArgumentException duplicate = + assertThrows(IllegalArgumentException.class, () -> WorkspaceBuilder.parse(""" + session_name: first + session_name: second + windows: + - window_name: one + """)); + IllegalArgumentException trailing = + assertThrows(IllegalArgumentException.class, () -> WorkspaceBuilder.parse(""" + session_name: first + windows: + - window_name: one + --- + session_name: ignored + windows: + - window_name: two + """)); + + assertTrue(String.valueOf(duplicate.getMessage()).contains("duplicate"), duplicate.getMessage()); + assertTrue(String.valueOf(trailing.getMessage()).contains("document"), trailing.getMessage()); + } + // ------------------------------------------------------------------------------ building @Test @@ -159,6 +303,135 @@ void buildingLeavesTheSessionTheFixtureAlreadyHad(Server server) { assertEquals(2, server.sessions().size(), "a workspace adds a session, it does not take one over"); } + @Test + void anUnsafeProgrammaticLayoutNeverReachesTmux(Server server) { + Workspace unsafe = new Workspace( + "unsafe", + List.of(new WindowSpec("one", Optional.of("0000,80x24,0,0,1"), List.of(new PaneSpec(List.of()))))); + + assertThrows(IllegalArgumentException.class, () -> WorkspaceBuilder.build(server, unsafe)); + + assertFalse(server.hasSession("unsafe")); + assertTrue(server.isAlive(), "rejecting input must not let tmux inspect an unsafe layout"); + } + + @Test + void anUnsupportedBuiltInLayoutIsRejectedBeforeAnyEffect() { + AtomicBoolean effected = new AtomicBoolean(); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + if (request.argv().get(0).equals("display-message")) { + return new CommandResult( + 0, List.of(String.join(RowFormat.of("field").separator(), "4242", "3.4")), List.of()); + } + effected.set(true); + return new CommandResult(0, List.of(), List.of()); + } + + @Override + public void close() {} + }; + Workspace workspace = new Workspace( + "portable", + List.of(new WindowSpec( + "one", Optional.of("main-horizontal-mirrored"), List.of(new PaneSpec(List.of()))))); + + try (Server old = Server.using(testConfig(), transport)) { + assertThrows(UnsupportedTmuxVersion.class, () -> WorkspaceBuilder.build(old, workspace)); + } + assertFalse(effected.get(), "version preflight must happen before new-session"); + } + + @Test + void anUncertainCreationStillTargetsItsUniqueStagingSessionForCleanup() { + AtomicReference staged = new AtomicReference<>(); + AtomicReference cleaned = new AtomicReference<>(); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + if (request.argv().get(0).equals("new-session")) { + staged.set(request.argv().get(request.argv().indexOf("-s") + 1)); + throw new TmuxTransportException("reply lost", DispatchOutcome.UNKNOWN, null); + } + if (request.argv().get(0).equals("kill-session")) { + cleaned.set(request.argv().get(request.argv().indexOf("-t") + 1)); + return new CommandResult(0, List.of(), List.of()); + } + return new CommandResult(0, List.of(), List.of()); + } + + @Override + public void close() {} + }; + Workspace workspace = new Workspace( + "wanted", List.of(new WindowSpec("one", Optional.empty(), List.of(new PaneSpec(List.of()))))); + + try (Server uncertain = Server.using(testConfig(), transport)) { + assertThrows(TmuxTransportException.class, () -> WorkspaceBuilder.build(uncertain, workspace)); + } + + assertTrue(staged.get().startsWith("libtmux-ws-")); + assertEquals("=" + staged.get(), cleaned.get()); + } + + @Test + void topologyFailureRunsNoCommandsAndRollsBackTheExactSession(Server server, @TempDir Path directory) + throws Exception { + Path marker = directory.resolve("command-ran"); + Workspace workspace = new Workspace( + "rolled-back", + List.of( + new WindowSpec("first", Optional.empty(), List.of(new PaneSpec(List.of("touch " + marker)))), + new WindowSpec("invalid\u0000window", Optional.empty(), List.of(new PaneSpec(List.of()))))); + + assertThrows(RuntimeException.class, () -> WorkspaceBuilder.build(server, workspace)); + Thread.sleep(250); + + assertFalse(Files.exists(marker), "commands must wait until every window and pane exists"); + assertFalse(server.hasSession("rolled-back"), "a failed build must not leave a partial session"); + assertEquals( + List.of("libtmux"), + server.sessions().stream().map(Session::name).toList()); + } + + @Test + void cleanupFailureIsSuppressedOnTheApplicationFailure(Server server) { + server.run(List.of("set-hook", "-g", "after-new-window", "kill-session -t =vanishing")); + Workspace workspace = new Workspace( + "vanishing", + List.of( + new WindowSpec( + "first", Optional.empty(), List.of(new PaneSpec(List.of("invalid\u0000command")))), + new WindowSpec("second", Optional.empty(), List.of(new PaneSpec(List.of()))))); + + RuntimeException failure = + assertThrows(RuntimeException.class, () -> WorkspaceBuilder.build(server, workspace)); + + assertTrue(failure.getSuppressed().length > 0, "cleanup must not replace the original application failure"); + assertEquals( + List.of("libtmux"), + server.sessions().stream().map(Session::name).toList()); + } + + @Test + void aPaneCountMismatchCannotSilentlyDropCommands(Server server) { + server.run(List.of("set-hook", "-g", "after-select-layout", "kill-pane -t =mismatched:0.1")); + Workspace workspace = new Workspace( + "mismatched", + List.of(new WindowSpec( + "window", + Optional.of("tiled"), + List.of(new PaneSpec(List.of("echo first")), new PaneSpec(List.of("echo must-not-be-lost")))))); + + assertThrows(IllegalStateException.class, () -> WorkspaceBuilder.build(server, workspace)); + + assertFalse(server.hasSession("mismatched")); + assertEquals( + List.of("libtmux"), + server.sessions().stream().map(Session::name).toList()); + } + private static boolean awaitOutput(Pane pane, String expected) throws InterruptedException { for (int attempt = 0; attempt < 100; attempt++) { if (pane.capture().stream().anyMatch(line -> line.contains(expected))) { @@ -168,4 +441,20 @@ private static boolean awaitOutput(Pane pane, String expected) throws Interrupte } return false; } + + private static String serialized(String body) { + int checksum = 0; + for (int index = 0; index < body.length(); index++) { + checksum = ((checksum >> 1) + ((checksum & 1) << 15) + body.charAt(index)) & 0xffff; + } + return "%04x,%s".formatted(checksum, body); + } + + private static ServerConfig testConfig() { + return ServerConfig.builder() + .endpoint(ServerEndpoint.namedSocket("workspace-test")) + .build(); + } + + private record BadWorkspace(String yaml, String path) {} } From fee7dc724a17f62b976721fe4a197575a480a2ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:16:52 -0500 Subject: [PATCH 05/77] Junit(fix[cleanup]): Prove fixture exit why: Teardown treated a failed probe as proof that tmux had exited, while abandoned-server recovery left dead owner directories behind. what: - Keep abandoned process and directory ownership together - Delete owner directories only after process exit is confirmed - Run leak regressions under the Java test root with unconditional cleanup --- .../github/libtmux/junit5/TmuxExtension.java | 89 ++++++++++------ .../libtmux/junit5/AbandonedServerTest.java | 100 ++++++++++++------ .../libtmux/junit5/TmuxExtensionTest.java | 27 +++++ 3 files changed, 147 insertions(+), 69 deletions(-) diff --git a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/TmuxExtension.java b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/TmuxExtension.java index 7c352e2..debc2ab 100644 --- a/libtmux-junit5/src/main/java/io/github/libtmux/junit5/TmuxExtension.java +++ b/libtmux-junit5/src/main/java/io/github/libtmux/junit5/TmuxExtension.java @@ -11,6 +11,7 @@ import java.time.Duration; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; @@ -48,6 +49,8 @@ public final class TmuxExtension implements ParameterResolver, BeforeEachCallbac private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(TmuxExtension.class); private static final String KEY = "fixture"; + private static final Path FIXTURE_ROOT = Path.of("/tmp/libtmux-java-test"); + /** * The directory a fixture is made in names the JVM that made it, which is the only durable record * of who owns the server inside. A registry cannot serve: the run that most needs reaping is the @@ -85,22 +88,28 @@ private static void releaseAll() { } } + static Path fixtureRoot() { + return FIXTURE_ROOT; + } + /** * Ends every tmux server under {@code root} whose owning JVM is gone, and answers how many * ended. Runs while other runs are using the same root, so it may only touch abandoned servers. */ static int reapAbandoned(Path root) { Path resolved = root.toAbsolutePath().normalize(); - List abandoned = ProcessHandle.allProcesses() - .filter(handle -> abandonedServer(handle, resolved)) + List abandoned = ProcessHandle.allProcesses() + .map(handle -> abandonedServer(handle, resolved)) + .flatMap(Optional::stream) .toList(); // Asked together, waited for afterwards, so one slow server does not serialise the rest. - abandoned.forEach(ProcessHandle::destroy); + abandoned.stream().map(AbandonedServer::process).forEach(ProcessHandle::destroy); int reaped = 0; - for (ProcessHandle handle : abandoned) { - if (ended(handle)) { + for (AbandonedServer server : abandoned) { + if (ended(server.process())) { + deleteTree(server.directory()); reaped++; } } @@ -124,38 +133,59 @@ private static boolean ended(ProcessHandle handle) { } /** Matched on the executable too: a shell whose command line mentions the socket is not a server. */ - private static boolean abandonedServer(ProcessHandle handle, Path root) { + private static Optional abandonedServer(ProcessHandle handle, Path root) { ProcessHandle.Info info = handle.info(); if (!info.command() .map(command -> Path.of(command).getFileName()) .map(Path::toString) .filter("tmux"::equals) .isPresent()) { - return false; + return Optional.empty(); } String[] argv = info.arguments().orElse(NO_ARGUMENTS); for (int index = 0; index + 1 < argv.length; index++) { if ("-S".equals(argv[index])) { - return ownerIsGone(Path.of(argv[index + 1]).toAbsolutePath().normalize(), root); + return abandonedDirectory( + Path.of(argv[index + 1]).toAbsolutePath().normalize(), root) + .map(directory -> new AbandonedServer(handle, directory)); } } - return false; + return Optional.empty(); } private static final String[] NO_ARGUMENTS = {}; /** A reused pid can only spare an abandoned server, never condemn a live one. */ - private static boolean ownerIsGone(Path socket, Path root) { + private static Optional abandonedDirectory(Path socket, Path root) { Path directory = socket.getParent(); if (directory == null || !socket.startsWith(root)) { - return false; + return Optional.empty(); } Matcher named = OWNER.matcher(directory.getFileName().toString()); if (!named.matches()) { // Something else's socket, or one from before this scheme. Not this sweep's to judge. - return false; + return Optional.empty(); + } + return ProcessHandle.of(Long.parseLong(named.group(1))).isEmpty() ? Optional.of(directory) : Optional.empty(); + } + + private record AbandonedServer(ProcessHandle process, Path directory) {} + + static void deleteTree(Path root) { + if (!Files.exists(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + // Best effort; a fixture directory is the operating system's to reclaim. + } + }); + } catch (IOException e) { + throw new UncheckedIOException("could not remove a tmux fixture directory", e); } - return ProcessHandle.of(Long.parseLong(named.group(1))).isEmpty(); } @Override @@ -214,10 +244,12 @@ synchronized void start() { try { // Once per JVM, before this run makes its first server: whatever a killed run left // behind is holding a pty and answering to a name this one might choose. + Path fixtureRoot = fixtureRoot(); + Files.createDirectories(fixtureRoot); if (SWEPT.compareAndSet(false, true)) { - reapAbandoned(Path.of(System.getProperty("java.io.tmpdir"))); + reapAbandoned(fixtureRoot); } - Path root = Files.createTempDirectory(PREFIX); + Path root = Files.createTempDirectory(fixtureRoot, PREFIX); directory = root; Path config = root.resolve("tmux.conf"); Files.writeString(config, ""); @@ -299,15 +331,19 @@ public synchronized void close() { } /** Exit is proved by asking, not assumed from a kill that may have raced. */ - private static boolean awaitExit(Server server) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(EXIT_MILLIS); + static boolean awaitExit(Server server) { + return awaitExit(server, EXIT_MILLIS); + } + + static boolean awaitExit(Server server, long timeoutMillis) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); while (System.nanoTime() < deadline) { try { if (!server.cmd(List.of("list-sessions"), PROBE).succeeded()) { return true; } } catch (RuntimeException e) { - return true; + return false; } try { Thread.sleep(25); @@ -318,22 +354,5 @@ private static boolean awaitExit(Server server) { } return false; } - - private static void deleteTree(Path root) { - if (!Files.exists(root)) { - return; - } - try (Stream paths = Files.walk(root)) { - paths.sorted(Comparator.reverseOrder()).forEach(path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - // Best effort; a fixture directory is the operating system's to reclaim. - } - }); - } catch (IOException e) { - throw new UncheckedIOException("could not remove a tmux fixture directory", e); - } - } } } diff --git a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/AbandonedServerTest.java b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/AbandonedServerTest.java index 6e3c1a2..e3d8c38 100644 --- a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/AbandonedServerTest.java +++ b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/AbandonedServerTest.java @@ -7,11 +7,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; /** * Teardown that survives its own process being killed. @@ -26,6 +25,11 @@ */ final class AbandonedServerTest { + private static Path testRoot() throws IOException { + Files.createDirectories(TmuxExtension.fixtureRoot()); + return Files.createTempDirectory(TmuxExtension.fixtureRoot(), "reaper-"); + } + private static Path socketFor(Path root, long owner) throws IOException { Path directory = Files.createDirectory(root.resolve("libtmux-" + owner + "-" + System.nanoTime())); return directory.resolve("s"); @@ -83,29 +87,55 @@ private static Optional serverOn(Path socket) { .findFirst(); } + private static void cleanup(Path root, Path socket) throws Exception { + Optional running = serverOn(socket); + if (running.isPresent()) { + ProcessHandle server = running.orElseThrow(); + server.destroy(); + try { + server.onExit().get(30, TimeUnit.SECONDS); + } catch (TimeoutException e) { + server.destroyForcibly(); + server.onExit().get(30, TimeUnit.SECONDS); + } + } + TmuxExtension.deleteTree(root); + } + @Test - void aServerWhoseOwnerIsGoneIsReaped(@TempDir Path root) throws Exception { + void aServerWhoseOwnerIsGoneIsReaped() throws Exception { + Path root = testRoot(); Path socket = socketFor(root, deadPid()); - startServer(socket); - assertTrue(alive(socket), "the fixture for this test must actually be running"); - - int reaped = TmuxExtension.reapAbandoned(root); - - assertEquals(1, reaped); - assertFalse(alive(socket), "a server nobody owns must not outlive the sweep"); + try { + startServer(socket); + assertTrue(alive(socket), "the fixture for this test must actually be running"); + + int reaped = TmuxExtension.reapAbandoned(root); + + assertEquals(1, reaped); + assertFalse(alive(socket), "a server nobody owns must not outlive the sweep"); + assertFalse(Files.exists(socket.getParent()), "the abandoned fixture directory was left behind"); + } finally { + cleanup(root, socket); + } } /** Asserted on the process, so a client's own startup cost cannot hide the window. */ @Test - void theSweepCountsServersThatEndedRatherThanSignalsItSent(@TempDir Path root) throws Exception { + void theSweepCountsServersThatEndedRatherThanSignalsItSent() throws Exception { + Path root = testRoot(); Path socket = socketFor(root, deadPid()); - startServer(socket); - ProcessHandle server = serverOn(socket).orElseThrow(() -> new AssertionError("no server to reap")); + try { + startServer(socket); + ProcessHandle server = serverOn(socket).orElseThrow(() -> new AssertionError("no server to reap")); - int reaped = TmuxExtension.reapAbandoned(root); + int reaped = TmuxExtension.reapAbandoned(root); - assertEquals(1, reaped); - assertFalse(server.isAlive(), "the sweep counted a server it had only asked to stop"); + assertEquals(1, reaped); + assertFalse(server.isAlive(), "the sweep counted a server it had only asked to stop"); + } finally { + cleanup(root, socket); + } } /** @@ -114,34 +144,36 @@ void theSweepCountsServersThatEndedRatherThanSignalsItSent(@TempDir Path root) t * would kill the servers of runs that are still using them. */ @Test - void aServerWhoseOwnerIsStillRunningIsLeftAlone(@TempDir Path root) throws Exception { + void aServerWhoseOwnerIsStillRunningIsLeftAlone() throws Exception { + Path root = testRoot(); Path socket = socketFor(root, ProcessHandle.current().pid()); - startServer(socket); + try { + startServer(socket); - int reaped = TmuxExtension.reapAbandoned(root); + int reaped = TmuxExtension.reapAbandoned(root); - assertEquals(0, reaped); - assertTrue(alive(socket), "this JVM is still running, so this server is still owned"); - - new ProcessBuilder(List.of(System.getProperty("libtmux.tmux", "tmux"), "-S", socket.toString(), "kill-server")) - .start() - .waitFor(30, TimeUnit.SECONDS); + assertEquals(0, reaped); + assertTrue(alive(socket), "this JVM is still running, so this server is still owned"); + } finally { + cleanup(root, socket); + } } /** A directory under the root that names no owner is not something this sweep may judge. */ @Test - void aSocketThatNamesNoOwnerIsLeftAlone(@TempDir Path root) throws Exception { + void aSocketThatNamesNoOwnerIsLeftAlone() throws Exception { + Path root = testRoot(); Path directory = Files.createDirectory(root.resolve("not-ours")); Path socket = directory.resolve("s"); - startServer(socket); - - int reaped = TmuxExtension.reapAbandoned(root); + try { + startServer(socket); - assertEquals(0, reaped); - assertTrue(alive(socket)); + int reaped = TmuxExtension.reapAbandoned(root); - new ProcessBuilder(List.of(System.getProperty("libtmux.tmux", "tmux"), "-S", socket.toString(), "kill-server")) - .start() - .waitFor(30, TimeUnit.SECONDS); + assertEquals(0, reaped); + assertTrue(alive(socket)); + } finally { + cleanup(root, socket); + } } } diff --git a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/TmuxExtensionTest.java b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/TmuxExtensionTest.java index a70b2b3..734d303 100644 --- a/libtmux-junit5/src/test/java/io/github/libtmux/junit5/TmuxExtensionTest.java +++ b/libtmux-junit5/src/test/java/io/github/libtmux/junit5/TmuxExtensionTest.java @@ -7,6 +7,12 @@ import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.TmuxTransport; +import io.github.libtmux.transport.TmuxTransportException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -28,6 +34,27 @@ final class TmuxExtensionTest { /** Sockets the nested tests were handed, so the outer case can check what became of them. */ static final List ISSUED = Collections.synchronizedList(new ArrayList<>()); + @Test + void aFailedExitProbeIsNotProofThatTheServerExited() { + TmuxTransport failedProbe = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + throw new TmuxTransportException("probe failed", DispatchOutcome.UNKNOWN, null); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(ServerConfig.builder().build(), failedProbe)) { + assertFalse(TmuxExtension.Fixture.awaitExit(server, 1)); + } + } + + @Test + void fixturesOwnTheirPortSpecificRootWithoutBuildConfiguration() { + assertEquals(Path.of("/tmp/libtmux-java-test"), TmuxExtension.fixtureRoot()); + } + @Test void aTestGetsItsOwnLiveServer() { ISSUED.clear(); From 2b8d8cd80f321c10d0aea19248b41ded56f9c51f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:07:53 -0500 Subject: [PATCH 06/77] Mcp(fix[protocol]): Bound server lifetime why: Watch notifications could race or grow without bound, attachment gaps lost invalidations, and malformed protocol input could leave the launcher alive after its session ended. what: - Serialize bounded invalidations and reconcile every session attachment - Track pane generations, retry outages with backoff, and hide watcher clients - Canonicalize resource input, discover live sockets, and couple launcher exit to protocol-session closure --- libtmux-mcp/README.md | 5 +- .../java/io/github/libtmux/mcp/Catalog.java | 16 +- .../io/github/libtmux/mcp/Connection.java | 73 +++- .../java/io/github/libtmux/mcp/Listings.java | 231 +++++----- .../main/java/io/github/libtmux/mcp/Main.java | 35 +- .../libtmux/mcp/NotificationBuffer.java | 39 ++ .../java/io/github/libtmux/mcp/Reading.java | 4 - .../libtmux/mcp/ResourceInvalidations.java | 210 +++++++++ .../java/io/github/libtmux/mcp/Resources.java | 52 ++- .../github/libtmux/mcp/ServerDiscovery.java | 286 +++++++++++++ .../io/github/libtmux/mcp/TmuxMcpServer.java | 178 +++++++- .../main/java/io/github/libtmux/mcp/Trim.java | 11 + .../main/java/io/github/libtmux/mcp/Uris.java | 125 ++++-- .../io/github/libtmux/mcp/WaitingForText.java | 12 +- .../github/libtmux/mcp/WatchAttachment.java | 192 +++++++++ .../java/io/github/libtmux/mcp/Watches.java | 401 ++++++++++++++---- .../java/io/github/libtmux/mcp/Watching.java | 5 + .../io/github/libtmux/mcp/CatalogTest.java | 8 + .../io/github/libtmux/mcp/ConnectionTest.java | 69 +++ .../github/libtmux/mcp/McpLauncherTest.java | 39 +- .../libtmux/mcp/NotificationBufferTest.java | 25 ++ .../mcp/ResourceInvalidationsTest.java | 289 +++++++++++++ .../libtmux/mcp/ServerDiscoveryTest.java | 373 ++++++++++++++++ .../github/libtmux/mcp/TmuxMcpServerTest.java | 42 ++ .../libtmux/mcp/ToolsAgainstTmuxTest.java | 52 +++ .../java/io/github/libtmux/mcp/TrimTest.java | 27 ++ .../java/io/github/libtmux/mcp/UrisTest.java | 48 +++ .../libtmux/mcp/WaitingForTextTest.java | 15 + .../io/github/libtmux/mcp/WatchesTest.java | 189 ++++++++- 29 files changed, 2698 insertions(+), 353 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/NotificationBuffer.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/ResourceInvalidations.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/ConnectionTest.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/NotificationBufferTest.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/ResourceInvalidationsTest.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerDiscoveryTest.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/TrimTest.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/UrisTest.java diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index e255fee..c33097d 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -29,8 +29,7 @@ stdout. | `--watch` | push notifications as tmux changes — see [Watching](#watching-instead-of-polling) | `LIBTMUX_SAFETY` and `LIBTMUX_WATCH` set the last two for an operator who cannot -edit the client's launch command. `LIBTMUX_MODE=control` reuses one tmux client -instead of starting a process per command. +edit the client's launch command. ### Claude Code @@ -188,7 +187,7 @@ once a second, and sends nothing while nothing changes — so a client subscribe to a pane spends nothing at all while it is idle. What arrives is `notifications/resources/updated` naming the resource that went -stale: `tmux://panes/%1/content` when that pane produces output, `tmux://sessions` +stale: `tmux://panes/%251/content` when pane `%1` produces output, `tmux://sessions` and `tmux://panes` when a window appears, closes, or is renamed. It is off by default because it is not free: watching means attaching a client, diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index 13918ec..4ee3f82 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -63,12 +63,13 @@ private static void discovery(List tools) { tools.add(ToolSpec.of( "tmux_list_servers", "List tmux servers", - "Lists every tmux server this user has running, by socket. Use it when the sessions you " - + "expected are not on this server: tmux keeps entirely separate servers per socket, and " - + "they cannot see each other.", + "Inspects a bounded set of this user's tmux sockets and reports whether each is running, " + + "unreachable, timed out, or could not be probed. Use it when the sessions you expected " + + "are not on this server: separate sockets cannot see each other. A truncated answer says " + + "the scan cap left directory entries uninspected.", Safety.READONLY, List.of(), - call -> Listings.servers(call.server(), call.server().config().binary()))); + call -> Listings.servers(call.server()))); tools.add(ToolSpec.of( "tmux_list_sessions", @@ -76,7 +77,7 @@ private static void discovery(List tools) { "Lists sessions on this server with the windows in each.", Safety.READONLY, List.of(), - call -> Listings.sessions(call.server()))); + call -> Listings.sessions(call.connection()))); tools.add(ToolSpec.of( "tmux_list_windows", @@ -221,11 +222,12 @@ private static void waiting(List tools) { tools.add(ToolSpec.of( "tmux_wait_for_channel", "Wait on a tmux channel", - "Blocks until something signals a tmux channel. This is the only wait that infers nothing " + "Consumes the next signal on a tmux channel, blocking until one exists. This is the only wait " + + "that infers nothing " + "from the screen: compose a command as 'mycommand; tmux wait-for -S mychannel' with " + "tmux_send_keys, then wait here. The answer says why the wait ended, because tmux " + "reports a server that died under a waiter as a successful wake.", - Safety.READONLY, + Safety.MUTATING, List.of( required("channel", "The channel name, which everything on this server shares."), seconds("timeout", "Seconds to wait before giving up.", 30), diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Connection.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Connection.java index 1e06009..dc53300 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Connection.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Connection.java @@ -3,24 +3,68 @@ import io.github.libtmux.Server; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Supplier; -/** - * What every tool, resource and prompt on this connection shares. - * - *

The caller's own pane is worked out once, when the connection is made, rather than on every - * call: finding it costs a tmux command, it cannot change while this process runs, and a guard that - * charges for itself on every call is one somebody will be tempted to remove. - * - * @param server the tmux server this connection acts on - * @param caller the pane this process runs in, when it runs in one on that server - * @param ceiling the most damage this server is configured to allow - */ -record Connection(Server server, Caller caller, Safety ceiling, Set ownClients) { +/** What every tool, resource and prompt on this connection shares. */ +final class Connection { + + private final Server server; + private final Caller caller; + private final Safety ceiling; + private final Set ownClients; + private final ReentrantReadWriteLock clientVisibility = new ReentrantReadWriteLock(true); + + Connection(Server server, Caller caller, Safety ceiling, Set ownClients) { + this.server = server; + this.caller = caller; + this.ceiling = ceiling; + this.ownClients = ownClients; + } static Connection to(Server server, Safety ceiling) { return new Connection(server, Caller.of(server), ceiling, ConcurrentHashMap.newKeySet()); } + Server server() { + return server; + } + + Caller caller() { + return caller; + } + + Safety ceiling() { + return ceiling; + } + + /** Runs a watcher-client transition without exposing its half-finished state to a listing. */ + T changeClients(Supplier change) { + return locked(clientVisibility.writeLock(), change); + } + + void changeClients(Runnable change) { + changeClients(() -> { + change.run(); + return null; + }); + } + + /** Reads tmux clients and the hidden-client set as one stable view. */ + T withStableClients(Supplier read) { + return locked(clientVisibility.readLock(), read); + } + + private static T locked(Lock lock, Supplier operation) { + lock.lock(); + try { + return operation.get(); + } finally { + lock.unlock(); + } + } + /** * Stops a client this server attached for its own purposes being reported as a person. * @@ -32,6 +76,11 @@ void hide(String clientName) { ownClients.add(clientName); } + /** Stops hiding a watcher client after that attachment ends. */ + void reveal(String clientName) { + ownClients.remove(clientName); + } + boolean isOurs(String clientName) { return ownClients.contains(clientName); } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java index 561aa0f..4ce4edd 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java @@ -2,20 +2,22 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.libtmux.Dimensions; +import io.github.libtmux.LibTmuxException; import io.github.libtmux.Pane; import io.github.libtmux.Server; -import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; import io.github.libtmux.Session; +import io.github.libtmux.SessionId; import io.github.libtmux.Window; import io.github.libtmux.jackson.FilterJson; import io.github.libtmux.jackson.LibTmuxModels; import io.github.libtmux.query.FilterExpr; -import java.io.IOException; -import java.nio.file.Files; +import io.github.libtmux.snapshot.ServerSnapshot; import java.nio.file.Path; -import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Stream; import org.jspecify.annotations.Nullable; @@ -91,20 +93,49 @@ record Whoami( record KnownServer( String socket, - boolean alive, + ServerDiscovery.State state, @Nullable Integer sessions, @Nullable String note) {} - record Servers(int count, List servers, String note) {} + record Servers( + int count, + List servers, + boolean truncated, + @Nullable String scanNote, + String note) {} static Sessions sessions(Server server) { - List summaries = server.sessions().stream() + return sessions(server, ignored -> false); + } + + /** Lists sessions without treating this connection's own control clients as people. */ + static Sessions sessions(Connection connection) { + return connection.withStableClients(() -> sessions(connection.server(), connection::isOurs)); + } + + static SessionSummary session(Connection connection, String name) { + return sessions(connection).sessions().stream() + .filter(session -> session.name().equals(name)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("no session named " + name)); + } + + private static Sessions sessions(Server server, Predicate hiddenClient) { + ServerSnapshot snapshot = server.snapshot(); + Set attached = new LinkedHashSet<>(); + snapshot.clients().stream() + .filter(client -> !hiddenClient.test(client.name())) + .flatMap(client -> client.session().stream()) + .forEach(attached::add); + List summaries = snapshot.sessions().stream() .map(session -> new SessionSummary( session.id().value(), session.name(), - session.attached(), - session.windows().size(), - session.windows().stream().map(Window::name).toList())) + attached.contains(session.id()), + snapshot.windowsOf(session.id()).size(), + snapshot.windowsOf(session.id()).stream() + .map(window -> window.name()) + .toList())) .toList(); return new Sessions(summaries.size(), summaries, emptiness(server, summaries.size(), "session")); } @@ -202,24 +233,28 @@ static List describe(List panes, Caller caller) { } static Clients clients(Call call) { - Server server = call.server(); - List summaries = server.clients().stream() - // A control client this server attached to watch for changes is not a person, and - // the whole point of this tool is answering whether a person is there. - .filter(client -> !call.connection().isOurs(client.name())) - .map(client -> new ClientSummary( - client.name(), - client.session().map(Session::name).orElse(null), - // The pane a person at this terminal is actually looking at, which is what - // "is anyone watching this" means in practice. - client.attachment() - .map(attachment -> attachment.activePane().id().value()) - .orElse(null))) - .toList(); - return new Clients( - summaries.size(), - summaries, - summaries.isEmpty() ? "Nothing is attached, so no person is watching these panes right now." : null); + return call.connection().withStableClients(() -> { + List summaries = call.server().clients().stream() + // A control client this server attached to watch for changes is not a person, + // and the whole point of this tool is answering whether a person is there. + .filter(client -> !call.connection().isOurs(client.name())) + .map(client -> new ClientSummary( + client.name(), + client.session().map(Session::name).orElse(null), + // The pane a person at this terminal is actually looking at, which is + // what "is anyone watching this" means in practice. + client.attachment() + .map(attachment -> + attachment.activePane().id().value()) + .orElse(null))) + .toList(); + return new Clients( + summaries.size(), + summaries, + summaries.isEmpty() + ? "Nothing is attached, so no person is watching these panes right now." + : null); + }); } /** @@ -230,34 +265,27 @@ static Clients clients(Call call) { * refuse to do that need to know which pane that is. */ static Whoami whoami(Server server, Caller caller, Safety ceiling) { - // Asked before anything else, including on a socket no server is listening on. Every other - // question here needs a server to answer it, so the answer to "is there one" comes first — - // the tool a model is told to call first must not fail at being told there is nothing there. - if (!server.isAlive()) { - return new Whoami( - server.identity().realm(), - server.identity().server(), - null, - null, - null, - 0, - 0, - 0, - ceiling.wireName(), - "No tmux server is running on the socket this was pointed at. Nothing here can act " - + "until one is, and tmux_new_session will start one. Call tmux_list_servers to see " - + "the servers that are running — the sessions you expected are probably on one of " - + "them, and a different socket cannot see them."); + ServerSnapshot snapshot; + try { + snapshot = server.snapshot(); + } catch (LibTmuxException failed) { + if (server.isAlive()) { + throw failed; + } + return absent(server, ceiling); + } + if (snapshot.serverPid().isEmpty()) { + return absent(server, ceiling); } return new Whoami( server.identity().realm(), server.identity().server(), socketOf(server), - server.version().toString(), + snapshot.serverVersion().orElseThrow().toString(), caller.pane().map(id -> id.value()).orElse(null), - server.sessions().size(), - server.windows().size(), - server.panes().size(), + snapshot.sessions().size(), + snapshot.windows().size(), + snapshot.panes().size(), ceiling.wireName(), caller.pane() .map(id -> "This MCP server runs in pane " + id.value() @@ -267,6 +295,23 @@ static Whoami whoami(Server server, Caller caller, Safety ceiling) { + "so no pane here is special.")); } + private static Whoami absent(Server server, Safety ceiling) { + return new Whoami( + server.identity().realm(), + server.identity().server(), + null, + null, + null, + 0, + 0, + 0, + ceiling.wireName(), + "No tmux server is running on the socket this was pointed at. Nothing here can act " + + "until one is, and tmux_new_session will start one. Call tmux_list_servers to see " + + "the servers that are running — the sessions you expected are probably on one of " + + "them, and a different socket cannot see them."); + } + private static @Nullable String socketOf(Server server) { try { List reported = @@ -277,78 +322,34 @@ static Whoami whoami(Server server, Caller caller, Safety ceiling) { } } - /** - * Every tmux server this user has, so a model pointed at the wrong one can find the right one. - * - *

tmux keeps its sockets in one directory per user, so the list is what is in that directory - * rather than anything this server was told. A socket file outlives the server that made it, so - * each is asked whether it answers rather than assumed to. - */ - static Servers servers(Server server, String binary) { - Path directory = socketDirectory(); - List found; - try (Stream entries = Files.list(directory)) { - found = entries.filter(Listings::isSocket) - .sorted(Comparator.comparing(Path::toString)) - // A directory of sockets is small, and each probe is a process; a bound keeps a - // pathological directory from turning one call into hundreds of them. - .limit(32) - .map(socket -> probe(socket, binary)) - .toList(); - } catch (IOException e) { - found = List.of(); - } + /** Shapes a bounded typed socket inventory for the protocol. */ + static Servers servers(Server server) { + Path currentSocket = currentSocket(server); + ServerDiscovery.Result discovery = + ServerDiscovery.system().discover(server.config().binary(), currentSocket); + List found = discovery.servers().stream() + .map(known -> new KnownServer(known.socket().toString(), known.state(), known.sessions(), known.note())) + .toList(); return new Servers( found.size(), found, + discovery.truncated(), + discovery.scanNote(), "Point another server at one of these with the --socket flag, or set LIBTMUX_SOCKET. " - + "This one is on " + socketOf(server) + "."); - } - - /** A unix socket is neither a file nor a directory, which is all "other" means here. */ - private static boolean isSocket(Path path) { - try { - return Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class) - .isOther(); - } catch (IOException e) { - return false; - } + + (currentSocket == null + ? "This connection did not name an exact socket path." + : "This connection names " + currentSocket + ".")); } - private static KnownServer probe(Path socket, String binary) { - Server candidate = null; - try { - candidate = Server.open(ServerConfig.builder() - .binary(binary) - .endpoint(ServerEndpoint.socketPath(socket)) - .build()); - List sessions = candidate.sessions(); - return new KnownServer(socket.toString(), true, sessions.size(), null); - } catch (RuntimeException e) { - return new KnownServer(socket.toString(), false, null, "not answering; the socket file is left over"); - } finally { - if (candidate != null) { - candidate.close(); - } - } - } - - /** tmux puts a user's sockets under {@code TMUX_TMPDIR}, falling back to {@code /tmp}. */ - private static Path socketDirectory() { - String configured = System.getenv("TMUX_TMPDIR"); - Path root = Path.of(configured == null || configured.isEmpty() ? "/tmp" : configured); - return root.resolve("tmux-" + uid()); - } - - private static String uid() { - try { - Process process = new ProcessBuilder("id", "-u").start(); - try (var reader = process.inputReader()) { - String line = reader.readLine(); - return line == null ? "0" : line.trim(); + private static @Nullable Path currentSocket(Server server) { + String live = socketOf(server); + if (live != null && !live.isBlank()) { + try { + return Path.of(live); + } catch (RuntimeException ignored) { + // Fall back to an explicitly configured path when tmux reported unusable text. } - } catch (IOException e) { - return "0"; } + return server.config().endpoint() instanceof ServerEndpoint.SocketPath socketPath ? socketPath.path() : null; } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java index 90cf6eb..30e7b7b 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java @@ -3,9 +3,6 @@ import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; -import java.io.FilterInputStream; -import java.io.IOException; -import java.io.InputStream; import java.nio.file.Path; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -66,44 +63,18 @@ public static void main(String[] args) { // A client that disconnects closes this end. Without noticing that, the process outlives the // client that launched it, and an MCP client leaves one behind every time it restarts. CountDownLatch disconnected = new CountDownLatch(1); - TmuxMcpServer.overStdio(server, new EndOfInputAware(System.in, disconnected::countDown), ceiling, watching); + var mcp = TmuxMcpServer.overStdio(server, System.in, ceiling, watching, disconnected::countDown); + Runtime.getRuntime().addShutdownHook(new Thread(mcp::close, "libtmux-mcp-protocol-shutdown")); try { disconnected.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + mcp.closeGracefully(); server.close(); System.exit(0); } - /** Wraps an input stream so end of input can be noticed by whoever is waiting for it. */ - private static final class EndOfInputAware extends FilterInputStream { - - private final Runnable onEnd; - - EndOfInputAware(InputStream in, Runnable onEnd) { - super(in); - this.onEnd = onEnd; - } - - @Override - public int read() throws IOException { - return ended(super.read()); - } - - @Override - public int read(byte[] buffer, int offset, int length) throws IOException { - return ended(super.read(buffer, offset, length)); - } - - private int ended(int result) { - if (result < 0) { - onEnd.run(); - } - return result; - } - } - static ServerConfig configure(List args) { ServerConfig.Builder config = ServerConfig.builder(); for (int index = 0; index < args.size(); index++) { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/NotificationBuffer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/NotificationBuffer.java new file mode 100644 index 0000000..e952864 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/NotificationBuffer.java @@ -0,0 +1,39 @@ +package io.github.libtmux.mcp; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** A bounded set that coalesces repeated resource updates while a client is slow. */ +final class NotificationBuffer { + + private final int capacity; + private final Set pending = new LinkedHashSet<>(); + + NotificationBuffer(int capacity) { + if (capacity < 1) { + throw new IllegalArgumentException("notification capacity is not positive"); + } + this.capacity = capacity; + } + + synchronized boolean offer(String uri) { + if (pending.contains(uri)) { + return true; + } + return pending.size() < capacity && pending.add(uri); + } + + synchronized Set drain() { + if (pending.isEmpty()) { + return Set.of(); + } + Set drained = Collections.unmodifiableSet(new LinkedHashSet<>(pending)); + pending.clear(); + return drained; + } + + synchronized int size() { + return pending.size(); + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java index ad46ca1..df4e0bc 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java @@ -73,10 +73,6 @@ static Captured capture(Call call) { static Since since(Call call) { Pane pane = Targets.pane(call.server(), call.string("pane_id")); Cursor from = call.maybe("cursor").map(Cursor::decode).orElse(null); - if (from != null && !from.paneId().equals(pane.id().value())) { - throw new IllegalArgumentException("that cursor belongs to pane " + from.paneId() + ", not " - + pane.id().value() + "; each pane has its own"); - } Watching.Fresh fresh = Watching.since(pane, from, Trim.lineBudget(call)); Trim.Trimmed trimmed = Trim.tail(fresh.lines(), Trim.lineBudget(call)); return new Since( diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ResourceInvalidations.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ResourceInvalidations.java new file mode 100644 index 0000000..8b34008 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ResourceInvalidations.java @@ -0,0 +1,210 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.PaneId; +import io.github.libtmux.SessionId; +import io.github.libtmux.snapshot.PaneState; +import io.github.libtmux.snapshot.ServerSnapshot; +import io.github.libtmux.snapshot.SessionState; +import io.github.libtmux.snapshot.WindowState; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +/** Computes exact resource invalidations without reading tmux or sending notifications. */ +final class ResourceInvalidations { + + private ResourceInvalidations() {} + + static Projection project(ServerSnapshot snapshot, Predicate hiddenClient) { + Objects.requireNonNull(snapshot, "snapshot"); + Objects.requireNonNull(hiddenClient, "hiddenClient"); + + Set attached = new LinkedHashSet<>(); + snapshot.clients().stream() + .filter(client -> !hiddenClient.test(client.name())) + .flatMap(client -> client.session().stream()) + .forEach(attached::add); + + List sessions = snapshot.sessions().stream() + .map(session -> session(snapshot, session, attached.contains(session.id()))) + .toList(); + Map sessionsById = new LinkedHashMap<>(); + sessions.forEach(session -> sessionsById.putIfAbsent(session.id(), session)); + + List panes = + snapshot.panes().stream().map(pane -> pane(snapshot, pane)).toList(); + Map panesById = new LinkedHashMap<>(); + for (PaneView pane : panes) { + panesById.putIfAbsent(pane.id(), pane); + } + + return new Projection( + new Counts( + snapshot.sessions().size(), + snapshot.windows().size(), + snapshot.panes().size()), + sessions, + sessionsById, + panes, + panesById); + } + + static Set between(Projection before, Projection after) { + Objects.requireNonNull(before, "before"); + Objects.requireNonNull(after, "after"); + Set changed = new LinkedHashSet<>(); + + if (!before.counts.equals(after.counts)) { + changed.add(Resources.SERVER_URI); + } + if (!before.sessions.equals(after.sessions)) { + changed.add(Resources.SESSIONS_URI); + } + for (SessionId id : union(before.sessionsById.keySet(), after.sessionsById.keySet())) { + SessionView oldSession = before.sessionsById.get(id); + SessionView newSession = after.sessionsById.get(id); + if (!Objects.equals(oldSession, newSession)) { + if (oldSession != null) { + changed.add(Resources.sessionUri(oldSession.name())); + } + if (newSession != null) { + changed.add(Resources.sessionUri(newSession.name())); + } + } + } + + if (!before.panes.equals(after.panes)) { + changed.add(Resources.PANES_URI); + } + for (PaneId id : union(before.panesById.keySet(), after.panesById.keySet())) { + PaneView oldPane = before.panesById.get(id); + PaneView newPane = after.panesById.get(id); + if (!Objects.equals(oldPane, newPane)) { + changed.add(Resources.paneUri(id)); + } + if (oldPane == null + || newPane == null + || !oldPane.size().equals(newPane.size()) + || oldPane.pid() != newPane.pid()) { + changed.add(Resources.paneContentUri(id)); + } + } + return immutable(changed); + } + + static Set allKnown(Projection... projections) { + Set changed = new LinkedHashSet<>(); + changed.add(Resources.SERVER_URI); + changed.add(Resources.SESSIONS_URI); + changed.add(Resources.PANES_URI); + for (Projection projection : projections) { + Objects.requireNonNull(projection, "projection"); + projection.sessionsById.values().stream() + .map(SessionView::name) + .map(Resources::sessionUri) + .forEach(changed::add); + for (PaneId pane : projection.panesById.keySet()) { + changed.add(Resources.paneUri(pane)); + changed.add(Resources.paneContentUri(pane)); + } + } + return immutable(changed); + } + + static Set output(PaneId pane) { + return Set.of(Resources.paneContentUri(Objects.requireNonNull(pane, "pane"))); + } + + static Set droppedOutput(Projection projection) { + Objects.requireNonNull(projection, "projection"); + Set changed = new LinkedHashSet<>(); + projection.panesById.keySet().stream().map(Resources::paneContentUri).forEach(changed::add); + return immutable(changed); + } + + private static SessionView session(ServerSnapshot snapshot, SessionState session, boolean attached) { + List windows = + snapshot.windowsOf(session.id()).stream().map(WindowState::name).toList(); + return new SessionView(session.id(), session.name(), attached, windows.size(), windows); + } + + private static PaneView pane(ServerSnapshot snapshot, PaneState pane) { + String session = snapshot.session(pane.context().session()) + .orElseThrow(() -> new IllegalArgumentException("pane refers to an absent session")) + .name(); + String window = snapshot.window(pane.context()) + .orElseThrow(() -> new IllegalArgumentException("pane refers to an absent window")) + .name(); + return new PaneView( + pane.id(), + session, + window, + pane.context().window().value(), + pane.currentCommand(), + pane.currentPath().toString(), + pane.size().toString(), + pane.pid(), + pane.active()); + } + + private static Set union(Set before, Set after) { + Set union = new LinkedHashSet<>(before); + union.addAll(after); + return union; + } + + private static Set immutable(Set values) { + return values.isEmpty() ? Set.of() : Collections.unmodifiableSet(values); + } + + static final class Projection { + + private final Counts counts; + private final List sessions; + private final Map sessionsById; + private final List panes; + private final Map panesById; + + private Projection( + Counts counts, + List sessions, + Map sessionsById, + List panes, + Map panesById) { + this.counts = counts; + this.sessions = List.copyOf(sessions); + this.sessionsById = unmodifiableMap(sessionsById); + this.panes = List.copyOf(panes); + this.panesById = unmodifiableMap(panesById); + } + } + + private static Map unmodifiableMap(Map values) { + return Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + private record Counts(int sessions, int windows, int panes) {} + + private record SessionView(SessionId id, String name, boolean attached, int windows, List windowNames) { + + private SessionView { + windowNames = List.copyOf(windowNames); + } + } + + private record PaneView( + PaneId id, + String session, + String window, + String windowId, + String command, + String path, + String size, + long pid, + boolean active) {} +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java index 54ae9bd..97edb3e 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.databind.ObjectMapper; import io.github.libtmux.Pane; +import io.github.libtmux.PaneId; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.spec.McpSchema; import java.util.List; @@ -12,7 +13,7 @@ * The same state, addressable rather than asked for. * *

A tool is a verb a model has to choose. A resource is a noun a client can hold: it can attach - * {@code tmux://panes/%1/content} to a conversation, refresh it, and show it to a person, none of + * {@code tmux://panes/%251/content} to a conversation, refresh it, and show it to a person, none of * which spends a tool call or a model's decision. Declaring only tools gives that up. * *

The templated ones carry the id in the URI, so a client that has listed panes once can address @@ -31,6 +32,12 @@ final class Resources { */ private static final String TEXT_MIME = "text/plain"; + static final String SERVER_URI = "tmux://server"; + + static final String SESSIONS_URI = "tmux://sessions"; + + static final String PANES_URI = "tmux://panes"; + static final String PANE_TEMPLATE = "tmux://panes/{pane_id}"; static final String PANE_CONTENT_TEMPLATE = "tmux://panes/{pane_id}/content"; @@ -39,44 +46,47 @@ final class Resources { private Resources() {} + static String sessionUri(String name) { + return SESSIONS_URI + "/" + Uris.segment(name); + } + + static String paneUri(PaneId pane) { + return PANES_URI + "/" + Uris.segment(pane.value()); + } + + static String paneContentUri(PaneId pane) { + return paneUri(pane) + "/content"; + } + static List fixed(Connection connection) { return List.of( resource( - "tmux://server", + SERVER_URI, "This tmux server", "Which server this connection acts on, how much is on it, and which pane this " + "conversation is coming through.", () -> Listings.whoami(connection.server(), connection.caller(), connection.ceiling())), resource( - "tmux://sessions", + SESSIONS_URI, "All sessions", "Every session on this server, with the windows in each.", - () -> Listings.sessions(connection.server())), + () -> Listings.sessions(connection)), resource( - "tmux://panes", + PANES_URI, "All panes", "Every pane on this server, with the id other tools take as a target.", - () -> new Listings.Panes( - connection.server().panes().size(), - Listings.describe(connection.server().panes(), connection.caller()), - null))); + () -> { + List panes = connection.server().panes(); + return new Listings.Panes( + panes.size(), Listings.describe(panes, connection.caller()), null); + })); } static List templated(Connection connection) { return List.of( jsonTemplate(SESSION_TEMPLATE, "One session", "A session and the windows in it.", values -> { - var found = Targets.session(connection.server(), values.get(0)); - return new Listings.Sessions( - 1, - List.of(new Listings.SessionSummary( - found.id().value(), - found.name(), - found.attached(), - found.windows().size(), - found.windows().stream() - .map(window -> window.name()) - .toList())), - null); + var found = Listings.session(connection, values.get(0)); + return new Listings.Sessions(1, List.of(found), null); }), jsonTemplate( PANE_TEMPLATE, diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java new file mode 100644 index 0000000..e9bf183 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java @@ -0,0 +1,286 @@ +package io.github.libtmux.mcp; + +import com.sun.security.auth.module.UnixSystem; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTimeoutException; +import io.github.libtmux.transport.TmuxTransportException; +import java.io.IOException; +import java.nio.file.DirectoryIteratorException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.jspecify.annotations.Nullable; + +/** Finds tmux sockets without constructing a server. */ +final class ServerDiscovery { + + private static final int DEFAULT_CANDIDATE_LIMIT = 32; + private static final int DEFAULT_SCAN_LIMIT = 128; + private static final int DEFAULT_CONCURRENCY = 4; + private static final Duration DEFAULT_PROBE_TIMEOUT = Duration.ofMillis(1_500); + private static final int UNIX_FILE_TYPE_MASK = 0170000; + private static final int UNIX_SOCKET_TYPE = 0140000; + + private final Path socketRoot; + private final UidResolver uidResolver; + private final int candidateLimit; + private final int scanLimit; + private final int maxConcurrency; + private final Duration probeTimeout; + + ServerDiscovery( + Path socketRoot, + UidResolver uidResolver, + int candidateLimit, + int scanLimit, + int maxConcurrency, + Duration probeTimeout) { + if (candidateLimit < 1) { + throw new IllegalArgumentException("candidateLimit is not positive"); + } + if (scanLimit < candidateLimit) { + throw new IllegalArgumentException("scanLimit is below candidateLimit"); + } + if (maxConcurrency < 1) { + throw new IllegalArgumentException("maxConcurrency is not positive"); + } + if (probeTimeout.isZero() || probeTimeout.isNegative()) { + throw new IllegalArgumentException("probeTimeout is not positive"); + } + this.socketRoot = socketRoot.toAbsolutePath().normalize(); + this.uidResolver = uidResolver; + this.candidateLimit = candidateLimit; + this.scanLimit = scanLimit; + this.maxConcurrency = maxConcurrency; + this.probeTimeout = probeTimeout; + } + + static ServerDiscovery system() { + String configured = System.getenv("TMUX_TMPDIR"); + Path root = Path.of(configured == null || configured.isEmpty() ? "/tmp" : configured); + return new ServerDiscovery( + root, + ServerDiscovery::unixUid, + DEFAULT_CANDIDATE_LIMIT, + DEFAULT_SCAN_LIMIT, + DEFAULT_CONCURRENCY, + DEFAULT_PROBE_TIMEOUT); + } + + Result discover(String binary, @Nullable Path currentSocket) { + Scan scan = scan(currentSocket); + if (scan.candidates().isEmpty()) { + return new Result(List.of(), scan.truncated(), scan.note()); + } + + List found = new ArrayList<>(scan.candidates().size()); + try (ProcessTransport transport = new ProcessTransport(maxConcurrency); + ExecutorService probes = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = scan.candidates().stream() + .map(socket -> probes.submit(() -> probe(transport, binary, socket))) + .toList(); + boolean interrupted = false; + for (int index = 0; index < futures.size(); index++) { + Future future = futures.get(index); + if (interrupted) { + future.cancel(true); + found.add(notProbed(scan.candidates().get(index), "discovery was interrupted")); + continue; + } + try { + found.add(future.get()); + } catch (InterruptedException e) { + interrupted = true; + future.cancel(true); + found.add(notProbed(scan.candidates().get(index), "discovery was interrupted")); + } catch (ExecutionException e) { + found.add(notProbed(scan.candidates().get(index), "tmux could not be probed")); + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + return new Result(found, scan.truncated(), scan.note()); + } + + private Scan scan(@Nullable Path currentSocket) { + LinkedHashSet candidates = new LinkedHashSet<>(); + Path current = + currentSocket == null ? null : currentSocket.toAbsolutePath().normalize(); + if (current != null) { + candidates.add(current); + } + + long uid; + try { + uid = uidResolver.currentUid(); + if (uid < 0) { + throw new IOException("negative Unix UID"); + } + } catch (IOException | RuntimeException e) { + return scanResult( + candidates, + current, + false, + "The current Unix user could not be identified; standard sockets were not scanned."); + } + + Path directory = socketRoot.resolve("tmux-" + uid); + try { + BasicFileAttributes directoryAttributes = + Files.readAttributes(directory, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!directoryAttributes.isDirectory()) { + return scanResult( + candidates, + current, + false, + "The standard socket path is not a directory, so it was not scanned."); + } + long owner = ((Number) Files.getAttribute(directory, "unix:uid", LinkOption.NOFOLLOW_LINKS)).longValue(); + int mode = ((Number) Files.getAttribute(directory, "unix:mode", LinkOption.NOFOLLOW_LINKS)).intValue(); + if (owner != uid || (mode & 07) != 0) { + return scanResult( + candidates, + current, + false, + "The standard socket directory has unsafe ownership or permissions; it was not scanned."); + } + } catch (NoSuchFileException e) { + return scanResult(candidates, current, false, null); + } catch (IOException | RuntimeException e) { + return scanResult(candidates, current, false, "The standard socket directory could not be read."); + } + + boolean truncated = false; + int inspected = 0; + try (DirectoryStream entries = Files.newDirectoryStream(directory)) { + var iterator = entries.iterator(); + while (iterator.hasNext()) { + if (inspected == scanLimit) { + truncated = true; + break; + } + Path entry = iterator.next().toAbsolutePath().normalize(); + inspected++; + if (!isSocket(entry) || candidates.contains(entry)) { + continue; + } + candidates.add(entry); + } + } catch (IOException | DirectoryIteratorException | SecurityException e) { + return scanResult( + candidates, current, truncated, "The standard socket directory could not be read completely."); + } + return scanResult(candidates, current, truncated, null); + } + + private Scan scanResult(Set candidates, @Nullable Path current, boolean truncated, @Nullable String note) { + int scannedLimit = candidateLimit - (current == null ? 0 : 1); + List bounded = new ArrayList<>(candidateLimit); + candidates.stream() + .filter(candidate -> !candidate.equals(current)) + .sorted(Comparator.comparing(Path::toString)) + .limit(scannedLimit) + .forEach(bounded::add); + if (current != null) { + bounded.add(current); + bounded.sort(Comparator.comparing(Path::toString)); + } + return new Scan(bounded, truncated || candidates.size() > candidateLimit, note); + } + + private static boolean isSocket(Path path) { + try { + int mode = (int) Files.getAttribute(path, "unix:mode", LinkOption.NOFOLLOW_LINKS); + return (mode & UNIX_FILE_TYPE_MASK) == UNIX_SOCKET_TYPE; + } catch (IOException | RuntimeException e) { + return false; + } + } + + private KnownServer probe(ProcessTransport transport, String binary, Path socket) { + try { + CommandResult result = transport.execute(new CommandRequest( + List.of(binary, "-S", socket.toString()), + List.of("list-sessions", "-F", "#{session_id}"), + probeTimeout)); + if (result.succeeded()) { + return new KnownServer(socket, State.RUNNING, result.stdout().size(), null); + } + return new KnownServer(socket, State.UNREACHABLE, null, "tmux did not answer on this socket"); + } catch (TmuxTimeoutException e) { + if (e.outcome() == DispatchOutcome.NOT_DISPATCHED) { + return notProbed(socket, "tmux could not be started before the probe deadline"); + } + return new KnownServer(socket, State.TIMED_OUT, null, "tmux did not answer before the probe deadline"); + } catch (TmuxTransportException e) { + if (e.outcome() == DispatchOutcome.NOT_DISPATCHED) { + return notProbed(socket, "tmux could not be started before the probe deadline"); + } + return new KnownServer(socket, State.UNREACHABLE, null, "tmux could not be probed"); + } catch (RuntimeException e) { + return notProbed(socket, "tmux could not be started"); + } + } + + private static KnownServer notProbed(Path socket, String note) { + return new KnownServer(socket, State.NOT_PROBED, null, note); + } + + private static long unixUid() throws IOException { + try { + return new UnixSystem().getUid(); + } catch (RuntimeException | LinkageError e) { + throw new IOException("could not read Unix UID", e); + } + } + + enum State { + RUNNING, + UNREACHABLE, + TIMED_OUT, + NOT_PROBED + } + + record KnownServer( + Path socket, + State state, + @Nullable Integer sessions, + @Nullable String note) {} + + record Result( + List servers, + boolean truncated, + @Nullable String scanNote) { + Result { + servers = List.copyOf(servers); + } + } + + @FunctionalInterface + interface UidResolver { + long currentUid() throws IOException; + } + + private record Scan( + List candidates, + boolean truncated, + @Nullable String note) {} +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index c6ac5e8..ac5c7c1 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -3,17 +3,22 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.libtmux.LibTmuxException; import io.github.libtmux.Server; +import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransport; import io.modelcontextprotocol.spec.McpServerTransportProvider; import java.io.InputStream; import java.time.Duration; +import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; /** * Exposes a tmux server to a model over the Model Context Protocol. @@ -67,11 +72,18 @@ public static McpSyncServer overStdio(Server server) { * @param watching whether to attach a control client and push notifications as tmux changes */ public static McpSyncServer overStdio(Server server, InputStream in, Safety ceiling, boolean watching) { + return overStdio(server, in, ceiling, watching, () -> {}); + } + + static McpSyncServer overStdio( + Server server, InputStream in, Safety ceiling, boolean watching, Runnable onSessionEnd) { return serving( server, ceiling, watching, - new StdioServerTransportProvider(new JacksonMcpJsonMapper(new ObjectMapper()), in, System.out)); + new SessionEndedProvider( + new StdioServerTransportProvider(new JacksonMcpJsonMapper(new ObjectMapper()), in, System.out), + onSessionEnd)); } /** Serves a tmux server over a caller-supplied transport. */ @@ -83,16 +95,37 @@ public static McpSyncServer serving(Server server, Safety ceiling, McpServerTran public static McpSyncServer serving( Server server, Safety ceiling, boolean watching, McpServerTransportProvider transport) { Connection connection = Connection.to(server, ceiling); + if (watching) { + Watches watches = Watches.prepare(connection); + @Nullable WatchedMcpServer owned = null; + try { + McpSyncServer built = build(connection, true, transport); + owned = new WatchedMcpServer(built, watches); + watches.start(new McpNotifier(owned)); + return owned; + } catch (RuntimeException | Error e) { + if (owned == null) { + watches.close(); + } else { + owned.close(); + } + throw e; + } + } + return build(connection, false, transport); + } + + private static McpSyncServer build(Connection connection, boolean watching, McpServerTransportProvider transport) { var specification = McpServer.sync(transport) .serverInfo("libtmux", version()) - .instructions(Instructions.forServer(ceiling, watching)) + .instructions(Instructions.forServer(connection.ceiling(), watching)) .requestTimeout(REQUEST_TIMEOUT) .capabilities(McpSchema.ServerCapabilities.builder() .tools(true) // Subscription is offered only when something is actually watching tmux. // Advertising it otherwise invites a client to subscribe and wait forever for // an update nothing will ever send. - .resources(watching, true) + .resources(watching, false) .prompts(false) .completions() .logging() @@ -102,29 +135,138 @@ public static McpSyncServer serving( .prompts(Prompts.all()) .completions(Completions.all(connection)); - for (ToolSpec tool : Catalog.offered(ceiling).values()) { + for (ToolSpec tool : Catalog.offered(connection.ceiling()).values()) { specification = specification.toolCall( tool.describe(), (exchange, request) -> answer(connection, tool, exchange, request)); } - McpSyncServer mcp = specification.build(); - if (watching) { - // Started after the server exists, because a notification has nowhere to go before that. - Watches.Notifier notifier = new Watches.Notifier() { - @Override - public void updated(String uri) { - mcp.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(uri)); + return specification.build(); + } + + /** Sends watcher output only after the owned MCP server exists. */ + private record McpNotifier(McpSyncServer target) implements Watches.Notifier { + + @Override + public void updated(String uri) { + target.notifyResourcesUpdated(new McpSchema.ResourcesUpdatedNotification(uri)); + } + } + + /** Couples the watcher lifecycle to the MCP server lifecycle for embedded callers. */ + private static final class WatchedMcpServer extends McpSyncServer { + + private final Watches watches; + private final AtomicBoolean closed = new AtomicBoolean(); + + WatchedMcpServer(McpSyncServer delegate, Watches watches) { + super(delegate.getAsyncServer()); + this.watches = watches; + } + + @Override + public void closeGracefully() { + if (closed.compareAndSet(false, true)) { + try { + watches.close(); + } finally { + super.closeGracefully(); + } + } + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + try { + watches.close(); + } finally { + super.close(); } + } + } + } + + /** Makes the SDK's actual protocol-session lifetime observable to a stdio launcher. */ + private static final class SessionEndedProvider implements McpServerTransportProvider { + + private final McpServerTransportProvider delegate; + private final Runnable ended; - @Override - public void listChanged() { - mcp.notifyResourcesListChanged(); + SessionEndedProvider(McpServerTransportProvider delegate, Runnable ended) { + this.delegate = delegate; + AtomicBoolean signalled = new AtomicBoolean(); + this.ended = () -> { + if (signalled.compareAndSet(false, true)) { + ended.run(); } }; - Watches.start(connection, notifier) - .ifPresent(watches -> - Runtime.getRuntime().addShutdownHook(new Thread(watches::close, "libtmux-mcp-watches"))); } - return mcp; + + @Override + public void setSessionFactory(io.modelcontextprotocol.spec.McpServerSession.Factory factory) { + delegate.setSessionFactory(transport -> factory.create(new SessionEndedTransport(transport, ended))); + } + + @Override + public Mono notifyClients(String method, Object params) { + return delegate.notifyClients(method, params); + } + + @Override + public Mono notifyClient(String sessionId, String method, Object params) { + return delegate.notifyClient(sessionId, method, params); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully().doFinally(ignored -> ended.run()); + } + + @Override + public void close() { + try { + delegate.close(); + } finally { + ended.run(); + } + } + + @Override + public List protocolVersions() { + return delegate.protocolVersions(); + } + } + + /** Signals both graceful and immediate session termination without changing transport behavior. */ + private record SessionEndedTransport(McpServerTransport delegate, Runnable ended) implements McpServerTransport { + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return delegate.sendMessage(message); + } + + @Override + public T unmarshalFrom(Object value, TypeRef type) { + return delegate.unmarshalFrom(value, type); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully().doFinally(ignored -> ended.run()); + } + + @Override + public void close() { + try { + delegate.close(); + } finally { + ended.run(); + } + } + + @Override + public List protocolVersions() { + return delegate.protocolVersions(); + } } /** diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Trim.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Trim.java index e1c2cc7..bc12b16 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Trim.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Trim.java @@ -1,5 +1,6 @@ package io.github.libtmux.mcp; +import java.util.ArrayList; import java.util.List; /** @@ -69,6 +70,16 @@ static Trimmed tail(List lines, int limit) { return new Trimmed(List.copyOf(kept), dropped); } + /** Adds new lines while retaining only the bounded tail and a cumulative drop count. */ + static Trimmed append(Trimmed retained, List fresh, int limit) { + List combined = new ArrayList<>(retained.lines().size() + fresh.size()); + combined.addAll(retained.lines()); + combined.addAll(fresh); + Trimmed next = tail(combined, limit); + long dropped = (long) retained.dropped() + next.dropped(); + return new Trimmed(next.lines(), (int) Math.min(Integer.MAX_VALUE, dropped)); + } + /** The line budget a caller asked for, or the default when it asked for nothing. */ static int lineBudget(Call call) { return Math.clamp(call.integer("max_lines", DEFAULT_LINES), 1, MAX_LINES); diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Uris.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Uris.java index d733c92..dd24cc8 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Uris.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Uris.java @@ -1,17 +1,24 @@ package io.github.libtmux.mcp; -import java.util.ArrayList; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Objects; /** * Reads the values out of a templated resource URI. * - *

Only the one shape MCP resource templates use here — literal text with {@code {name}} standing - * for one path segment. A general RFC 6570 implementation would be more than any URI in this server - * needs, and every extra form it accepted would be one nothing produces. + *

Only the one shape MCP resource templates use here: a prefix, one {@code {name}} path segment, + * and an optional suffix. A general RFC 6570 implementation would accept forms this server never + * produces. */ final class Uris { + private static final char[] HEX = "0123456789ABCDEF".toCharArray(); + private Uris() {} /** @@ -20,37 +27,91 @@ private Uris() {} * @throws IllegalArgumentException if the URI does not fit the template */ static List values(String template, String uri) { - List values = new ArrayList<>(); - int templateAt = 0; - int uriAt = 0; - while (templateAt < template.length()) { - int open = template.indexOf('{', templateAt); - if (open < 0) { - break; - } - String literal = template.substring(templateAt, open); - if (!uri.startsWith(literal, uriAt)) { - throw mismatch(template, uri); - } - uriAt += literal.length(); - int close = template.indexOf('}', open); - if (close < 0) { - throw new IllegalStateException("a resource template is missing a closing brace: " + template); + int open = template.indexOf('{'); + int close = open < 0 ? -1 : template.indexOf('}', open + 1); + if (open < 0 + || close < 0 + || close == open + 1 + || template.indexOf('{', close + 1) >= 0 + || template.indexOf('}', close + 1) >= 0) { + throw new IllegalStateException("resource template must contain one named segment: " + template); + } + + String before = template.substring(0, open); + String after = template.substring(close + 1); + if (!uri.startsWith(before) || !uri.endsWith(after)) { + throw mismatch(template, uri); + } + int end = uri.length() - after.length(); + if (end <= before.length()) { + throw mismatch(template, uri); + } + String encoded = uri.substring(before.length(), end); + if (encoded.indexOf('/') >= 0) { + throw mismatch(template, uri); + } + String value = decodeSegment(encoded, template, uri); + if (!encoded.equals(segment(value))) { + throw mismatch(template, uri); + } + return List.of(value); + } + + /** Encodes one value for the simple path-segment expansion used by the resource templates. */ + static String segment(String value) { + Objects.requireNonNull(value, "value"); + StringBuilder encoded = new StringBuilder(value.length()); + for (byte octet : value.getBytes(StandardCharsets.UTF_8)) { + int unsigned = Byte.toUnsignedInt(octet); + if (unreserved(unsigned)) { + encoded.append((char) unsigned); + } else { + encoded.append('%').append(HEX[unsigned >>> 4]).append(HEX[unsigned & 0x0f]); } - // A variable stands for one segment, so it ends where the next literal begins — or at the - // next slash when the template ends with it. - String after = template.substring(close + 1); - int end = after.isEmpty() ? uri.length() : uri.indexOf(after, uriAt); - if (end < 0) { - throw mismatch(template, uri); + } + return encoded.toString(); + } + + private static String decodeSegment(String encoded, String template, String uri) { + ByteArrayOutputStream decoded = new ByteArrayOutputStream(encoded.length()); + for (int index = 0; index < encoded.length(); ) { + int codePoint = encoded.codePointAt(index); + if (codePoint == '%') { + if (index + 2 >= encoded.length()) { + throw mismatch(template, uri); + } + int high = Character.digit(encoded.charAt(index + 1), 16); + int low = Character.digit(encoded.charAt(index + 2), 16); + if (high < 0 || low < 0) { + throw mismatch(template, uri); + } + decoded.write((high << 4) | low); + index += 3; + } else { + decoded.writeBytes(new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8)); + index += Character.charCount(codePoint); } - // Taken literally, never percent-decoded: a pane id starts with '%', which is the escape - // character itself, so decoding turns tmux://panes/%0 into a malformed-escape error. - values.add(uri.substring(uriAt, end)); - uriAt = end; - templateAt = close + 1; } - return List.copyOf(values); + try { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(decoded.toByteArray())) + .toString(); + } catch (CharacterCodingException e) { + throw mismatch(template, uri); + } + } + + private static boolean unreserved(int octet) { + return (octet >= 'a' && octet <= 'z') + || (octet >= 'A' && octet <= 'Z') + || (octet >= '0' && octet <= '9') + || octet == '-' + || octet == '.' + || octet == '_' + || octet == '~'; } private static IllegalArgumentException mismatch(String template, String uri) { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java index 6e5c040..d6d7aac 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java @@ -2,7 +2,6 @@ import io.github.libtmux.Pane; import java.time.Duration; -import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -57,7 +56,7 @@ static Waited waitFor(Call call) { Cursor cursor = call.maybe("cursor") .map(Cursor::decode) .orElseGet(() -> Watching.from(pane).cursor()); - List seen = new ArrayList<>(); + Trim.Trimmed retained = new Trim.Trimmed(List.of(), 0); long started = System.nanoTime(); long deadline = started + timeout.toNanos(); @@ -68,7 +67,7 @@ static Waited waitFor(Call call) { while (true) { Watching.Fresh fresh = Watching.since(pane, cursor, budget); cursor = fresh.cursor(); - seen.addAll(fresh.lines()); + retained = Trim.append(retained, fresh.lines(), budget); // Failure first: a build that has already printed "error:" is not going to print // "Listening on", and the wait that notices is the one that returns in seconds. @@ -110,15 +109,14 @@ static Waited waitFor(Call call) { outcome = "SERVER_GONE"; } double seconds = (System.nanoTime() - started) / 1_000_000_000.0; - Trim.Trimmed trimmed = Trim.tail(seen, Trim.lineBudget(call)); return new Waited( pane.id().value(), outcome, hit == null ? null : hit.source(), hitLine, - trimmed.lines(), - trimmed.truncated(), - trimmed.dropped(), + retained.lines(), + retained.truncated(), + retained.dropped(), cursor.encode(), Math.round(seconds * 100) / 100.0, Waits.asSeconds(timeout), diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java new file mode 100644 index 0000000..e60d977 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java @@ -0,0 +1,192 @@ +package io.github.libtmux.mcp; + +import io.github.libtmux.SessionId; +import io.github.libtmux.control.ControlClient; +import io.github.libtmux.control.ControlEvent; +import io.github.libtmux.control.EventSubscription; +import io.github.libtmux.control.PaneOutput; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jspecify.annotations.Nullable; + +/** Owns the control client and loss-aware subscription drains for one tmux session. */ +final class WatchAttachment implements AutoCloseable { + + private static final String PANE_STATE = String.join( + ",", "#{pane_current_command}", "#{pane_current_path}", "#{pane_width}x#{pane_height}", "#{pane_active}"); + private static final int EVENT_CAPACITY = 128; + private static final long JOIN_MILLIS = 5_000; + + private final Watches owner; + private final Connection connection; + private final SessionId session; + private final ControlClient client; + private final EventSubscription output; + private final EventSubscription events; + private final @Nullable String clientName; + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final Thread outputConsumer; + private final Thread eventConsumer; + + private WatchAttachment( + Watches owner, + Connection connection, + SessionId session, + ControlClient client, + EventSubscription output, + EventSubscription events, + @Nullable String clientName) { + this.owner = owner; + this.connection = connection; + this.session = session; + this.client = client; + this.output = output; + this.events = events; + this.clientName = clientName; + this.outputConsumer = Thread.ofVirtual().unstarted(this::consumeOutput); + this.outputConsumer.setName("libtmux-mcp-output-" + session.value()); + this.eventConsumer = Thread.ofVirtual().unstarted(this::consumeEvents); + this.eventConsumer.setName("libtmux-mcp-events-" + session.value()); + } + + static WatchAttachment open(Watches owner, Connection connection, SessionId session) { + return connection.changeClients(() -> openWhileHidden(owner, connection, session)); + } + + private static WatchAttachment openWhileHidden(Watches owner, Connection connection, SessionId session) { + ControlClient client = ControlClient.attach( + connection.server().config(), + session, + connection.server().config().defaultTimeout()); + String name = null; + try { + name = client.send("display-message", "-p", "#{client_name}").lines().stream() + .filter(value -> !value.isBlank()) + .findFirst() + .orElse(null); + if (name != null) { + connection.hide(name); + } + EventSubscription output = client.subscribeOutput(EVENT_CAPACITY); + EventSubscription events = client.subscribeEvents(EVENT_CAPACITY); + if (!client.watch("state", "%*", PANE_STATE).succeeded()) { + throw new IllegalStateException("tmux refused the pane-state watch"); + } + return new WatchAttachment(owner, connection, session, client, output, events, name); + } catch (RuntimeException e) { + if (name != null) { + connection.reveal(name); + } + client.close(); + throw e; + } + } + + SessionId session() { + return session; + } + + void start() { + if (started.compareAndSet(false, true)) { + outputConsumer.start(); + eventConsumer.start(); + } + } + + boolean isAlive() { + return !closed.get() && client.isAlive(); + } + + private void consumeOutput() { + long dropped = 0; + try { + while (!closed.get()) { + Optional next = output.next(); + long nowDropped = output.droppedCount(); + if (nowDropped != dropped) { + dropped = nowDropped; + owner.outputDropped(); + } + if (next.isEmpty()) { + return; + } + owner.output(next.orElseThrow().pane()); + } + } catch (InterruptedException e) { + if (!closed.get()) { + Thread.currentThread().interrupt(); + } + } finally { + owner.ended(this); + } + } + + private void consumeEvents() { + long dropped = 0; + try { + while (!closed.get()) { + Optional next = events.next(); + long nowDropped = events.droppedCount(); + if (nowDropped != dropped) { + dropped = nowDropped; + owner.stateLost(); + } + if (next.isEmpty()) { + return; + } + owner.stateChanged(); + } + } catch (InterruptedException e) { + if (!closed.get()) { + Thread.currentThread().interrupt(); + } + } finally { + owner.ended(this); + } + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + output.close(); + events.close(); + outputConsumer.interrupt(); + eventConsumer.interrupt(); + connection.changeClients(() -> { + try { + client.close(); + } finally { + if (clientName != null) { + connection.reveal(clientName); + } + } + }); + if (started.get()) { + join(outputConsumer); + join(eventConsumer); + } + } + + private static void join(Thread thread) { + boolean interrupted = Thread.interrupted(); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(JOIN_MILLIS); + while (thread.isAlive()) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + break; + } + try { + thread.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining))); + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java index 8417b7d..9fe2604 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java @@ -1,127 +1,346 @@ package io.github.libtmux.mcp; -import io.github.libtmux.control.ControlClient; -import io.github.libtmux.control.ControlEvent; -import java.util.Optional; +import io.github.libtmux.LibTmuxException; +import io.github.libtmux.PaneId; +import io.github.libtmux.SessionId; +import io.github.libtmux.snapshot.ServerSnapshot; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.Nullable; -/** - * Tells a client when tmux has changed, instead of waiting to be asked. - * - *

A control client stays attached and tmux pushes at it: a window appearing, a session renamed, a - * pane producing output. Each of those becomes an MCP notification naming the resource that is now - * out of date, so a client holding {@code tmux://panes} refreshes when there is a reason to and - * never otherwise. Between changes nothing here runs at all — the comparison happens inside tmux, - * on its own timer. - * - *

Off unless asked for, because it is not free: watching means attaching a client, and an - * attached client is a real change to the server. The one attached here is hidden from - * {@code tmux_list_clients} so it cannot be mistaken for a person. - */ +/** Keeps MCP resource subscriptions aligned with a changing tmux server. */ final class Watches implements AutoCloseable { + private static final int SIGNAL_CAPACITY = 64; + private static final int NOTIFICATION_CAPACITY = 256; + private static final Duration RETRY_DELAY = Duration.ofMillis(250); + private static final Duration MAX_RETRY_DELAY = Duration.ofSeconds(8); + private static final long JOIN_MILLIS = 5_000; - /** - * What is watched over every pane: how far its output has got. - * - *

Not the pane's contents — a format expanding to a whole screen would be compared, and sent, - * every second. The cursor and history position change exactly when a pane produces output, - * which is the thing worth being told about. - */ - private static final String PANE_PROGRESS = "#{history_size},#{cursor_y},#{cursor_x}"; - - /** Notifications that mean the shape of the server changed, whatever else they carry. */ - private static final Set RESHAPED = Set.of( - "window-add", - "window-close", - "window-renamed", - "window-pane-changed", - "unlinked-window-add", - "unlinked-window-close", - "unlinked-window-renamed", - "session-changed", - "session-renamed", - "session-window-changed", - "sessions-changed", - "layout-change", - "client-session-changed", - "client-detached"); - - /** - * Where a change is announced. - * - *

An interface rather than the MCP server itself, so what tmux pushes and what the protocol - * sends can be tested apart. Watching real tmux is worth testing; the SDK's notification methods - * are not. - */ + /** What tmux pushes and what the protocol sends can be tested independently. */ interface Notifier { /** Says a resource is no longer what a client last read. */ void updated(String uri); + } - /** Says the set of resources itself has changed. */ - void listChanged(); + private enum Signal { + STATE, + OUTPUT_GAP, + GENERATION_GAP, + NOTIFY, + RETRY, + STOP } - private final ControlClient client; + private final Connection connection; + private final Map attachments = new ConcurrentHashMap<>(); + private final ArrayBlockingQueue signals = new ArrayBlockingQueue<>(SIGNAL_CAPACITY); + private final NotificationBuffer notifications = new NotificationBuffer(NOTIFICATION_CAPACITY); + private final AtomicBoolean generationGap = new AtomicBoolean(); + private final AtomicBoolean outputGap = new AtomicBoolean(); + private final AtomicBoolean outageAnnounced = new AtomicBoolean(); + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicReference<@Nullable Notifier> notifier = new AtomicReference<>(); + private final Thread supervisor; + private volatile ResourceInvalidations.Projection projection; - private Watches(ControlClient client) { - this.client = client; + private Watches(Connection connection, ResourceInvalidations.Projection projection) { + this.connection = connection; + this.projection = projection; + this.supervisor = Thread.ofVirtual().unstarted(this::supervise); + this.supervisor.setName("libtmux-mcp-watch-supervisor"); } - /** - * Starts watching, if there is anything to attach to. - * - * @return the watcher, or empty when the server has no session to attach to or tmux refused - */ - static Optional start(Connection connection, Notifier notifier) { - var sessions = connection.server().sessions(); - if (sessions.isEmpty()) { - return Optional.empty(); + /** Attaches and buffers before the MCP server is built, without notifying an unbound target. */ + static Watches prepare(Connection connection) { + ServerSnapshot snapshot = connection.server().snapshot(); + if (snapshot.sessions().isEmpty()) { + throw new IllegalStateException("watching requires a tmux session to attach to"); } - ControlClient client; + Watches watches = new Watches(connection, ResourceInvalidations.project(snapshot, connection::isOurs)); try { - client = ControlClient.attach( - connection.server().config(), sessions.get(0).id()); - } catch (RuntimeException e) { - // Watching is an improvement, not a requirement. A server that cannot be watched is still - // a server every tool works against. - return Optional.empty(); + if (!watches.reconcile(snapshot)) { + throw new IllegalStateException("could not attach every tmux session"); + } + ServerSnapshot current = connection.server().snapshot(); + if (!watches.reconcile(current)) { + throw new IllegalStateException("could not attach every current tmux session"); + } + watches.projection = ResourceInvalidations.project(current, connection::isOurs); + return watches; + } catch (RuntimeException | Error e) { + watches.close(); + throw new IllegalStateException("could not start the requested tmux watcher", e); } - // Asked through the client itself, so the answer is that client's own name and not a guess. - client.send("display-message", "-p", "#{client_name}").lines().stream() - .filter(name -> !name.isBlank()) - .forEach(connection::hide); + } + + /** Starts watching after a notifier has a live MCP server to target. */ + void start(Notifier target) { + if (closed.get()) { + throw new IllegalStateException("tmux watcher is closed"); + } + if (!notifier.compareAndSet(null, target) || !started.compareAndSet(false, true)) { + throw new IllegalStateException("tmux watcher is already started"); + } + attachments.values().forEach(WatchAttachment::start); + supervisor.start(); + signal(Signal.STATE); + } - client.onEvent(event -> announce(notifier, event)); - client.watch("panes", "%*", PANE_PROGRESS); - return Optional.of(new Watches(client)); + /** Convenience entry point for callers that already have a notifier. */ + static Watches start(Connection connection, Notifier notifier) { + Watches watches = prepare(connection); + try { + watches.start(notifier); + return watches; + } catch (RuntimeException | Error e) { + watches.close(); + throw e; + } } - private static void announce(Notifier notifier, ControlEvent event) { + private void supervise() { + boolean retry = false; + RetryBackoff backoff = new RetryBackoff(); try { - if (event.subscription().filter("panes"::equals).isPresent()) { - // A pane produced output, so what it is showing is no longer what a client last read. - event.paneId().ifPresent(pane -> notifier.updated("tmux://panes/" + pane + "/content")); - return; + while (!closed.get()) { + Signal first = takeSignal(retry, backoff.delay()); + if (first == Signal.STOP) { + return; + } + boolean timedRetry = first == Signal.RETRY; + if (!timedRetry) { + backoff.reset(); + } + boolean state = first == Signal.STATE || first == Signal.GENERATION_GAP; + boolean lostOutput = first == Signal.OUTPUT_GAP; + boolean lostState = first == Signal.GENERATION_GAP; + Signal next; + while ((next = signals.poll()) != null) { + if (next == Signal.STOP) { + return; + } + state |= next == Signal.STATE || next == Signal.GENERATION_GAP; + lostOutput |= next == Signal.OUTPUT_GAP; + lostState |= next == Signal.GENERATION_GAP; + } + lostOutput |= outputGap.getAndSet(false); + lostState |= generationGap.getAndSet(false); + if (lostOutput) { + announce(ResourceInvalidations.droppedOutput(projection)); + } + announce(notifications.drain()); + if (state || lostState || retry) { + retry = !refresh(lostState); + if (!retry) { + backoff.reset(); + } else if (timedRetry) { + backoff.failedRetry(); + } + } } - if (RESHAPED.contains(event.kind())) { - notifier.updated("tmux://sessions"); - notifier.updated("tmux://panes"); - notifier.listChanged(); + } catch (InterruptedException e) { + if (!closed.get()) { + Thread.currentThread().interrupt(); } + } + } + + private Signal takeSignal(boolean retry, Duration delay) throws InterruptedException { + if (!retry) { + return signals.take(); + } + Signal signal = signals.poll(delay.toNanos(), TimeUnit.NANOSECONDS); + return signal == null ? Signal.RETRY : signal; + } + + private boolean refresh(boolean lostState) { + ServerSnapshot snapshot; + ResourceInvalidations.Projection fresh; + try { + snapshot = connection.server().snapshot(); + fresh = ResourceInvalidations.project(snapshot, connection::isOurs); } catch (RuntimeException e) { - // These run on the control client's reader thread, which also resolves every reply. A - // client that has stopped listening must not be able to stop it reading. + if (outageAnnounced.compareAndSet(false, true)) { + announce(ResourceInvalidations.allKnown(projection)); + } + generationGap.set(true); + return false; + } + + outageAnnounced.set(false); + boolean reconciled = reconcile(snapshot); + announce( + lostState + ? ResourceInvalidations.allKnown(projection, fresh) + : ResourceInvalidations.between(projection, fresh)); + projection = fresh; + return reconciled; + } + + private boolean reconcile(ServerSnapshot snapshot) { + Set wanted = new LinkedHashSet<>(); + snapshot.sessions().forEach(session -> wanted.add(session.id())); + for (Map.Entry entry : new ArrayList<>(attachments.entrySet())) { + if (!wanted.contains(entry.getKey()) || !entry.getValue().isAlive()) { + if (attachments.remove(entry.getKey(), entry.getValue())) { + entry.getValue().close(); + } + } + } + for (SessionId session : wanted) { + if (closed.get()) { + return false; + } + if (!attachments.containsKey(session)) { + try { + WatchAttachment added = attach(session); + if (started.get()) { + added.start(); + } + } catch (LibTmuxException | IllegalStateException e) { + generationGap.set(true); + } + } + } + return !attachments.isEmpty() + && attachments.keySet().containsAll(wanted) + && attachments.values().stream().allMatch(WatchAttachment::isAlive); + } + + private WatchAttachment attach(SessionId session) { + WatchAttachment added = WatchAttachment.open(this, connection, session); + WatchAttachment existing = attachments.putIfAbsent(session, added); + if (existing != null) { + added.close(); + return existing; + } + if (closed.get()) { + attachments.remove(session, added); + added.close(); + throw new IllegalStateException("tmux watcher is closed"); + } + return added; + } + + void output(PaneId pane) { + String uri = Resources.paneContentUri(pane); + if (notifications.offer(uri)) { + signal(Signal.NOTIFY); + } else { + signal(Signal.OUTPUT_GAP); + } + } + + void outputDropped() { + signal(Signal.OUTPUT_GAP); + } + + void stateChanged() { + signal(Signal.STATE); + } + + void stateLost() { + signal(Signal.GENERATION_GAP); + } + + void ended(WatchAttachment attachment) { + if (!closed.get() && attachments.get(attachment.session()) == attachment) { + signal(Signal.GENERATION_GAP); + } + } + + private void signal(Signal signal) { + if (closed.get() && signal != Signal.STOP) { + return; + } + if (!signals.offer(signal)) { + if (signal == Signal.OUTPUT_GAP) { + outputGap.set(true); + } else { + generationGap.set(true); + } + } + } + + private void announce(Set uris) { + Notifier target = notifier.get(); + if (target == null) { + return; + } + for (String uri : uris) { + try { + target.updated(uri); + } catch (RuntimeException e) { + // A client that stopped listening does not end later notifications. + } } } - /** Whether the control client this watches through is still up. */ + /** Whether every currently attached control client is still up. */ boolean isAlive() { - return client.isAlive(); + return !closed.get() + && !attachments.isEmpty() + && attachments.values().stream().allMatch(WatchAttachment::isAlive); } @Override public void close() { - client.close(); + if (!closed.compareAndSet(false, true)) { + return; + } + signals.offer(Signal.STOP); + supervisor.interrupt(); + attachments.values().forEach(WatchAttachment::close); + attachments.clear(); + if (supervisor.getState() != Thread.State.NEW && !Thread.currentThread().equals(supervisor)) { + join(supervisor); + } + } + + private static void join(Thread thread) { + boolean interrupted = Thread.interrupted(); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(JOIN_MILLIS); + while (thread.isAlive()) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + break; + } + try { + thread.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remaining))); + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + static final class RetryBackoff { + + private Duration delay = RETRY_DELAY; + + Duration delay() { + return delay; + } + + void failedRetry() { + long doubled = Math.min(MAX_RETRY_DELAY.toNanos(), delay.toNanos() * 2); + delay = Duration.ofNanos(doubled); + } + + void reset() { + delay = RETRY_DELAY; + } } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watching.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watching.java index 7307d81..c5a561e 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watching.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watching.java @@ -71,6 +71,11 @@ static Fresh since(Pane pane, @Nullable Cursor from, int budget) { if (from == null) { return from(pane); } + String paneId = pane.id().value(); + if (!paneId.equals(from.paneId())) { + throw new IllegalArgumentException( + "that cursor belongs to pane " + from.paneId() + ", not " + paneId + "; each pane has its own"); + } Look look = look(pane, budget + SLACK_LINES); Fresh answer = resolve(from, look); if (answer != null) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java index 67231c9..bc8cf56 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java @@ -110,4 +110,12 @@ void findingOutWhatIsThereIsAlwaysOffered() { assertTrue(readonly.containsKey("tmux_capture_pane")); assertTrue(readonly.containsKey("tmux_wait_for_text"), "watching is reading, whatever it waits for"); } + + @Test + void consumingAChannelSignalIsNotAdvertisedAsReadOnly() { + assertFalse(Catalog.offered(Safety.READONLY).containsKey("tmux_wait_for_channel")); + ToolSpec wait = + Objects.requireNonNull(Catalog.offered(Safety.MUTATING).get("tmux_wait_for_channel"), "wait tool"); + assertEquals(Safety.MUTATING, wait.safety()); + } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ConnectionTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ConnectionTest.java new file mode 100644 index 0000000..a0ca6a4 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ConnectionTest.java @@ -0,0 +1,69 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.TmuxTransport; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; + +final class ConnectionTest { + + @Test + void aClientListingCannotObserveAHalfHiddenWatcher() throws Exception { + TmuxTransport unused = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + throw new AssertionError("this test dispatches no tmux command"); + } + + @Override + public void close() {} + }; + try (Server server = Server.using(ServerConfig.builder().build(), unused); + var tasks = Executors.newVirtualThreadPerTaskExecutor()) { + Connection connection = + new Connection(server, Caller.nowhere(), Safety.MUTATING, ConcurrentHashMap.newKeySet()); + CountDownLatch attaching = new CountDownLatch(1); + CountDownLatch hide = new CountDownLatch(1); + CountDownLatch listing = new CountDownLatch(1); + + var attachment = tasks.submit(() -> connection.changeClients(() -> { + attaching.countDown(); + await(hide); + connection.hide("watcher"); + })); + assertTrue(attaching.await(1, TimeUnit.SECONDS)); + + var observed = tasks.submit(() -> { + listing.countDown(); + return connection.withStableClients(() -> connection.isOurs("watcher")); + }); + assertTrue(listing.await(1, TimeUnit.SECONDS)); + try { + assertThrows(TimeoutException.class, () -> observed.get(100, TimeUnit.MILLISECONDS)); + } finally { + hide.countDown(); + } + attachment.get(1, TimeUnit.SECONDS); + assertTrue(observed.get(1, TimeUnit.SECONDS), "the listing ran before the watcher was hidden"); + } + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while arranging the test", e); + } + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java index 5c07783..4fc6ee7 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.libtmux.PaneId; import io.github.libtmux.Server; import io.github.libtmux.ServerConfig; import io.github.libtmux.ServerEndpoint; @@ -18,10 +19,12 @@ import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.spec.McpSchema; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.extension.ExtendWith; @@ -40,6 +43,38 @@ final class McpLauncherTest { /** Longer than any single call needs, short enough that a hung launcher fails as itself. */ private static final int PATIENCE_SECONDS = 60; + /** Protocol failure ends the session even when the client forgets to close its stdin pipe. */ + @Test + void malformedInputDoesNotLeaveTheLauncherWaitingForEndOfInput(Server server, TmuxSocketPath socket) + throws Exception { + Process launcher = new ProcessBuilder(List.of( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-classpath", + System.getProperty("java.class.path"), + Main.class.getName(), + "--socket", + socket.path().toString(), + "--tmux", + TMUX)) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + try { + launcher.getOutputStream().write("{not-json}\n".getBytes(StandardCharsets.UTF_8)); + launcher.getOutputStream().flush(); + + assertTrue( + launcher.waitFor(5, TimeUnit.SECONDS), + "the protocol session ended, but the launcher was still waiting for stdin EOF"); + } finally { + launcher.getOutputStream().close(); + if (!launcher.waitFor(5, TimeUnit.SECONDS)) { + launcher.destroyForcibly(); + launcher.waitFor(5, TimeUnit.SECONDS); + } + } + } + @Test @Timeout(PATIENCE_SECONDS) void aLaunchedServerAnswersAboutTheSocketItWasGiven(Server server, TmuxSocketPath socket) { @@ -155,8 +190,8 @@ void aPaneCanBeReadAsAResource(Server server, TmuxSocketPath socket) { try (McpSyncClient client = launch(socket.path())) { client.initialize(); - McpSchema.ReadResourceResult read = - client.readResource(McpSchema.ReadResourceRequest.builder("tmux://panes/" + pane + "/content") + McpSchema.ReadResourceResult read = client.readResource( + McpSchema.ReadResourceRequest.builder(Resources.paneContentUri(new PaneId(pane))) .build()); assertEquals(1, read.contents().size()); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/NotificationBufferTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/NotificationBufferTest.java new file mode 100644 index 0000000..41afc67 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/NotificationBufferTest.java @@ -0,0 +1,25 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import org.junit.jupiter.api.Test; + +final class NotificationBufferTest { + + @Test + void repeatedUpdatesCoalesceAndDistinctUpdatesStayBounded() { + NotificationBuffer notifications = new NotificationBuffer(2); + + assertTrue(notifications.offer("one")); + assertTrue(notifications.offer("one")); + assertTrue(notifications.offer("two")); + assertFalse(notifications.offer("three")); + assertEquals(2, notifications.size()); + assertEquals(Set.of("one", "two"), notifications.drain()); + assertEquals(0, notifications.size()); + assertTrue(notifications.offer("three")); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ResourceInvalidationsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ResourceInvalidationsTest.java new file mode 100644 index 0000000..45c040f --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ResourceInvalidationsTest.java @@ -0,0 +1,289 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.libtmux.Dimensions; +import io.github.libtmux.PaneEdges; +import io.github.libtmux.PaneId; +import io.github.libtmux.SessionId; +import io.github.libtmux.WindowId; +import io.github.libtmux.WindowIndex; +import io.github.libtmux.snapshot.ClientState; +import io.github.libtmux.snapshot.PaneState; +import io.github.libtmux.snapshot.ServerSnapshot; +import io.github.libtmux.snapshot.SessionState; +import io.github.libtmux.snapshot.WindowContext; +import io.github.libtmux.snapshot.WindowState; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +final class ResourceInvalidationsTest { + + private static final Instant WHEN = Instant.parse("2026-08-28T00:00:00Z"); + private static final SessionId SESSION = new SessionId("$1"); + private static final WindowContext WINDOW = new WindowContext(SESSION, new WindowIndex(0), new WindowId("@1")); + private static final WindowContext OTHER_WINDOW = + new WindowContext(SESSION, new WindowIndex(1), new WindowId("@2")); + private static final PaneId PANE = new PaneId("%1"); + private static final PaneId OTHER_PANE = new PaneId("%2"); + private static final Dimensions SIZE = new Dimensions(80, 24); + + @Test + void sessionRenameInvalidatesTheOldAndNewAddresses() { + ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); + ServerSnapshot after = onePane(WHEN.plusSeconds(1), "renamed", false, "zsh", SIZE, List.of()); + + assertEquals( + Set.of( + "tmux://sessions", + "tmux://sessions/alpha", + "tmux://sessions/renamed", + "tmux://panes", + "tmux://panes/%251"), + changed(before, after)); + } + + @Test + void paneMetadataChangeInvalidatesOnlyPaneMetadata() { + ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); + ServerSnapshot after = onePane(WHEN.plusSeconds(1), "alpha", false, "nvim", SIZE, List.of()); + + assertEquals(Set.of("tmux://panes", "tmux://panes/%251"), changed(before, after)); + } + + @Test + void addingOrRemovingAPaneInvalidatesItsAddressesAndServerCount() { + PaneState first = pane(WINDOW, PANE, "zsh", SIZE); + PaneState second = pane(WINDOW, OTHER_PANE, 1, "tail", SIZE); + ServerSnapshot before = snapshot(WHEN, "alpha", false, List.of(window(WINDOW, "shell", 1)), List.of(first)); + ServerSnapshot after = snapshot( + WHEN.plusSeconds(1), "alpha", false, List.of(window(WINDOW, "shell", 2)), List.of(first, second)); + Set expected = + Set.of("tmux://server", "tmux://panes", "tmux://panes/%252", "tmux://panes/%252/content"); + + assertEquals(expected, changed(before, after)); + assertEquals(expected, changed(after, before)); + } + + @Test + void addingOrRemovingASessionInvalidatesItsAddressAndServerCount() { + ServerSnapshot empty = ServerSnapshot.of(WHEN, List.of(), List.of(), List.of(), List.of()); + ServerSnapshot present = ServerSnapshot.of( + WHEN.plusSeconds(1), + List.of(new SessionState(SESSION, "alpha", false, 0)), + List.of(), + List.of(), + List.of()); + Set expected = Set.of("tmux://server", "tmux://sessions", "tmux://sessions/alpha"); + + assertEquals(expected, changed(empty, present)); + assertEquals(expected, changed(present, empty)); + } + + @Test + void paneDimensionsAlsoInvalidateContent() { + ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); + ServerSnapshot after = onePane(WHEN.plusSeconds(1), "alpha", false, "zsh", new Dimensions(120, 40), List.of()); + + assertEquals(Set.of("tmux://panes", "tmux://panes/%251", "tmux://panes/%251/content"), changed(before, after)); + } + + @Test + void replacingAPaneProcessAlsoInvalidatesItsContent() { + PaneState beforePane = pane(WINDOW, PANE, "zsh", SIZE, 11L); + PaneState afterPane = pane(WINDOW, PANE, "sleep", SIZE, 12L); + ServerSnapshot before = + snapshot(WHEN, "alpha", false, List.of(window(WINDOW, "shell", 1)), List.of(beforePane)); + ServerSnapshot after = + snapshot(WHEN.plusSeconds(1), "alpha", false, List.of(window(WINDOW, "shell", 1)), List.of(afterPane)); + + assertEquals(Set.of("tmux://panes", "tmux://panes/%251", "tmux://panes/%251/content"), changed(before, after)); + } + + @Test + void hierarchyCountsInvalidateTheServerResource() { + PaneState pane = pane(WINDOW, PANE, "zsh", SIZE); + ServerSnapshot before = snapshot(WHEN, "alpha", false, List.of(window(WINDOW, "shell", 1)), List.of(pane)); + ServerSnapshot after = snapshot( + WHEN.plusSeconds(1), + "alpha", + false, + List.of(window(WINDOW, "shell", 1), window(OTHER_WINDOW, "logs", 0)), + List.of(pane)); + + assertEquals(Set.of("tmux://server", "tmux://sessions", "tmux://sessions/alpha"), changed(before, after)); + } + + @Test + void aGenerationGapInvalidatesAllOldAndNewKnownResources() { + ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); + PaneState replacement = pane(WINDOW, OTHER_PANE, "nvim", SIZE); + ServerSnapshot after = snapshot( + WHEN.plusSeconds(1), "renamed", false, List.of(window(WINDOW, "shell", 1)), List.of(replacement)); + + assertEquals( + Set.of( + "tmux://server", + "tmux://sessions", + "tmux://panes", + "tmux://sessions/alpha", + "tmux://sessions/renamed", + "tmux://panes/%251", + "tmux://panes/%251/content", + "tmux://panes/%252", + "tmux://panes/%252/content"), + ResourceInvalidations.allKnown(project(before), project(after))); + } + + @Test + void paneOutputInvalidatesOnlyThatPanesContent() { + assertEquals(Set.of("tmux://panes/%251/content"), ResourceInvalidations.output(PANE)); + } + + @Test + void droppedOutputInvalidatesEveryKnownPaneContentOnce() { + WindowContext linked = new WindowContext(SESSION, new WindowIndex(1), new WindowId("@1")); + PaneState first = pane(WINDOW, PANE, "zsh", SIZE); + PaneState duplicate = pane(linked, PANE, "zsh", SIZE); + PaneState second = pane(WINDOW, OTHER_PANE, 1, "tail", SIZE); + ServerSnapshot snapshot = snapshot( + WHEN, + "alpha", + false, + List.of(window(WINDOW, "shell", 2), window(linked, "linked", 1)), + List.of(first, duplicate, second)); + + assertEquals( + Set.of("tmux://panes/%251/content", "tmux://panes/%252/content"), + ResourceInvalidations.droppedOutput(project(snapshot))); + } + + @Test + void capturedTimeAloneProducesNoInvalidation() { + ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); + ServerSnapshot after = onePane(WHEN.plusSeconds(1), "alpha", false, "zsh", SIZE, List.of()); + + assertEquals(Set.of(), changed(before, after)); + } + + @Test + void hiddenClientsDoNotMakeSessionsAppearAttached() { + ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); + ServerSnapshot after = onePane( + WHEN.plusSeconds(1), + "alpha", + true, + "zsh", + SIZE, + List.of(new ClientState("watcher", Optional.of(SESSION)))); + + assertEquals( + Set.of(), + ResourceInvalidations.between( + ResourceInvalidations.project(before, Set.of("watcher")::contains), + ResourceInvalidations.project(after, Set.of("watcher")::contains))); + } + + @Test + void visibleClientsMakeSessionsAppearAttached() { + ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); + ServerSnapshot after = onePane( + WHEN.plusSeconds(1), + "alpha", + true, + "zsh", + SIZE, + List.of(new ClientState("terminal", Optional.of(SESSION)))); + + assertEquals(Set.of("tmux://sessions", "tmux://sessions/alpha"), changed(before, after)); + } + + @Test + void changedUriSetsAreImmutable() { + ServerSnapshot before = onePane(WHEN, "alpha", false, "zsh", SIZE, List.of()); + ServerSnapshot after = onePane(WHEN.plusSeconds(1), "alpha", false, "nvim", SIZE, List.of()); + Set changed = changed(before, after); + + assertThrows(UnsupportedOperationException.class, () -> changed.add("tmux://server")); + } + + private static Set changed(ServerSnapshot before, ServerSnapshot after) { + return ResourceInvalidations.between(project(before), project(after)); + } + + private static ResourceInvalidations.Projection project(ServerSnapshot snapshot) { + return ResourceInvalidations.project(snapshot, ignored -> false); + } + + private static ServerSnapshot onePane( + Instant when, + String sessionName, + boolean attached, + String command, + Dimensions size, + List clients) { + return ServerSnapshot.of( + when, + List.of(new SessionState(SESSION, sessionName, attached, 1)), + List.of(window(WINDOW, "shell", 1)), + List.of(pane(WINDOW, PANE, command, size)), + clients); + } + + private static ServerSnapshot snapshot( + Instant when, String sessionName, boolean attached, List windows, List panes) { + return snapshot(when, sessionName, attached, windows, panes, List.of()); + } + + private static ServerSnapshot snapshot( + Instant when, + String sessionName, + boolean attached, + List windows, + List panes, + List clients) { + return ServerSnapshot.of( + when, + List.of(new SessionState(SESSION, sessionName, attached, windows.size())), + windows, + panes, + clients); + } + + private static WindowState window(WindowContext context, String name, int panes) { + return new WindowState(context, name, true, panes, false, SIZE, "layout"); + } + + private static PaneState pane(WindowContext context, PaneId id, String command, Dimensions size) { + return pane(context, id, 0, command, size, 1L); + } + + private static PaneState pane(WindowContext context, PaneId id, String command, Dimensions size, long pid) { + return pane(context, id, 0, command, size, pid); + } + + private static PaneState pane(WindowContext context, PaneId id, int index, String command, Dimensions size) { + return pane(context, id, index, command, size, 1L); + } + + private static PaneState pane( + WindowContext context, PaneId id, int index, String command, Dimensions size, long pid) { + return new PaneState( + context, + id, + index, + true, + command, + size, + "title", + Path.of("/work"), + pid, + new PaneEdges(true, true, true, true), + Optional.of(false)); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerDiscoveryTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerDiscoveryTest.java new file mode 100644 index 0000000..a4bb69d --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerDiscoveryTest.java @@ -0,0 +1,373 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.channels.ServerSocketChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.FutureTask; +import java.util.function.BooleanSupplier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@SuppressWarnings("try") +final class ServerDiscoveryTest { + + private static final String STRICT_PROBE = """ + #!/bin/sh + [ "$#" -eq 5 ] || exit 90 + [ "$1" = '-S' ] || exit 91 + [ "$3" = 'list-sessions' ] || exit 92 + [ "$4" = '-F' ] || exit 93 + [ "$5" = '#{session_id}' ] || exit 94 + printf '%s\n' "$*" >> "${2}.calls" + """; + + @Test + void staleSocketIsUnreachableAfterOneStrictProbe(@TempDir Path root) throws Exception { + Path socket = standard(root, "stale"); + staleSocket(socket); + Path binary = binary(root, "exit 1\n"); + + ServerDiscovery.Result result = + discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); + + assertEquals(1, result.servers().size()); + ServerDiscovery.KnownServer known = result.servers().get(0); + assertEquals(socket, known.socket()); + assertEquals(ServerDiscovery.State.UNREACHABLE, known.state()); + assertNull(known.sessions()); + assertEquals(1, Files.readAllLines(callLog(socket)).size(), "one candidate gets one strict probe"); + } + + @Test + void candidateIsNotProbedWhenTmuxCannotStart(@TempDir Path root) throws Exception { + Path socket = standard(root, "socket"); + try (SocketSet ignored = sockets(List.of(socket))) { + Path missingBinary = root.resolve("missing-tmux"); + + ServerDiscovery.Result result = + discovery(root, 8, 32, 1, Duration.ofSeconds(1)).discover(missingBinary.toString(), null); + + assertEquals( + ServerDiscovery.State.NOT_PROBED, result.servers().get(0).state()); + assertNull(result.servers().get(0).sessions()); + } + } + + @Test + void slowSocketHasATypedDeadline(@TempDir Path root) throws Exception { + Path socket = standard(root, "slow"); + try (SocketSet ignored = sockets(List.of(socket))) { + Path binary = binary(root, "exec sleep 10\n"); + ServerDiscovery discovery = discovery(root, 8, 32, 1, Duration.ofMillis(200)); + + long started = System.nanoTime(); + ServerDiscovery.Result result = discovery.discover(binary.toString(), null); + Duration elapsed = Duration.ofNanos(System.nanoTime() - started); + + assertEquals( + ServerDiscovery.State.TIMED_OUT, result.servers().get(0).state()); + assertTrue(elapsed.compareTo(Duration.ofSeconds(2)) < 0, "per-candidate deadline was " + elapsed); + assertEquals(1, Files.readAllLines(callLog(socket)).size()); + } + } + + @Test + void candidateCapReportsTruncation(@TempDir Path root) throws Exception { + List candidates = + List.of(standard(root, "a"), standard(root, "b"), standard(root, "c"), standard(root, "d")); + try (SocketSet ignored = sockets(candidates)) { + Path binary = binary(root, "printf '%%1\\n'\n"); + + ServerDiscovery.Result result = + discovery(root, 2, 16, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); + + assertEquals(2, result.servers().size()); + assertTrue(result.truncated()); + assertTrue(result.servers().stream().allMatch(server -> server.state() == ServerDiscovery.State.RUNNING)); + assertTrue(result.servers().stream() + .allMatch(server -> Integer.valueOf(1).equals(server.sessions()))); + } + } + + @Test + void candidateCapSelectsLexicallyFromTheInspectedEntries(@TempDir Path root) throws Exception { + List candidates = + List.of(standard(root, "d"), standard(root, "c"), standard(root, "b"), standard(root, "a")); + try (SocketSet ignored = sockets(candidates)) { + Path binary = binary(root, "printf '%%1\\n'\n"); + + ServerDiscovery.Result result = + discovery(root, 2, 16, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); + + assertEquals( + List.of(standard(root, "a"), standard(root, "b")), + result.servers().stream() + .map(ServerDiscovery.KnownServer::socket) + .toList()); + assertTrue(result.truncated()); + } + } + + @Test + void processConcurrencyNeverExceedsTheConfiguredBound(@TempDir Path root) throws Exception { + List candidates = + List.of(standard(root, "a"), standard(root, "b"), standard(root, "c"), standard(root, "d")); + try (SocketSet ignored = sockets(candidates)) { + Path binary = binary(root, """ + : > "${2}.started" + while [ ! -e "${2}.release" ]; do + sleep 0.01 + done + printf '%%1\n' + """); + ServerDiscovery discovery = discovery(root, 4, 16, 2, Duration.ofSeconds(2)); + FutureTask task = + new FutureTask<>(() -> discovery.discover(binary.toString(), null)); + Thread worker = Thread.startVirtualThread(task); + + await(() -> started(candidates) >= 2, Duration.ofSeconds(2)); + Thread.sleep(150); + int firstWave = started(candidates); + releaseStarted(candidates); + assertEquals(2, firstWave, "a third process started before one of the first two left"); + await(() -> started(candidates) == 4, Duration.ofSeconds(2)); + releaseStarted(candidates); + + ServerDiscovery.Result result = task.get(); + worker.join(); + assertTrue(result.servers().stream().allMatch(server -> server.state() == ServerDiscovery.State.RUNNING)); + } + } + + @Test + void uidFailureDoesNotScanTheRootFallbackDirectory(@TempDir Path root) throws Exception { + Path rootFallback = root.resolve("tmux-0/would-have-been-probed"); + try (SocketSet ignored = sockets(List.of(rootFallback))) { + Path binary = binary(root, "printf '%%1\\n'\n"); + ServerDiscovery discovery = new ServerDiscovery( + root, + () -> { + throw new IOException("uid unavailable"); + }, + 8, + 32, + 2, + Duration.ofSeconds(1)); + + ServerDiscovery.Result result = discovery.discover(binary.toString(), null); + + assertTrue(result.servers().isEmpty()); + assertTrue(Objects.requireNonNull(result.scanNote()).contains("user")); + assertFalse(Files.exists(callLog(rootFallback)), "UID failure must not become uid 0"); + } + } + + @Test + void currentCustomSocketOutsideTheStandardDirectoryIsIncludedOnce(@TempDir Path root) throws Exception { + Path standard = standard(root, "standard"); + Path custom = root.resolve("custom/current"); + try (SocketSet ignored = sockets(List.of(standard, custom))) { + Path binary = binary(root, "printf '%%1\\n'\n"); + + ServerDiscovery.Result result = + discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), custom); + + assertEquals(2, result.servers().size()); + assertEquals( + 1, + result.servers().stream() + .filter(server -> server.socket().equals(custom)) + .count()); + assertEquals(1, Files.readAllLines(callLog(custom)).size()); + } + } + + @Test + void candidateCapReservesTheCurrentSocket(@TempDir Path root) throws Exception { + Path first = standard(root, "a"); + Path second = standard(root, "b"); + Path current = root.resolve("z-custom/current"); + try (SocketSet ignored = sockets(List.of(first, second, current))) { + Path binary = binary(root, "printf '%%1\\n'\n"); + + ServerDiscovery.Result result = + discovery(root, 2, 16, 2, Duration.ofSeconds(1)).discover(binary.toString(), current); + + assertEquals( + List.of(first, current), + result.servers().stream() + .map(ServerDiscovery.KnownServer::socket) + .toList()); + assertTrue(result.truncated()); + } + } + + @Test + void currentSocketFoundByTheDirectoryScanIsNotProbedTwice(@TempDir Path root) throws Exception { + Path current = standard(root, "current"); + try (SocketSet ignored = sockets(List.of(current))) { + Path binary = binary(root, "printf '%%1\\n'\n"); + + ServerDiscovery.Result result = + discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), current); + + assertEquals(1, result.servers().size()); + assertEquals(1, Files.readAllLines(callLog(current)).size()); + } + } + + @Test + void symlinkedSocketEntriesAreNotProbed(@TempDir Path root) throws Exception { + Path outside = root.resolve("outside/socket"); + try (SocketSet ignored = sockets(List.of(outside))) { + Path directory = standardDirectory(root); + Files.createSymbolicLink(directory.resolve("alias"), outside); + Path binary = binary(root, "printf '%%1\\n'\n"); + + ServerDiscovery.Result result = + discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); + + assertTrue(result.servers().isEmpty()); + assertFalse(Files.exists(callLog(outside))); + } + } + + @Test + void fifoEntriesAreNotProbedAsSockets(@TempDir Path root) throws Exception { + Path directory = standardDirectory(root); + Path fifo = directory.resolve("not-a-socket"); + Process process = new ProcessBuilder("mkfifo", fifo.toString()).start(); + assertEquals(0, process.waitFor()); + Path binary = binary(root, "printf '%%1\\n'\n"); + + ServerDiscovery.Result result = + discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); + + assertTrue(result.servers().isEmpty()); + assertFalse(Files.exists(callLog(fifo))); + } + + @Test + void unsafeStandardSocketDirectoryIsNotScanned(@TempDir Path root) throws Exception { + Path socket = standard(root, "untrusted"); + try (SocketSet ignored = sockets(List.of(socket))) { + Files.setPosixFilePermissions(socket.getParent(), PosixFilePermissions.fromString("rwx---r-x")); + Path binary = binary(root, "printf '%%1\\n'\n"); + + ServerDiscovery.Result result = + discovery(root, 8, 32, 2, Duration.ofSeconds(1)).discover(binary.toString(), null); + + assertTrue(result.servers().isEmpty()); + assertTrue(Objects.requireNonNull(result.scanNote()).contains("unsafe")); + assertFalse(Files.exists(callLog(socket))); + } + } + + private static ServerDiscovery discovery( + Path root, int candidateLimit, int scanLimit, int maxConcurrency, Duration timeout) throws IOException { + return new ServerDiscovery(root, () -> uid(root), candidateLimit, scanLimit, maxConcurrency, timeout); + } + + private static Path standard(Path root, String name) throws IOException { + return standardDirectory(root).resolve(name); + } + + private static Path standardDirectory(Path root) throws IOException { + Path directory = Files.createDirectories(root.resolve("tmux-" + uid(root))); + Files.setPosixFilePermissions(directory, PosixFilePermissions.fromString("rwx------")); + return directory; + } + + private static long uid(Path path) throws IOException { + return ((Number) Files.getAttribute(path, "unix:uid")).longValue(); + } + + private static Path binary(Path root, String behavior) throws IOException { + Path binary = root.resolve("fake-tmux-" + System.nanoTime()); + Files.writeString(binary, STRICT_PROBE + behavior); + Files.setPosixFilePermissions(binary, PosixFilePermissions.fromString("rwx------")); + return binary; + } + + private static SocketSet sockets(List paths) throws IOException { + List channels = new ArrayList<>(); + try { + for (Path path : paths) { + Files.createDirectories(path.getParent()); + ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX); + channel.bind(UnixDomainSocketAddress.of(path)); + channels.add(channel); + } + return new SocketSet(channels); + } catch (IOException | RuntimeException e) { + new SocketSet(channels).close(); + throw e; + } + } + + private static void staleSocket(Path path) throws IOException { + try (SocketSet ignored = sockets(List.of(path))) { + // Closing the listener deliberately leaves its socket node behind. + } + } + + private static Path callLog(Path socket) { + return Path.of(socket + ".calls"); + } + + private static int started(List sockets) { + return (int) sockets.stream() + .filter(socket -> Files.exists(Path.of(socket + ".started"))) + .count(); + } + + private static void releaseStarted(List sockets) throws IOException { + for (Path socket : sockets) { + if (Files.exists(Path.of(socket + ".started"))) { + Files.writeString(Path.of(socket + ".release"), ""); + } + } + } + + private static void await(BooleanSupplier condition, Duration timeout) throws InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + while (!condition.getAsBoolean() && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertTrue(condition.getAsBoolean(), "condition did not become true within " + timeout); + } + + private record SocketSet(List channels) implements AutoCloseable { + @Override + public void close() throws IOException { + IOException failure = null; + for (ServerSocketChannel channel : channels) { + try { + channel.close(); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + if (failure != null) { + throw failure; + } + } + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java index 2899712..743fefe 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.ObjectMapper; import io.github.libtmux.Pane; import io.github.libtmux.Pane_; import io.github.libtmux.Server; @@ -10,6 +11,13 @@ import io.github.libtmux.jackson.LibTmuxModels; import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.query.FilterExpr; +import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import java.io.ByteArrayOutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.time.Duration; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,4 +50,38 @@ void theFilterExampleSelectsAgainstRealTmux(Server server) { "the fixture runs a shell, so nothing should match a filter for nvim"); assertEquals(1, server.panes().size(), "and the unfiltered listing still sees the pane"); } + + @Test + void closingAnEmbeddedMcpServerClosesItsWatcher(Server server) throws Exception { + PipedInputStream input = new PipedInputStream(); + try (PipedOutputStream client = new PipedOutputStream(input)) { + client.flush(); + StdioServerTransportProvider transport = new StdioServerTransportProvider( + new JacksonMcpJsonMapper(new ObjectMapper()), input, new ByteArrayOutputStream()); + McpSyncServer mcp = TmuxMcpServer.serving(server, Safety.MUTATING, true, transport); + try { + assertTrue(await(() -> !server.clients().isEmpty()), "the watcher never attached"); + + mcp.close(); + + assertTrue(await(() -> server.clients().isEmpty()), "closing MCP left its watcher attached"); + } finally { + mcp.close(); + for (var attached : server.clients()) { + server.cmd("detach-client", "-t", attached.name()); + } + } + } + } + + private static boolean await(java.util.function.BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + Thread.sleep(25); + } + return condition.getAsBoolean(); + } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index f624987..6858ec3 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -9,9 +9,15 @@ import io.github.libtmux.ObjectDoesNotExist; import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; import io.github.libtmux.WakeReason; import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -140,6 +146,52 @@ void whoamiSaysWhichServerAndThatNoPaneIsSpecial(Server server) { assertNotNull(whoami.socket()); } + @Test + void serverDiscoveryReservesTheLiveSocketForAnAmbientEndpoint() { + String liveSocket = "/tmp/libtmux-java-test/ambient-custom"; + TmuxTransport reportsSocket = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return new CommandResult(0, List.of(liveSocket), List.of()); + } + + @Override + public void close() {} + }; + ServerConfig config = ServerConfig.builder().binary("/bin/false").build(); + + try (Server ambient = Server.using(config, reportsSocket)) { + Listings.Servers servers = Listings.servers(ambient); + + assertTrue( + servers.servers().stream().anyMatch(found -> found.socket().equals(liveSocket))); + assertTrue(servers.note().contains(liveSocket), servers.note()); + } + } + + @Test + void whoamiCapturesTheHierarchyOnceInsteadOfTraversingLiveHandles(Server server) { + AtomicInteger commands = new AtomicInteger(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport counting = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + commands.incrementAndGet(); + return processes.execute(request); + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), counting)) { + Listings.Whoami whoami = Listings.whoami(measured, Caller.nowhere(), Safety.MUTATING); + + assertEquals(1, whoami.sessions()); + assertTrue(commands.get() <= 6, "whoami dispatched " + commands.get() + " tmux commands"); + } + } + } + /** And when this process really is inside a pane, that pane is named as the one to protect. */ @Test void whoamiNamesTheCallersOwnPaneWhenThereIsOne(Server server) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TrimTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TrimTest.java new file mode 100644 index 0000000..57a56ba --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TrimTest.java @@ -0,0 +1,27 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +final class TrimTest { + + @Test + void appendingOutputRetainsOnlyTheBoundedTailAndCountsEverythingDropped() { + Trim.Trimmed retained = new Trim.Trimmed(List.of(), 0); + + for (int batch = 0; batch < 100; batch++) { + List fresh = new ArrayList<>(); + for (int line = 0; line < 100; line++) { + fresh.add("line-" + batch + "-" + line); + } + retained = Trim.append(retained, fresh, 10); + } + + assertEquals(10, retained.lines().size()); + assertEquals("line-99-99", retained.lines().get(9)); + assertEquals(9_990, retained.dropped()); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/UrisTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/UrisTest.java new file mode 100644 index 0000000..7a7076c --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/UrisTest.java @@ -0,0 +1,48 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.github.libtmux.PaneId; +import java.util.List; +import org.junit.jupiter.api.Test; + +final class UrisTest { + + @Test + void resourceIdentifiersEncodeOnePathSegment() { + assertEquals("tmux://panes/%251", Resources.paneUri(new PaneId("%1"))); + assertEquals("tmux://sessions/a%2Fb%20%25%3F%23", Resources.sessionUri("a/b %?#")); + } + + @Test + void templateValuesDecodeOnePathSegment() { + assertEquals(List.of("%1"), Uris.values(Resources.PANE_TEMPLATE, "tmux://panes/%251")); + assertEquals(List.of("a/b é"), Uris.values(Resources.SESSION_TEMPLATE, "tmux://sessions/a%2Fb%20%C3%A9")); + } + + @Test + void aTemplateMustConsumeTheWholeUri() { + assertThrows( + IllegalArgumentException.class, + () -> Uris.values(Resources.PANE_CONTENT_TEMPLATE, "tmux://panes/%251/content/extra")); + assertThrows( + IllegalArgumentException.class, () -> Uris.values(Resources.PANE_TEMPLATE, "tmux://panes/%251/extra")); + assertThrows(IllegalArgumentException.class, () -> Uris.values(Resources.PANE_TEMPLATE, "tmux://panes/")); + } + + @Test + void malformedEscapesAreRejected() { + assertThrows(IllegalArgumentException.class, () -> Uris.values(Resources.PANE_TEMPLATE, "tmux://panes/%1")); + } + + @Test + void equivalentButNoncanonicalUrisAreRejected() { + assertThrows( + IllegalArgumentException.class, () -> Uris.values(Resources.SESSION_TEMPLATE, "tmux://sessions/f%6Fo")); + assertThrows( + IllegalArgumentException.class, () -> Uris.values(Resources.SESSION_TEMPLATE, "tmux://sessions/a%2fb")); + assertThrows( + IllegalArgumentException.class, () -> Uris.values(Resources.SESSION_TEMPLATE, "tmux://sessions/a?b")); + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WaitingForTextTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WaitingForTextTest.java index 31947e6..c98099b 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WaitingForTextTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WaitingForTextTest.java @@ -121,6 +121,21 @@ void anExpressionThatWillNotCompileSaysSoRatherThanNeverMatching(Server server) assertTrue(String.valueOf(refused.getMessage()).contains("Omit 'regex'"), refused.getMessage()); } + @Test + void aCursorFromAnotherPaneIsRejectedBeforeWaiting(Server server) { + String first = server.panes().get(0).id().value(); + String second = server.sessions().get(0).windows().get(0).split().id().value(); + String cursor = Reading.since(TestCalls.on(server, "pane_id", first)).cursor(); + + IllegalArgumentException refused = assertThrows( + IllegalArgumentException.class, + () -> WaitingForText.waitFor( + TestCalls.on(server, "pane_id", second, "cursor", cursor, "timeout", 0.2))); + + assertTrue(String.valueOf(refused.getMessage()).contains(first), refused.getMessage()); + assertTrue(String.valueOf(refused.getMessage()).contains(second), refused.getMessage()); + } + /** A model that sends one string where the schema says a list means the one string. */ @Test void aSinglePatternSentWithoutAListIsStillUnderstood(Server server) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java index 8f0707b..5ce2d6b 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java @@ -1,13 +1,22 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.github.libtmux.Pane; +import io.github.libtmux.PaneId; import io.github.libtmux.Server; import io.github.libtmux.junit5.TmuxExtension; +import java.time.Duration; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,16 +35,14 @@ final class WatchesTest { private static final class Heard implements Watches.Notifier { private final List updated = new CopyOnWriteArrayList<>(); - private final List listChanged = new CopyOnWriteArrayList<>(); @Override public void updated(String uri) { updated.add(uri); } - @Override - public void listChanged() { - listChanged.add("list"); + void clear() { + updated.clear(); } } @@ -44,13 +51,24 @@ void aWindowAppearingTellsTheClientTheListingIsStale(Server server) throws Excep Connection connection = Connection.to(server, Safety.MUTATING); Heard heard = new Heard(); - try (Watches watching = Watches.start(connection, heard).orElseThrow()) { + try (Watches watching = Watches.start(connection, heard)) { assertTrue(watching.isAlive(), "the control client stayed up"); server.sessions().get(0).newWindow("appeared"); assertTrue(await(() -> heard.updated.contains("tmux://sessions")), heard.updated.toString()); assertTrue(heard.updated.contains("tmux://panes")); - assertTrue(!heard.listChanged.isEmpty(), "and the set of resources itself changed"); + assertTrue(heard.updated.contains("tmux://server")); + assertTrue(heard.updated.contains("tmux://sessions/libtmux")); + String pane = server.windows().stream() + .filter(window -> window.name().equals("appeared")) + .findFirst() + .orElseThrow() + .panes() + .getFirst() + .id() + .value(); + assertTrue(heard.updated.contains(Resources.paneUri(new PaneId(pane)))); + assertTrue(heard.updated.contains(Resources.paneContentUri(new PaneId(pane)))); } } @@ -61,17 +79,149 @@ void outputInAPaneNamesThatPanesContentAsStale(Server server) throws Exception { String pane = server.panes().get(0).id().value(); Heard heard = new Heard(); - try (Watches watching = Watches.start(connection, heard).orElseThrow()) { + try (Watches watching = Watches.start(connection, heard)) { assertTrue(watching.isAlive()); server.run(List.of("send-keys", "-l", "-t", pane, "echo watched-output")); server.run(List.of("send-keys", "-t", pane, "Enter")); assertTrue( - await(() -> heard.updated.contains("tmux://panes/" + pane + "/content")), + await(() -> heard.updated.contains(Resources.paneContentUri(new PaneId(pane)))), "the pane that produced output is the one named: " + heard.updated); } } + @Test + void anInPlaceRedrawInvalidatesContentEvenWhenTheCursorDoesNotMove(Server server) throws Exception { + Connection connection = Connection.to(server, Safety.MUTATING); + Pane pane = server.panes().getFirst(); + String content = Resources.paneContentUri(pane.id()); + Heard heard = new Heard(); + + try (Watches watching = Watches.start(connection, heard)) { + assertTrue(watching.isAlive()); + assertTrue(await(() -> heard.updated.contains(content)), "the initial subscription never settled"); + heard.clear(); + pane.sendLine("printf first; sleep 4; printf '\\rother'; sleep 2"); + assertTrue(await(() -> heard.updated.contains(content)), "the first output was not observed"); + Thread.sleep(1_200); + heard.clear(); + + assertTrue(await(() -> heard.updated.contains(content)), "the in-place redraw was missed"); + } + } + + @Test + void outputInASecondSessionIsWatched(Server server) throws Exception { + Pane second = + server.newSession("watched-second").windows().getFirst().panes().getFirst(); + String content = Resources.paneContentUri(second.id()); + Heard heard = new Heard(); + + try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), heard)) { + assertTrue(watching.isAlive()); + second.sendLine("echo second-session-output"); + + assertTrue(await(() -> heard.updated.contains(content)), "the second session was not covered"); + } + } + + @Test + void concurrentProducersNeverCallTheProtocolNotifierConcurrently(Server server) throws Exception { + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch secondReturned = new CountDownLatch(1); + AtomicBoolean notifying = new AtomicBoolean(); + AtomicBoolean concurrent = new AtomicBoolean(); + Watches.Notifier slow = uri -> { + if (!notifying.compareAndSet(false, true)) { + concurrent.set(true); + } + entered.countDown(); + await(release); + notifying.set(false); + }; + + try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), slow)) { + Thread first = Thread.ofVirtual().start(() -> watching.output(new PaneId("%900"))); + assertTrue(entered.await(5, TimeUnit.SECONDS), "the first notification never started"); + Thread second = Thread.ofVirtual().start(() -> { + watching.output(new PaneId("%901")); + secondReturned.countDown(); + }); + + assertTrue(secondReturned.await(2, TimeUnit.SECONDS), "a slow client blocked a producer"); + assertFalse(concurrent.get(), "the protocol notifier was entered concurrently"); + release.countDown(); + first.join(); + second.join(); + } finally { + release.countDown(); + } + } + + @Test + void outageRetriesBackOffToACapAndRealActivityResetsThem() { + Watches.RetryBackoff backoff = new Watches.RetryBackoff(); + + assertEquals(Duration.ofMillis(250), backoff.delay()); + backoff.failedRetry(); + assertEquals(Duration.ofMillis(500), backoff.delay()); + for (int attempt = 0; attempt < 20; attempt++) { + backoff.failedRetry(); + } + assertEquals(Duration.ofSeconds(8), backoff.delay()); + backoff.reset(); + assertEquals(Duration.ofMillis(250), backoff.delay()); + } + + @Test + void aNewPanesFirstInvalidationComesAfterItsWatcherIsAttached(Server server) throws Exception { + CountDownLatch inspect = new CountDownLatch(1); + CountDownLatch contentAnnounced = new CountDownLatch(1); + AtomicReference content = new AtomicReference<>(); + AtomicReference session = new AtomicReference<>(); + AtomicBoolean attachedAtAnnouncement = new AtomicBoolean(); + Watches.Notifier notifier = uri -> { + await(inspect); + if (uri.equals(content.get())) { + attachedAtAnnouncement.set(server.snapshot().clients().stream() + .anyMatch(client -> + client.session().filter(session.get()::equals).isPresent())); + contentAnnounced.countDown(); + } + }; + + try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), notifier)) { + assertTrue(watching.isAlive()); + var addedSession = server.newSession("attached-before-announced"); + Pane added = addedSession.windows().getFirst().panes().getFirst(); + session.set(addedSession.id()); + content.set(Resources.paneContentUri(added.id())); + inspect.countDown(); + + assertTrue(contentAnnounced.await(10, TimeUnit.SECONDS), "the new pane was not invalidated"); + assertTrue(attachedAtAnnouncement.get(), "a client could refresh before output watching was active"); + } finally { + inspect.countDown(); + } + } + + @Test + void watchingRecoversAfterTheTmuxServerRestarts(Server server) throws Exception { + Heard heard = new Heard(); + + try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), heard)) { + server.killServer(); + Pane reborn = + server.newSession("reborn").windows().getFirst().panes().getFirst(); + String content = Resources.paneContentUri(reborn.id()); + reborn.sendLine("echo after-restart"); + + assertTrue(await(watching::isAlive), "the watcher never reattached"); + assertTrue(await(() -> heard.updated.contains(content)), "output after restart was not observed"); + } + } + /** * Watching attaches a client, and an attached client is exactly what "is anybody looking at this" * is answered with. This server's own watcher must not be mistaken for a person. @@ -80,14 +230,16 @@ void outputInAPaneNamesThatPanesContentAsStale(Server server) throws Exception { void theWatchersOwnClientIsNotReportedAsSomebodyWatching(Server server) throws Exception { Connection connection = Connection.to(server, Safety.MUTATING); - try (Watches watching = Watches.start(connection, new Heard()).orElseThrow()) { + try (Watches watching = Watches.start(connection, new Heard())) { assertTrue(watching.isAlive()); assertTrue(await(() -> !server.clients().isEmpty()), "the control client really did attach"); Listings.Clients clients = Listings.clients(new Call(connection, java.util.Map.of(), Call.Progress.SILENT)); + Listings.Sessions sessions = Listings.sessions(connection); assertEquals(0, clients.count(), "our own watcher is not a person watching"); assertTrue(String.valueOf(clients.note()).contains("no person is watching")); + assertFalse(sessions.sessions().getFirst().attached(), "our own watcher did not attach a person"); } } @@ -102,6 +254,17 @@ void withoutAWatcherEveryAttachedClientIsReported(Server server) { assertEquals(server.clients().size(), clients.count()); } + @Test + void explicitlyRequestedWatchingFailsLoudlyWhenNothingCanBeAttached(Server server) { + Connection connection = Connection.to(server, Safety.MUTATING); + server.killSession(server.sessions().get(0).name()); + + IllegalStateException refused = + assertThrows(IllegalStateException.class, () -> Watches.start(connection, new Heard())); + + assertTrue(String.valueOf(refused.getMessage()).contains("session"), refused.getMessage()); + } + private static boolean await(BooleanSupplier condition) throws InterruptedException { // tmux checks a subscription about once a second, so this has to outlast that. for (int attempt = 0; attempt < 100; attempt++) { @@ -112,4 +275,12 @@ private static boolean await(BooleanSupplier condition) throws InterruptedExcept } return false; } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } } From 74391507eb30906a1a134d4362667c19fa0cf459 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:08:23 -0500 Subject: [PATCH 07/77] Build(fix[matrix]): Restore aggregate gate why: The documented testTmuxMatrix command did not exist even though every module registered per-release lane tasks. what: - Create one root compatibility task - Let each matrix-enabled module contribute all supported tmux lanes --- build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts b/build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts index 89ecdd8..1cf1841 100644 --- a/build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts +++ b/build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts @@ -39,5 +39,8 @@ val laneTasks = } } -// The benchmark lives in its own module now, so nothing here has to exclude it and no module has -// to remember a tag to stay fast. +rootProject.tasks.maybeCreate("testTmuxMatrix").apply { + group = "verification" + description = "Runs every real-tmux test against every supported tmux release." + dependsOn(laneTasks) +} From 61161b088cbaf3f45de66946602111fa7a5ad91c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:09:06 -0500 Subject: [PATCH 08/77] Docs(docs[safety]): State MCP trust boundary why: Safety tiers can be mistaken for confinement even though mutating tools can run arbitrary commands in a pane. what: - State that the ceiling filters tools and protocol annotations - Name the OS, socket and container boundaries that constrain effects --- docs/guide/mcp.md | 5 +++++ libtmux-mcp/README.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 8cc0b5b..8df280a 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -176,6 +176,11 @@ Safety.DESTRUCTIVE.allows(Safety.MUTATING); // → true Safety.ofWireName("readonly"); // → READONLY ``` +The ceiling filters the tool catalog and supplies protocol hints; it does not +confine effects. `MUTATING` includes `tmux_run`, key input, and pasted text, so +it can run programs or delete data in a pane. Use a separate OS account, socket +permissions, or a container when effects must be contained. + A tool above the ceiling is never listed. A model cannot be tempted by a tool it never saw, and an error it can do nothing about is context spent for nothing. The server's instructions say plainly what is absent and how an operator would enable diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index c33097d..e48e36c 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -205,6 +205,11 @@ Safety.MUTATING.allows(Safety.DESTRUCTIVE); // → false Safety.ofWireName("destructive"); // → DESTRUCTIVE ``` +The ceiling filters the tool catalog and supplies protocol hints; it does not +confine effects. `MUTATING` includes `tmux_run`, key input, and pasted text, so +it can run programs or delete data in a pane. Use a separate OS account, socket +permissions, or a container when effects must be contained. + A tool above the ceiling is **not listed at all**, rather than listed and refused. A model cannot be tempted by a tool it never saw, and an error it can do nothing about is wasted context. The server's instructions say plainly what is From 80ea8002255d85e39c2c91c186627ae68cd09dfe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:24:35 -0500 Subject: [PATCH 09/77] Core(fix[identity]): Reject torn snapshots and stale winlinks Snapshot the server identity around hydration and retry one complete capture when the server is replaced. Preserve real listing and parse failures when the incarnation remains unchanged. Run version gates from the captured snapshot and guard winlink operations with one target-scoped tmux command so a stale session:index cannot act on its replacement. --- .../libtmux/it/CreationIntegrationTest.java | 21 ++ .../src/main/java/io/github/libtmux/Pane.java | 6 +- .../main/java/io/github/libtmux/Server.java | 66 ++++++- .../main/java/io/github/libtmux/Session.java | 3 +- .../main/java/io/github/libtmux/Window.java | 12 +- .../java/io/github/libtmux/HandleTest.java | 17 +- .../java/io/github/libtmux/ServerTest.java | 187 ++++++++++++++++++ 7 files changed, 297 insertions(+), 15 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CreationIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CreationIntegrationTest.java index 1108ccd..4e9290e 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/CreationIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/CreationIntegrationTest.java @@ -1,11 +1,13 @@ package io.github.libtmux.it; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import io.github.libtmux.Dimensions; +import io.github.libtmux.ObjectDoesNotExist; import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.Session; @@ -101,6 +103,25 @@ void replacingDestroysWhateverHeldThatIndex(Server server) { "the window that held the index is gone"); } + @Test + void staleWinlinkCannotSelectItsReplacement(Server server) { + Session session = server.sessions().get(0); + Window stale = session.newWindow(w -> w.named("stale")); + int held = stale.index().value(); + Window other = session.refresh().newWindow(w -> w.named("other")); + Window replacement = session.refresh() + .newWindow(w -> w.named("replacement").atIndex(held).replaceExisting()); + other.select(); + + assertAll( + () -> assertThrows(ObjectDoesNotExist.class, stale::select), + () -> assertEquals( + other.id(), + session.refresh().activeWindow().orElseThrow().id(), + "the stale handle must not select the replacement")); + assertNotEquals(stale.id(), replacement.id()); + } + /** * 3.2a takes {@code -c} on new-window and drops it, though it honours the same flag on * split-window. Refused there rather than sent, so the caller is never handed a window that diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 61ed5a6..61f8920 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -288,7 +288,7 @@ public List capture(Consumer configure) { * @throws UnsupportedTmuxVersion if the spec asks for something this server does not have */ public List capture(CaptureSpec spec) { - return server.run(snapshot, spec.argv(state.id().value(), server.version())) + return server.run(snapshot, spec.argv(state.id().value(), server.version(snapshot))) .stdout(); } @@ -411,7 +411,7 @@ private Window breakNamed(Optional wanted, String supplied) { new SessionId(fields.get(0)), new WindowIndex(Integer.parseInt(fields.get(2))), new WindowId(fields.get(1))); - if (server.version().equals(BREAK_PANE_NAMING_BROKEN)) { + if (server.version(snapshot).equals(BREAK_PANE_NAMING_BROKEN)) { // 3.7 took the name and ignored it, so the caller's choice is applied afterwards. wanted.ifPresent(name -> server.run(snapshot, List.of("rename-window", "-t", fields.get(1), name))); } @@ -455,7 +455,7 @@ public Pane split(Consumer configure) { * @throws UnsupportedTmuxVersion if the spec asks for something this server does not have */ public Pane split(SplitSpec spec) { - return created(server, snapshot, spec.argv(state.id().value(), CREATED.template(), server.version())); + return created(server, snapshot, spec.argv(state.id().value(), CREATED.template(), server.version(snapshot))); } /** diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index f008913..837ba19 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -463,6 +463,10 @@ public TmuxVersion version() { .orElseThrow(() -> new LibTmuxException("no tmux server is answering on this endpoint")); } + TmuxVersion version(ServerSnapshot snapshot) { + return snapshot.serverVersion().orElseGet(this::version); + } + /** Which server this is. Every handle taken from it is scoped by this. */ public ServerIdentity identity() { return identity; @@ -538,7 +542,7 @@ public CommandResult cmd(List argv, Duration timeout) { } /** - * Captures the whole hierarchy, in four listings whatever its size. + * Captures the whole hierarchy in four listings, retrying once if the server is replaced. * *

One server-wide listing per kind of object, so ordering and membership stay tmux's decision * rather than being re-derived from another listing's rows. @@ -550,7 +554,9 @@ public CommandResult cmd(List argv, Duration timeout) { */ public ServerSnapshot snapshot() { try { - return hydrateSnapshot(); + return hydrateSnapshot() + .or(this::hydrateSnapshot) + .orElseThrow(() -> new LibTmuxException("tmux server changed during snapshot capture")); } catch (LibTmuxException e) { throw e; } catch (RuntimeException e) { @@ -558,12 +564,35 @@ public ServerSnapshot snapshot() { } } - private ServerSnapshot hydrateSnapshot() { + private Optional hydrateSnapshot() { Optional observed = process(); if (observed.isEmpty()) { - return ServerSnapshot.of(Instant.now(), List.of(), List.of(), List.of(), List.of()); + return Optional.of(ServerSnapshot.of(Instant.now(), List.of(), List.of(), List.of(), List.of())); } ServerProcess process = observed.orElseThrow(); + ServerSnapshot captured; + try { + captured = captureSnapshot(process); + } catch (RuntimeException failure) { + Optional current; + try { + current = process(); + } catch (RuntimeException probeFailure) { + probeFailure.addSuppressed(failure); + throw probeFailure; + } + if (Optional.of(process).equals(current)) { + throw failure; + } + return Optional.empty(); + } + if (!Optional.of(process).equals(process())) { + return Optional.empty(); + } + return Optional.of(captured); + } + + private ServerSnapshot captureSnapshot(ServerProcess process) { List sessions = new ArrayList<>(); for (List row : rows(SESSIONS, "list-sessions")) { sessions.add(new SessionState( @@ -616,7 +645,10 @@ private ServerSnapshot hydrateSnapshot() { private Optional process() { CommandResult result = cmd("display-message", "-p", PROCESS.template()); if (!result.succeeded()) { - return Optional.empty(); + if (result.stderr().stream().anyMatch(Server::serverAbsent)) { + return Optional.empty(); + } + throw new LibTmuxException("tmux display-message failed: " + String.join("; ", result.stderr())); } if (result.stdout().size() != 1) { throw new LibTmuxException("tmux did not report exactly one server identity row"); @@ -629,6 +661,12 @@ private Optional process() { return Optional.of(new ServerProcess(Long.parseLong(pid), TmuxVersion.parse(fields.get(1)))); } + private static boolean serverAbsent(String message) { + return message.contains("no server running") + || message.contains("server exited unexpectedly") + || message.contains("(No such file or directory)"); + } + private record ServerProcess(long pid, TmuxVersion version) {} private static boolean bit(String value, String field) { @@ -726,6 +764,24 @@ CommandResult run(ServerSnapshot snapshot, List argv) { return result; } + CommandResult run(ServerSnapshot snapshot, WindowContext expected, List argv) { + long pid = snapshot.serverPid() + .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); + String target = expected.session().value() + ":" + expected.index().value(); + String stale = "libtmux-stale-winlink-" + pid + "-" + expected.window().value(); + String condition = "#{&&:#{==:#{pid}," + pid + "},#{==:#{window_id}," + + expected.window().value() + "}}"; + CommandResult result = + cmd(List.of("if-shell", "-F", "-t", target, condition, CommandStrings.stringify(argv), stale)); + if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { + throw new ObjectDoesNotExist("window " + expected.window() + " no longer exists here"); + } + if (!result.succeeded()) { + throw new LibTmuxException("tmux " + argv.get(0) + " failed: " + String.join("; ", result.stderr())); + } + return result; + } + ServerSnapshot refresh(ServerSnapshot previous) { ServerSnapshot fresh = snapshot(); if (!identity(previous).equals(identity(fresh))) { diff --git a/libtmux/src/main/java/io/github/libtmux/Session.java b/libtmux/src/main/java/io/github/libtmux/Session.java index aa90e5b..a401e41 100644 --- a/libtmux/src/main/java/io/github/libtmux/Session.java +++ b/libtmux/src/main/java/io/github/libtmux/Session.java @@ -76,6 +76,7 @@ public void selectWindow(Window window) { } server.run( snapshot, + window.context(), List.of( "select-window", "-t", @@ -162,7 +163,7 @@ public Window newWindow(Consumer configure) { */ public Window newWindow(WindowSpec spec) { List reported = server.run( - snapshot, spec.argv(state.id().value(), CREATED.template(), server.version())) + snapshot, spec.argv(state.id().value(), CREATED.template(), server.version(snapshot))) .stdout(); ServerSnapshot fresh = server.refresh(snapshot); if (reported.isEmpty()) { diff --git a/libtmux/src/main/java/io/github/libtmux/Window.java b/libtmux/src/main/java/io/github/libtmux/Window.java index d65dd26..23d1ead 100644 --- a/libtmux/src/main/java/io/github/libtmux/Window.java +++ b/libtmux/src/main/java/io/github/libtmux/Window.java @@ -80,7 +80,7 @@ public Optional activePane() { /** Makes this the active window of its session. */ public void select() { - server.run(snapshot, List.of("select-window", "-t", linkTarget())); + server.run(snapshot, state.context(), List.of("select-window", "-t", linkTarget())); } /** The server this window lives on. */ @@ -145,7 +145,7 @@ public Pane split(Consumer configure) { * @throws UnsupportedTmuxVersion if the spec asks for something this server does not have */ public Pane split(SplitSpec spec) { - return Pane.created(server, snapshot, spec.argv(target(), Pane.createdFormat(), server.version())); + return Pane.created(server, snapshot, spec.argv(target(), Pane.createdFormat(), server.version(snapshot))); } /** @@ -158,7 +158,8 @@ public Pane split(SplitSpec spec) { */ public String expand(String format) { Objects.requireNonNull(format, "format"); - List reported = server.run(snapshot, List.of("display-message", "-p", "-t", linkTarget(), format)) + List reported = server.run( + snapshot, state.context(), List.of("display-message", "-p", "-t", linkTarget(), format)) .stdout(); return reported.isEmpty() ? "" : reported.get(0); } @@ -190,7 +191,7 @@ public void linkTo(Session session) { * @throws LibTmuxException if this is the window's only link, which tmux refuses to remove */ public void unlink() { - server.run(snapshot, List.of("unlink-window", "-t", linkTarget())); + server.run(snapshot, state.context(), List.of("unlink-window", "-t", linkTarget())); } /** Moves this window into another session. */ @@ -199,6 +200,7 @@ public void moveTo(Session session) { server.requireSameIncarnation(snapshot, session.server(), session.snapshot()); server.run( snapshot, + state.context(), List.of("move-window", "-s", linkTarget(), "-t", session.id().value())); } @@ -214,7 +216,7 @@ public void rotate() { */ public void selectLayout(Layout layout) { Objects.requireNonNull(layout, "layout"); - layout.requireSupported(server.version()); + layout.requireSupported(server.version(snapshot)); server.run(snapshot, List.of("select-layout", "-t", target(), layout.tmuxName())); } diff --git a/libtmux/src/test/java/io/github/libtmux/HandleTest.java b/libtmux/src/test/java/io/github/libtmux/HandleTest.java index b3e6428..0706299 100644 --- a/libtmux/src/test/java/io/github/libtmux/HandleTest.java +++ b/libtmux/src/test/java/io/github/libtmux/HandleTest.java @@ -217,6 +217,19 @@ void linkSpecificOperationsKeepTheCapturedSessionAndIndex() { } } + @Test + void aHandleUsesTheVersionCapturedWithItsIdentity() { + CountingTransport transport = new CountingTransport("alpha"); + try (Server server = Server.using(config(ServerEndpoint.namedSocket("fixture")), transport)) { + Window window = server.windows().get(0); + int captured = transport.calls.get(); + + window.selectLayout(Layout.MAIN_HORIZONTAL_MIRRORED); + + assertEquals(captured + 1, transport.calls.get(), "the operation must not probe another server version"); + } + } + // ------------------------------------------------------------------------------- fixtures private static ServerConfig config(ServerEndpoint endpoint) { @@ -236,7 +249,9 @@ private static Server canned(ServerEndpoint endpoint) { } private static String last(CountingTransport transport) { - return transport.requests.get(transport.requests.size() - 1).argv().get(3); + List argv = + transport.requests.get(transport.requests.size() - 1).argv(); + return argv.get(argv.size() - 2); } /** diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 0475951..1531239 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -209,6 +209,96 @@ void moreThanOneAttachedClientStillMeansTheSessionIsAttached(@TempDir Path direc } } + @Test + void snapshotDistinguishesAnAbsentServerFromAnIdentityProbeFailure(@TempDir Path directory) throws IOException { + try (Server server = Server.using(config(directory), new RefusingTransport("permission denied"))) { + LibTmuxException failure = assertThrows(LibTmuxException.class, server::snapshot); + + assertTrue(String.valueOf(failure.getMessage()).contains("permission denied")); + } + for (String absent : List.of( + "no server running on /tmp/s", + "server exited unexpectedly", + "error connecting to /tmp/s (No such file or directory)")) { + try (Server server = Server.using(config(directory), new RefusingTransport(absent))) { + assertTrue(server.snapshot().sessions().isEmpty(), absent); + } + } + } + + @Test + void snapshotRetriesAChangedIncarnationAndKeepsOnlyTheSecondCapture(@TempDir Path directory) throws IOException { + String separator = RowFormat.of("field").separator(); + try (Server server = Server.using( + config(directory), + new SnapshotRaceTransport( + List.of("4242", "4343", "4343", "4343"), + List.of( + String.join(separator, "$0", "old", "0", "0"), + String.join(separator, "$1", "new", "0", "0"))))) { + var snapshot = server.snapshot(); + + assertEquals(4343L, snapshot.serverPid().orElseThrow()); + assertEquals( + List.of("new"), + snapshot.sessions().stream().map(session -> session.name()).toList()); + } + } + + @Test + void snapshotRetriesWhenTheReplacedServerMakesAListingFail(@TempDir Path directory) throws IOException { + try (Server server = + Server.using(config(directory), new ReplacementDuringCaptureTransport(CaptureFailure.LISTING))) { + var snapshot = server.snapshot(); + + assertEquals(4343L, snapshot.serverPid().orElseThrow()); + assertEquals("new", snapshot.sessions().get(0).name()); + } + } + + @Test + void snapshotRetriesWhenTheReplacedServerChangesThePaneRowShape(@TempDir Path directory) throws IOException { + try (Server server = + Server.using(config(directory), new ReplacementDuringCaptureTransport(CaptureFailure.PANE_SHAPE))) { + var snapshot = server.snapshot(); + + assertEquals(4343L, snapshot.serverPid().orElseThrow()); + assertEquals("new", snapshot.sessions().get(0).name()); + } + } + + @Test + void snapshotRejectsASecondReplacementDuringHydration(@TempDir Path directory) throws IOException { + String separator = RowFormat.of("field").separator(); + try (Server server = Server.using( + config(directory), + new SnapshotRaceTransport( + List.of("4242", "4343", "4343", "4545"), + List.of( + String.join(separator, "$0", "old", "0", "0"), + String.join(separator, "$1", "new", "0", "0"))))) { + LibTmuxException failure = assertThrows(LibTmuxException.class, server::snapshot); + + assertTrue(String.valueOf(failure.getMessage()).contains("changed during snapshot")); + } + } + + @Test + void snapshotRejectsASecondDisappearanceDuringHydration(@TempDir Path directory) throws IOException { + String separator = RowFormat.of("field").separator(); + try (Server server = Server.using( + config(directory), + new SnapshotRaceTransport( + List.of("4242", "4343", "4343", ""), + List.of( + String.join(separator, "$0", "old", "0", "0"), + String.join(separator, "$1", "new", "0", "0"))))) { + LibTmuxException failure = assertThrows(LibTmuxException.class, server::snapshot); + + assertTrue(String.valueOf(failure.getMessage()).contains("changed during snapshot")); + } + } + // -------------------------------------------------------------------------------- builders @Test @@ -339,4 +429,101 @@ public CommandResult execute(CommandRequest request) { @Override public void close() {} } + + private static final class SnapshotRaceTransport implements TmuxTransport { + + private final AtomicInteger identityReads = new AtomicInteger(); + private final AtomicInteger sessionReads = new AtomicInteger(); + private final List identities; + private final List sessionRows; + + SnapshotRaceTransport(List identities, List sessionRows) { + this.identities = identities; + this.sessionRows = sessionRows; + } + + @Override + public CommandResult execute(CommandRequest request) { + return switch (request.argv().get(0)) { + case "display-message" -> identity(identities.get(identityReads.getAndIncrement())); + case "list-sessions" -> + new CommandResult(0, List.of(sessionRows.get(sessionReads.getAndIncrement())), List.of()); + default -> new CommandResult(0, List.of(), List.of()); + }; + } + + private static CommandResult identity(String pid) { + if (pid.isEmpty()) { + return new CommandResult(1, List.of(), List.of("no server running on /tmp/s")); + } + return new CommandResult( + 0, List.of(String.join(RowFormat.of("field").separator(), pid, "3.6")), List.of()); + } + + @Override + public void close() {} + } + + private enum CaptureFailure { + LISTING, + PANE_SHAPE + } + + private static final class ReplacementDuringCaptureTransport implements TmuxTransport { + + private final AtomicInteger identityReads = new AtomicInteger(); + private final CaptureFailure failure; + + ReplacementDuringCaptureTransport(CaptureFailure failure) { + this.failure = failure; + } + + @Override + public CommandResult execute(CommandRequest request) { + boolean firstCapture = identityReads.get() == 1; + return switch (request.argv().get(0)) { + case "display-message" -> + identityReads.getAndIncrement() == 0 + ? identity("4242", failure == CaptureFailure.PANE_SHAPE ? "3.7" : "3.6") + : identity("4343", "3.6"); + case "list-sessions" -> { + if (firstCapture && failure == CaptureFailure.LISTING) { + yield new CommandResult(1, List.of(), List.of("server exited unexpectedly")); + } + yield new CommandResult( + 0, + List.of(firstCapture ? row("$0", "old", "0", "1") : row("$1", "new", "0", "0")), + List.of()); + } + case "list-windows" -> + new CommandResult( + 0, + firstCapture && failure == CaptureFailure.PANE_SHAPE + ? List.of(row("$0", "@0", "0", "old", "1", "1", "0", "80", "24", "layout")) + : List.of(), + List.of()); + case "list-panes" -> + new CommandResult( + 0, + firstCapture && failure == CaptureFailure.PANE_SHAPE + ? List.of(row( + "$0", "@0", "0", "%0", "0", "1", "sh", "80", "24", "", "/tmp", "7", "1", + "1", "1", "1")) + : List.of(), + List.of()); + default -> new CommandResult(0, List.of(), List.of()); + }; + } + + private static CommandResult identity(String pid, String version) { + return new CommandResult(0, List.of(row(pid, version)), List.of()); + } + + private static String row(String... fields) { + return String.join(RowFormat.of("field").separator(), fields); + } + + @Override + public void close() {} + } } From 4c86294f51a4fc4622393779b63053343cf325ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:50:21 -0500 Subject: [PATCH 10/77] Core(fix[lifecycle]): Own process cleanup before closing pipes Share one bounded process owner between subprocess and control carriers. Snapshot descendants before failure paths can close a pipe, keep later daemonized servers outside cleanup, and report incomplete reclamation without replacing the primary failure. Start pump workers lazily and redact rejected control argv. --- .../github/libtmux/control/ControlClient.java | 109 ++++------- .../github/libtmux/control/ControlWriter.java | 5 +- .../github/libtmux/internal/ProcessTree.java | 176 ++++++++++++++++++ .../libtmux/transport/ProcessTransport.java | 154 ++++----------- .../libtmux/transport/package-info.java | 8 +- .../libtmux/control/ControlClientTest.java | 54 ++++++ .../libtmux/control/ControlWriterTest.java | 22 +++ .../transport/ProcessTransportTest.java | 109 ++++++++++- 8 files changed, 436 insertions(+), 201 deletions(-) create mode 100644 libtmux/src/main/java/io/github/libtmux/internal/ProcessTree.java diff --git a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java index 44de960..858d0b2 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java @@ -5,6 +5,7 @@ import io.github.libtmux.ServerConfig; import io.github.libtmux.SessionId; import io.github.libtmux.batch.OperationOutcome; +import io.github.libtmux.internal.ProcessTree; import io.github.libtmux.transport.DispatchOutcome; import io.github.libtmux.transport.TmuxTimeoutException; import io.github.libtmux.transport.TmuxTransportException; @@ -45,6 +46,7 @@ public final class ControlClient implements AutoCloseable { private static final long EXIT_MILLIS = 5_000; private final Process process; + private final ProcessTree processTree; private final InputStream standardOutput; private final InputStream standardError; private final ControlWriter writer; @@ -59,11 +61,12 @@ public final class ControlClient implements AutoCloseable { private ControlClient(Process process) { this.process = process; + this.processTree = new ProcessTree(process); this.standardOutput = process.getInputStream(); this.standardError = process.getErrorStream(); BufferedWriter requests = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); - this.writer = new ControlWriter(requests, ControlWriter.DEFAULT_CAPACITY, ignored -> terminate()); + this.writer = new ControlWriter(requests, ControlWriter.DEFAULT_CAPACITY, this::terminate); this.reader = new Thread(this::read, "libtmux-control"); this.reader.setDaemon(false); this.errorReader = new Thread(this::drainErrors, "libtmux-control-stderr"); @@ -113,15 +116,17 @@ public static ControlClient attach(ServerConfig config, SessionId session, Durat try { reply = client.writer.await(attached); } catch (TmuxTimeoutException e) { - client.close(); + client.closeAfterFailure(e); throw e; } catch (TmuxTransportException e) { - client.close(); + client.closeAfterFailure(e); throw new LibTmuxException("could not attach the control client", e); } if (reply.outcome() != OperationOutcome.COMPLETE) { - client.close(); - throw new LibTmuxException("the control client did not become ready: " + reply.lines()); + LibTmuxException failure = + new LibTmuxException("the control client did not become ready: " + reply.lines()); + client.closeAfterFailure(failure); + throw failure; } client.writer.start(); return client; @@ -158,8 +163,7 @@ public ControlReply send(List argv, Duration timeout) { if (isCommandGroup(argv)) { // Refused before anything is written, so the stream stays in step and the caller can // send the commands one at a time — which is what this carrier is for. - throw new IllegalArgumentException( - "a control-mode request is one command, and this argv is several: " + argv); + throw new IllegalArgumentException("a control-mode request must contain one command"); } if (closed.get() || failed) { throw new IllegalStateException("control client is not usable"); @@ -227,21 +231,20 @@ public ControlReply unwatch(String name) { /** Ends the client, rejecting queued requests and resolving picked requests as uncertain. */ @Override public void close() { - if (!closed.compareAndSet(false, true)) { + boolean closeOwner = closed.compareAndSet(false, true); + if (closeOwner) { + processTree.captureDescendants(); + writer.close(); + closeSubscriptions(); + } + boolean reclaimed = processTree.terminate(); + if (!closeOwner) { + if (!reclaimed) { + throw new IllegalStateException("control process tree was not reclaimed"); + } return; } - List descendants = process.descendants().toList(); - writer.close(); - closeSubscriptions(); AtomicBoolean interrupted = new AtomicBoolean(Thread.interrupted()); - stop(descendants, interrupted); - process.destroy(); - if (!awaitExit(process, EXIT_MILLIS, interrupted)) { - process.destroyForcibly(); - awaitExit(process, EXIT_MILLIS, interrupted); - } - close(standardOutput); - close(standardError); if (!Thread.currentThread().equals(reader)) { join(reader, EXIT_MILLIS, interrupted); } @@ -252,6 +255,17 @@ public void close() { if (interrupted.get()) { Thread.currentThread().interrupt(); } + if (!reclaimed) { + throw new IllegalStateException("control process tree was not reclaimed"); + } + } + + private void closeAfterFailure(RuntimeException failure) { + try { + close(); + } catch (RuntimeException cleanup) { + failure.addSuppressed(cleanup); + } } // -------------------------------------------------------------------------------- protocol @@ -306,7 +320,6 @@ private void read() { // The client ended. Everything still waiting is resolved below. } finally { writer.readerEnded(); - terminate(); } } @@ -332,11 +345,12 @@ private void complete(OperationOutcome outcome, List block) { writer.complete(outcome, block); } - private void terminate() { + private void terminate(TmuxTransportException failure) { failed = true; closeSubscriptions(); - process.destroyForcibly(); - close(standardError); + if (!processTree.terminate()) { + failure.addSuppressed(new IllegalStateException("control process tree was not reclaimed")); + } } private void publish(String line) { @@ -382,55 +396,6 @@ private void announce(ControlEvent event) { offer(eventSubscriptions, event); } - private static boolean awaitExit(Process process, long millis, AtomicBoolean interrupted) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); - while (process.isAlive()) { - long left = deadline - System.nanoTime(); - if (left <= 0) { - return false; - } - try { - process.waitFor(Math.max(1, TimeUnit.NANOSECONDS.toMillis(left)), TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - interrupted.set(true); - } - } - return true; - } - - private static void stop(List descendants, AtomicBoolean interrupted) { - descendants.forEach(ProcessHandle::destroy); - if (awaitExit(descendants, 500, interrupted)) { - return; - } - descendants.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly); - awaitExit(descendants, 500, interrupted); - } - - private static boolean awaitExit(List processes, long millis, AtomicBoolean interrupted) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); - while (processes.stream().anyMatch(ProcessHandle::isAlive)) { - long left = deadline - System.nanoTime(); - if (left <= 0) { - return false; - } - try { - Thread.sleep(Math.max(1, Math.min(10, TimeUnit.NANOSECONDS.toMillis(left)))); - } catch (InterruptedException e) { - interrupted.set(true); - } - } - return true; - } - - private static void close(InputStream stream) { - try { - stream.close(); - } catch (IOException ignored) { - // Closing is best effort; process termination is the ownership boundary. - } - } - private static void join(Thread thread, long millis, AtomicBoolean interrupted) { long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); while (thread.isAlive()) { diff --git a/libtmux/src/main/java/io/github/libtmux/control/ControlWriter.java b/libtmux/src/main/java/io/github/libtmux/control/ControlWriter.java index a46b9c8..9bda23c 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlWriter.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlWriter.java @@ -88,6 +88,9 @@ void close() { } void join(long timeoutMillis) throws InterruptedException { + if (Thread.currentThread().equals(thread)) { + return; + } if (thread.getState() == Thread.State.NEW) { closeOutput(); return; @@ -214,12 +217,12 @@ private void stop(TmuxTransportException activeFailure, boolean notifyFailure) { if (dispatched != null) { dispatched.claimFailure(); } - thread.interrupt(); try { if (notifyFailure) { failed.accept(activeFailure); } } finally { + thread.interrupt(); for (Request request : queuedRequests) { request.cancel(notDispatched("control client closed before dispatch", null)); } diff --git a/libtmux/src/main/java/io/github/libtmux/internal/ProcessTree.java b/libtmux/src/main/java/io/github/libtmux/internal/ProcessTree.java new file mode 100644 index 0000000..3eb7bda --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/internal/ProcessTree.java @@ -0,0 +1,176 @@ +package io.github.libtmux.internal; + +import java.io.Closeable; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +/** Owns bounded, idempotent termination of one process and its observable descendants. */ +public final class ProcessTree { + + private static final Duration GRACEFUL_WAIT = Duration.ofMillis(250); + private static final Duration FORCIBLE_WAIT = Duration.ofSeconds(5); + + private final Process root; + private final Set descendants = new LinkedHashSet<>(); + private boolean captured; + private boolean captureFailed; + private boolean terminated; + private boolean reclaimed; + + /** Takes ownership of terminating {@code root} and descendants visible from it. */ + public ProcessTree(Process root) { + this.root = Objects.requireNonNull(root, "root"); + } + + /** Retains descendants before another cleanup step can make the root disappear. */ + public synchronized void captureDescendants() { + if (!captured && !terminated) { + rememberDescendants(); + captured = true; + } + } + + /** Terminates the known process tree, closes the root's streams, and reports reclamation. */ + public synchronized boolean terminate() { + if (terminated) { + return reclaimed; + } + AtomicBoolean interrupted = new AtomicBoolean(Thread.interrupted()); + try { + if (!captured) { + rememberDescendants(); + captured = true; + } + stopProcesses(interrupted); + boolean streamsClosed = closeStreams(); + reclaimed = !captureFailed + && streamsClosed + && !isAlive(root) + && descendants.stream().noneMatch(ProcessTree::isAlive); + } catch (RuntimeException e) { + reclaimed = false; + } finally { + terminated = true; + if (interrupted.get()) { + Thread.currentThread().interrupt(); + } + } + return reclaimed; + } + + private void stopProcesses(AtomicBoolean interrupted) { + signal(descendants, false); + signalRoot(false); + if (!awaitExit(this::allExited, GRACEFUL_WAIT, interrupted)) { + signal(descendants, true); + signalRoot(true); + awaitExit(this::allExited, FORCIBLE_WAIT, interrupted); + } + } + + private boolean allExited() { + return !isAlive(root) && descendants.stream().noneMatch(ProcessTree::isAlive); + } + + private static boolean awaitExit(BooleanSupplier exited, Duration timeout, AtomicBoolean interrupted) { + long deadline = System.nanoTime() + timeout.toNanos(); + while (!exited.getAsBoolean()) { + long left = deadline - System.nanoTime(); + if (left <= 0) { + return false; + } + try { + Thread.sleep(Math.max(1, Math.min(10, TimeUnit.NANOSECONDS.toMillis(left)))); + } catch (InterruptedException e) { + interrupted.set(true); + } + } + return true; + } + + private void rememberDescendants() { + try { + root.descendants().forEach(descendants::add); + } catch (UnsupportedOperationException | SecurityException e) { + // The root remains reclaimable when the platform cannot expose descendants. + } catch (RuntimeException e) { + captureFailed = true; + } + } + + private static void signal(Iterable descendants, boolean forcibly) { + List ordered = new ArrayList<>(); + descendants.forEach(ordered::add); + for (int index = ordered.size() - 1; index >= 0; index--) { + ProcessHandle descendant = ordered.get(index); + if (!isAlive(descendant)) { + continue; + } + try { + if (forcibly) { + descendant.destroyForcibly(); + } else { + descendant.destroy(); + } + } catch (RuntimeException ignored) { + // The bounded wait and final live check decide whether reclamation succeeded. + } + } + } + + private void signalRoot(boolean forcibly) { + if (!isAlive(root)) { + return; + } + try { + if (forcibly) { + root.destroyForcibly(); + } else { + root.destroy(); + } + } catch (RuntimeException ignored) { + // The bounded wait and final live check decide whether reclamation succeeded. + } + } + + private boolean closeStreams() { + boolean closed = close(root::getOutputStream); + closed &= close(root::getInputStream); + closed &= close(root::getErrorStream); + return closed; + } + + private static boolean close(Supplier stream) { + try { + stream.get().close(); + return true; + } catch (IOException | RuntimeException e) { + return false; + } + } + + private static boolean isAlive(Process process) { + try { + return process.isAlive(); + } catch (RuntimeException e) { + return true; + } + } + + private static boolean isAlive(ProcessHandle process) { + try { + return process.isAlive(); + } catch (RuntimeException e) { + return true; + } + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java index 52dbbde..a2799a3 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java @@ -1,5 +1,6 @@ package io.github.libtmux.transport; +import io.github.libtmux.internal.ProcessTree; import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; @@ -51,8 +52,6 @@ public final class ProcessTransport implements TmuxTransport { private static final int DEFAULT_BOUND = 4; private static final int DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; - private static final long GRACEFUL_MILLIS = 250; - private static final long FORCIBLE_MILLIS = 5_000; private static final long RECLAIM_MILLIS = 5_000; private static final long TERMINATION_SECONDS = 60; private static final ProcessStarter SYSTEM_STARTER = command -> new ProcessBuilder(command).start(); @@ -62,8 +61,8 @@ public final class ProcessTransport implements TmuxTransport { private final int maxOutputBytes; private final ProcessStarter starter; private final LongSupplier nanoTime; - private final Set live = ConcurrentHashMap.newKeySet(); - private final Set killedByClose = ConcurrentHashMap.newKeySet(); + private final Set live = ConcurrentHashMap.newKeySet(); + private final Set killedByClose = ConcurrentHashMap.newKeySet(); private final ReentrantLock gate = new ReentrantLock(); private final Condition quiesced = gate.newCondition(); @@ -105,7 +104,6 @@ public ProcessTransport(int maxConcurrentProcesses, int maxOutputBytes) { this.maxOutputBytes = maxOutputBytes; this.starter = Objects.requireNonNull(starter, "starter"); this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); - this.pumps.prestartAllCoreThreads(); } @Override @@ -114,7 +112,7 @@ public CommandResult execute(CommandRequest request) { requireDispatchable(request.argv()); long deadline = deadlineAfter(request.timeout()); admit(deadline); - Process process; + RunningProcess process; try { process = launch(request, deadline); } catch (RuntimeException e) { @@ -123,7 +121,7 @@ public CommandResult execute(CommandRequest request) { } Drains drains = null; try { - closeQuietly(process.getOutputStream(), null); + closeQuietly(process.process().getOutputStream(), null); drains = submit(process); return complete(process, drains, deadline); } finally { @@ -188,12 +186,12 @@ public void close() { private @Nullable ResourceNotReclaimed closeResources(AtomicBoolean interrupted) { @Nullable ResourceNotReclaimed failure = null; - for (Process process : live) { + for (RunningProcess process : live) { // Published before the kill, so the caller parked in waitFor can tell our signal from tmux's. killedByClose.add(process); try { - if (!destroyAndAwait(process, interrupted)) { - failure = recordFailure(failure, "tmux survived forcible destruction", null); + if (!process.tree().terminate()) { + failure = recordFailure(failure, "tmux process tree was not reclaimed", null); } } catch (RuntimeException e) { failure = recordFailure(failure, "could not destroy tmux", e); @@ -252,7 +250,7 @@ private void admit(long deadline) { } /** Starts and registers the child atomically with respect to {@link #close()}. */ - private Process launch(CommandRequest request, long deadline) { + private RunningProcess launch(CommandRequest request, long deadline) { gate.lock(); try { if (closed) { @@ -267,8 +265,9 @@ private Process launch(CommandRequest request, long deadline) { } try { Process process = starter.start(request.commandLine()); - live.add(process); - return process; + RunningProcess running = new RunningProcess(process, new ProcessTree(process)); + live.add(running); + return running; } catch (IOException e) { throw new TmuxTransportException("could not start tmux", DispatchOutcome.NOT_DISPATCHED, e); } finally { @@ -285,13 +284,13 @@ private Process launch(CommandRequest request, long deadline) { // ------------------------------------------------------------------------------ draining - private Drains submit(Process process) { + private Drains submit(RunningProcess process) { CountDownLatch finished = new CountDownLatch(2); CompletableFuture failure = new CompletableFuture<>(); try { return new Drains( - pumps.submit(new Pump(process.getInputStream(), process, maxOutputBytes, finished, failure)), - pumps.submit(new Pump(process.getErrorStream(), process, maxOutputBytes, finished, failure)), + pumps.submit(new Pump(process.process().getInputStream(), maxOutputBytes, finished, failure)), + pumps.submit(new Pump(process.process().getErrorStream(), maxOutputBytes, finished, failure)), finished, failure); } catch (RejectedExecutionException e) { @@ -299,7 +298,7 @@ private Drains submit(Process process) { } } - private CommandResult complete(Process process, Drains drains, long deadline) { + private CommandResult complete(RunningProcess process, Drains drains, long deadline) { awaitExitOrFailure(process, drains, deadline); if (killedByClose.contains(process)) { // This exit status is ours, not tmux's; returning it would read as tmux dying on a signal. @@ -307,12 +306,13 @@ private CommandResult complete(Process process, Drains drains, long deadline) { } byte[] out = collect(drains.stdout(), process, deadline); byte[] err = collect(drains.stderr(), process, deadline); - return new CommandResult(process.exitValue(), OutputDecoder.stdoutLines(out), OutputDecoder.stderrLines(err)); + return new CommandResult( + process.process().exitValue(), OutputDecoder.stdoutLines(out), OutputDecoder.stderrLines(err)); } - private void awaitExitOrFailure(Process process, Drains drains, long deadline) { + private void awaitExitOrFailure(RunningProcess process, Drains drains, long deadline) { try { - CompletableFuture.anyOf(process.onExit(), drains.failure()) + CompletableFuture.anyOf(process.process().onExit(), drains.failure()) .get(remainingNanos(deadline), TimeUnit.NANOSECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -330,7 +330,7 @@ private void awaitExitOrFailure(Process process, Drains drains, long deadline) { } } - private byte[] collect(Future drain, Process process, long deadline) { + private byte[] collect(Future drain, RunningProcess process, long deadline) { try { return drain.get(remainingNanos(deadline), TimeUnit.NANOSECONDS); } catch (InterruptedException e) { @@ -367,107 +367,21 @@ private static TmuxTimeoutException admissionTimeout() { // --------------------------------------------------------------------------- destruction /** Drains are deliberately not cancelled: killing the child is what actually ends the read. */ - private TmuxTransportException terminate(Process process, String message, @Nullable Throwable cause) { + private TmuxTransportException terminate(RunningProcess process, String message, @Nullable Throwable cause) { return reclaim(process, new TmuxTransportException(message, DispatchOutcome.UNKNOWN, cause)); } - private TmuxTimeoutException timeout(Process process, String message, @Nullable Throwable cause) { + private TmuxTimeoutException timeout(RunningProcess process, String message, @Nullable Throwable cause) { return reclaim(process, new TmuxTimeoutException(message, cause)); } - private T reclaim(Process process, T failure) { - AtomicBoolean interrupted = new AtomicBoolean(Thread.interrupted()); - if (!destroyAndAwait(process, interrupted)) { - failure.addSuppressed(new ResourceNotReclaimed("tmux survived forcible destruction")); - } - if (interrupted.get()) { - Thread.currentThread().interrupt(); + private T reclaim(RunningProcess process, T failure) { + if (!process.tree().terminate()) { + failure.addSuppressed(new ResourceNotReclaimed("tmux process tree was not reclaimed")); } return failure; } - /** - * Each wait retries across interruption. Returning early is how a child survives: one interrupt - * landing in the graceful wait would otherwise skip forcible destruction entirely, and the - * request's own cleanup then drops the last handle to it. - */ - private static boolean destroyAndAwait(Process process, AtomicBoolean interrupted) { - List descendants = descendants(process); - process.destroy(); - destroy(descendants, false); - if (awaitExit(process, descendants, GRACEFUL_MILLIS, interrupted)) { - return true; - } - descendants = union(descendants, descendants(process)); - process.destroyForcibly(); - destroy(descendants, true); - return awaitExit(process, descendants, FORCIBLE_MILLIS, interrupted); - } - - private static boolean awaitExit( - Process process, List descendants, long millis, AtomicBoolean interrupted) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); - while (process.isAlive()) { - long left = deadline - System.nanoTime(); - if (left <= 0) { - return false; - } - try { - process.waitFor(Math.max(1, TimeUnit.NANOSECONDS.toMillis(left)), TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - interrupted.set(true); - } - } - for (ProcessHandle descendant : descendants) { - while (descendant.isAlive()) { - long left = deadline - System.nanoTime(); - if (left <= 0) { - return false; - } - try { - descendant.onExit().get(left, TimeUnit.NANOSECONDS); - } catch (InterruptedException e) { - interrupted.set(true); - } catch (ExecutionException e) { - return !descendant.isAlive(); - } catch (TimeoutException e) { - return false; - } - } - } - return true; - } - - private static List descendants(Process process) { - try { - return process.descendants().toList(); - } catch (UnsupportedOperationException | SecurityException e) { - return List.of(); - } - } - - private static List union(List first, List second) { - Set all = ConcurrentHashMap.newKeySet(); - all.addAll(first); - all.addAll(second); - return List.copyOf(all); - } - - private static void destroy(List descendants, boolean forcibly) { - for (int index = descendants.size() - 1; index >= 0; index--) { - ProcessHandle descendant = descendants.get(index); - try { - if (forcibly) { - descendant.destroyForcibly(); - } else { - descendant.destroy(); - } - } catch (RuntimeException e) { - // The bounded wait below decides whether reclamation actually succeeded. - } - } - } - private static void awaitWhile(Condition condition, BooleanSupplier waiting, AtomicBoolean interrupted) { while (waiting.getAsBoolean()) { try { @@ -563,34 +477,32 @@ boolean reclaimed() { } } + private record RunningProcess(Process process, ProcessTree tree) {} + @FunctionalInterface interface ProcessStarter { Process start(List command) throws IOException; } - private record Pump( - InputStream source, - Process process, - int limit, - CountDownLatch finished, - CompletableFuture failure) + private record Pump(InputStream source, int limit, CountDownLatch finished, CompletableFuture failure) implements Callable { @Override public byte[] call() throws IOException { - try (source) { + try { ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(limit, 8_192)); byte[] buffer = new byte[8_192]; int total = 0; int read; while ((read = source.read(buffer)) >= 0) { if (read > limit - total) { - process.destroy(); throw new OutputLimitExceeded(limit); } output.write(buffer, 0, read); total += read; } - return output.toByteArray(); + byte[] result = output.toByteArray(); + source.close(); + return result; } catch (IOException | RuntimeException e) { failure.complete(e); throw e; diff --git a/libtmux/src/main/java/io/github/libtmux/transport/package-info.java b/libtmux/src/main/java/io/github/libtmux/transport/package-info.java index d600aaf..cc1be38 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/package-info.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/package-info.java @@ -1,9 +1,11 @@ /** * Running one tmux command and getting its output back. * - *

The transport is blocking and its caller may be a virtual thread. It owns every child process - * it starts: no child outlives the call that started it, and a call that cannot say whether tmux - * applied a command reports that uncertainty rather than inventing an exit status. + *

The transport is blocking and its caller may be a virtual thread. Cleanup owns the command + * process and descendants still visible when cleanup begins; incomplete reclamation is reported. A + * tmux server that has already daemonized is intentionally outside that snapshot. A call that + * cannot say whether tmux applied a command reports that uncertainty rather than inventing an exit + * status. * *

The package is null-marked: every type is non-null unless annotated otherwise. */ diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java index bb561fd..c020329 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java @@ -80,6 +80,22 @@ void aBackslashKeepsTheSemicolonInsteadOfEndingTheCommand() { assertFalse(ControlClient.isCommandGroup(List.of("display-message", "-p", "trailing\\;"))); } + @Test + void aRejectedCommandGroupDoesNotDiscloseItsArguments(@TempDir Path directory) throws Exception { + String secret = "pane-secret;"; + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + while IFS= read -r request; do :; done + """); + + try (ControlClient client = ControlClient.attach(config, new SessionId("$0"))) { + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> client.send(List.of("display-message", secret))); + + assertFalse(String.valueOf(failure.getMessage()).contains(secret)); + } + } + /** * The process carrier reaches tmux's argv parser and this one does not, so the backslash that * parser would consume is consumed here instead. Passing it on would deliver a different @@ -260,6 +276,36 @@ void closingAControlClientReclaimsDescendantsThatInheritedItsPipes(@TempDir Path } } + @Test + void aWriterFailureReclaimsDescendantsBeforeKillingTheControlProcess(@TempDir Path directory) throws Exception { + Path childFile = directory.resolve("child-pid"); + Path ready = directory.resolve("stdin-closed"); + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + sh -c 'trap "" HUP TERM; exec sleep 30' /dev/null 2>&1 & + printf '%s\n' "$!" > "${0%/*}/child-pid" + exec 0<&- + : > "${0%/*}/stdin-closed" + while :; do sleep 30; done + """); + long child = -1; + try { + try (ControlClient client = ControlClient.attach(config, new SessionId("$0"))) { + assertTrue(awaitFile(childFile), "the fake control client never started its descendant"); + assertTrue(awaitFile(ready), "the fake control client never closed its request pipe"); + child = Long.parseLong(Files.readString(childFile).trim()); + + assertThrows(TmuxTransportException.class, () -> client.send("list-windows")); + + assertTrue(awaitDead(child), "the failed control client orphaned its descendant"); + } + } finally { + if (child > 0) { + ProcessHandle.of(child).ifPresent(ProcessHandle::destroyForcibly); + } + } + } + private static ServerConfig fakeTmux(Path directory, String body) throws Exception { Path fakeTmux = directory.resolve("tmux"); Files.writeString(fakeTmux, "#!/bin/sh\n" + body); @@ -275,5 +321,13 @@ private static boolean awaitFile(Path file) throws InterruptedException { return Files.exists(file); } + private static boolean awaitDead(long pid) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false) && System.nanoTime() < deadline) { + Thread.sleep(10); + } + return !ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + } + private record InterruptedFailure(TmuxTransportException failure, boolean interrupted) {} } diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlWriterTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlWriterTest.java index 833c035..ddd1e54 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlWriterTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlWriterTest.java @@ -78,6 +78,26 @@ void oneDeadlineIncludesAWriteThatNeverReturns() throws Exception { writer.join(1_000); } + @Test + void failureCleanupRunsBeforeTheWriterCanCloseProcessInput() throws Exception { + BlockingWriter output = new BlockingWriter(); + AtomicReference closedBeforeCleanup = new AtomicReference<>(); + ControlWriter writer = writer(output, 1, ignored -> { + try { + closedBeforeCleanup.set(output.closed.await(200, TimeUnit.MILLISECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + closedBeforeCleanup.set(true); + } + }); + writer.start(); + + assertThrows(TmuxTransportException.class, () -> writer.exchange("active", Duration.ofMillis(100))); + + writer.join(1_000); + assertFalse(closedBeforeCleanup.get(), "the writer closed process input before failure cleanup began"); + } + @Test void closeDistinguishesPickedFromQueuedRequests() throws Exception { BlockingWriter output = new BlockingWriter(); @@ -205,6 +225,7 @@ private static class BlockingWriter extends Writer { final CountDownLatch entered = new CountDownLatch(1); final CountDownLatch release = new CountDownLatch(1); + final CountDownLatch closed = new CountDownLatch(1); @Override public void write(char[] data, int offset, int length) throws IOException { @@ -222,6 +243,7 @@ public void flush() {} @Override public void close() { + closed.countDown(); release.countDown(); } } diff --git a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java index 2aafcca..a39b047 100644 --- a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java +++ b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java @@ -39,9 +39,8 @@ /** * The contract every caller depends on, driven with an ordinary child rather than tmux. * - *

Nothing here is about tmux specifically: it is about owning a child process honestly. A call - * either produces an exact result, or it fails saying how certain it is that the command ran. No - * child ever outlives the call that started it. + *

The transport owns its direct process and the descendants visible when cleanup starts. A call + * either produces an exact result, or it fails saying how certain it is that the command ran. */ final class ProcessTransportTest { @@ -224,6 +223,70 @@ void outputOverflowIsReportedBeforeATermIgnoringChildsDeadline() { } } + @Test + void aPipeCloseFailureCannotBeReportedAsSuccess() { + StubProcess process = new StubProcess(new FailingCloseInputStream()); + process.finish(); + + try (ProcessTransport transport = new ProcessTransport(1, 1_024, command -> process, System::nanoTime)) { + TmuxTransportException failure = + assertThrows(TmuxTransportException.class, () -> transport.execute(shell("ignored", GENEROUS))); + + assertEquals(DispatchOutcome.UNKNOWN, failure.outcome()); + } + } + + @Test + void outputOverflowReclaimsDescendantsBeforeTheRootCanDisappear(@TempDir Path directory) throws Exception { + Path descendantPid = directory.resolve("descendant.pid"); + String script = "(trap '' HUP TERM; echo \"$BASHPID\" > \"$1.tmp\"; " + + "mv \"$1.tmp\" \"$1\"; exec sleep 30) /dev/null 2>&1 & " + + "while [ ! -f \"$1\" ]; do :; done; " + + "while :; do printf 1234567890; done"; + long descendant = -1; + CommandRequest request = new CommandRequest( + List.of("/bin/bash"), List.of("-c", script, "probe", descendantPid.toString()), GENEROUS); + + try (ProcessTransport transport = new ProcessTransport(1, 1_024)) { + assertThrows(TmuxTransportException.class, () -> transport.execute(request)); + assertTrue(awaitFile(descendantPid), "the overflowing process never started its descendant"); + descendant = Long.parseLong(Files.readString(descendantPid).trim()); + + assertTrue(awaitDead(descendant), "output overflow orphaned a descendant of the killed process"); + } finally { + if (descendant > 0) { + ProcessHandle.of(descendant).ifPresent(ProcessHandle::destroyForcibly); + } + } + } + + /** A tmux command may start its durable server only after cleanup has begun. */ + @Test + void cleanupDoesNotAdoptADescendantSpawnedAfterItsOwnershipSnapshot(@TempDir Path directory) throws Exception { + Path descendantPid = directory.resolve("detached.pid"); + String script = "trap '(trap \"\" HUP TERM; echo \"$BASHPID\" > \"$1.tmp\"; " + + "mv \"$1.tmp\" \"$1\"; exec sleep 30) /dev/null 2>&1 & " + + "while :; do :; done' TERM; " + + "while :; do sleep 30; done"; + CommandRequest request = new CommandRequest( + List.of("/bin/bash"), List.of("-c", script, "probe", descendantPid.toString()), Duration.ofMillis(250)); + long descendant = -1; + + try (ProcessTransport transport = new ProcessTransport()) { + assertThrows(TmuxTransportException.class, () -> transport.execute(request)); + assertTrue(awaitFile(descendantPid), "the cleanup-time descendant never started"); + descendant = Long.parseLong(Files.readString(descendantPid).trim()); + + assertTrue( + ProcessHandle.of(descendant).map(ProcessHandle::isAlive).orElse(false), + "cleanup adopted a descendant created after its ownership snapshot"); + } finally { + if (descendant > 0) { + ProcessHandle.of(descendant).ifPresent(ProcessHandle::destroyForcibly); + } + } + } + @Test void anInterruptedCallerReportsUnknownAndKeepsItsInterrupt() throws InterruptedException { try (ProcessTransport transport = new ProcessTransport()) { @@ -516,12 +579,24 @@ void interruptedReclamationReturnsItsAdmissionPermit() throws Exception { // ---------------------------------------------------------------------------- process hygiene + @Test + void anIdleTransportStartsNoPumpThreads() { + long before = pumpThreads(); + ProcessTransport transport = new ProcessTransport(2); + + try { + assertEquals(before, pumpThreads(), "an idle transport does not need process-pipe workers"); + } finally { + transport.close(); + } + } + /** * The probe proves itself before it is trusted: a gate that cannot observe a live child would * report every leak as clean. */ @Test - void noChildOutlivesTheCallThatStartedIt() throws Exception { + void aTimedOutCallReclaimsItsObservableProcessTree() throws Exception { String marker = "libtmux-probe-" + UUID.randomUUID(); Process control = new ProcessBuilder("/bin/sh", "-c", "sleep 30 # " + marker).start(); @@ -567,6 +642,20 @@ private static Optional marked(String marker) { .findAny(); } + private static long pumpThreads() { + return Thread.getAllStackTraces().keySet().stream() + .filter(thread -> thread.getName().startsWith("libtmux-pump-")) + .count(); + } + + private static boolean awaitDead(long pid) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false) && System.nanoTime() < deadline) { + Thread.sleep(10); + } + return !ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + } + private static boolean awaitFile(Path file) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); while (!Files.exists(file) && System.nanoTime() < deadline) { @@ -632,6 +721,18 @@ void release() { } } + private static final class FailingCloseInputStream extends ByteArrayInputStream { + + FailingCloseInputStream() { + super(new byte[0]); + } + + @Override + public void close() throws IOException { + throw new IOException("pipe close failed"); + } + } + private static final class StubProcess extends Process { private final OutputStream stdin = new ByteArrayOutputStream(); private final InputStream stdout; From 5ce5fe56ac945d5ed54d45a3a79d32db4584941f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:50:39 -0500 Subject: [PATCH 11/77] MCP(test[whoami]): Count identity fence why: Snapshot consistency now samples server identity on both sides of hydration, so the whoami command-count guard was stale. what: - Expect four listings, two identity probes, and one socket lookup - Keep the guard exact so handle-by-handle traversal still fails --- .../test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index 6858ec3..29c4f67 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -187,7 +187,7 @@ public void close() {} Listings.Whoami whoami = Listings.whoami(measured, Caller.nowhere(), Safety.MUTATING); assertEquals(1, whoami.sessions()); - assertTrue(commands.get() <= 6, "whoami dispatched " + commands.get() + " tmux commands"); + assertEquals(7, commands.get(), "one identity-fenced snapshot and one socket-path read"); } } } From b10572d252f08c714975d50c6b34b7cf5a12704d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:57:47 -0500 Subject: [PATCH 12/77] MCP(fix[safety]): Report destructive effects why: The mutating ceiling includes arbitrary commands and input, but its tools advertised destructiveHint=false, which promises additive-only updates and can suppress client confirmation. what: - Declare each tool's update effect separately from its availability ceiling - Mark all 16 non-additive tools destructive, including mutating-tier commands - Verify direct and stdio hints and correct the safety documentation --- docs/guide/mcp.md | 10 +++-- libtmux-mcp/README.md | 15 ++++---- .../java/io/github/libtmux/mcp/Catalog.java | 31 ++++++++++++++++ .../io/github/libtmux/mcp/Instructions.java | 6 +-- .../java/io/github/libtmux/mcp/Safety.java | 15 ++++---- .../java/io/github/libtmux/mcp/ToolSpec.java | 33 +++++++++++------ .../io/github/libtmux/mcp/CatalogTest.java | 37 +++++++++++++++++-- .../java/io/github/libtmux/mcp/MainTest.java | 2 +- .../github/libtmux/mcp/McpLauncherTest.java | 1 + 9 files changed, 112 insertions(+), 38 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 8df280a..6578d5d 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -176,10 +176,12 @@ Safety.DESTRUCTIVE.allows(Safety.MUTATING); // → true Safety.ofWireName("readonly"); // → READONLY ``` -The ceiling filters the tool catalog and supplies protocol hints; it does not -confine effects. `MUTATING` includes `tmux_run`, key input, and pasted text, so -it can run programs or delete data in a pane. Use a separate OS account, socket -permissions, or a container when effects must be contained. +The ceiling filters the tool catalog; it does not confine effects. `MUTATING` +includes `tmux_run`, key input, and pasted text, so it can run programs or +delete data in a pane. Use a separate OS account, socket permissions, or a +container when effects must be contained. MCP effect hints are declared +separately, so a tool can remain available at this ceiling while warning that +its update may be destructive. A tool above the ceiling is never listed. A model cannot be tempted by a tool it never saw, and an error it can do nothing about is context spent for nothing. The diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index e48e36c..fb06611 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -205,19 +205,20 @@ Safety.MUTATING.allows(Safety.DESTRUCTIVE); // → false Safety.ofWireName("destructive"); // → DESTRUCTIVE ``` -The ceiling filters the tool catalog and supplies protocol hints; it does not -confine effects. `MUTATING` includes `tmux_run`, key input, and pasted text, so -it can run programs or delete data in a pane. Use a separate OS account, socket -permissions, or a container when effects must be contained. +The ceiling filters the tool catalog; it does not confine effects. `MUTATING` +includes `tmux_run`, key input, and pasted text, so it can run programs or +delete data in a pane. Use a separate OS account, socket permissions, or a +container when effects must be contained. A tool above the ceiling is **not listed at all**, rather than listed and refused. A model cannot be tempted by a tool it never saw, and an error it can do nothing about is wasted context. The server's instructions say plainly what is missing and why, so a model does not spend a turn looking for it. -Every tool also carries MCP's own annotations — `readOnlyHint`, `destructiveHint`, -`idempotentHint` — derived from its tier rather than stated per tool, so a tool -that kills a session cannot describe itself as read-only by forgetting to. +Every tool carries MCP's own effect hints — `readOnlyHint`, `destructiveHint`, +`idempotentHint`, and `openWorldHint` — independently of the ceiling. A command +tool can stay at the `MUTATING` ceiling while truthfully warning that its update +may be destructive. ## Resources, prompts, completion diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index 4ee3f82..2195076 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -7,6 +7,8 @@ import static io.github.libtmux.mcp.Argument.required; import static io.github.libtmux.mcp.Argument.seconds; import static io.github.libtmux.mcp.Argument.strings; +import static io.github.libtmux.mcp.ToolSpec.Effect.DESTRUCTIVE; +import static io.github.libtmux.mcp.ToolSpec.Effect.READ_ONLY; import io.github.libtmux.jackson.FilterJson; import io.github.libtmux.jackson.LibTmuxModels; @@ -57,6 +59,7 @@ private static void discovery(List tools) { + "the only way to learn which pane belongs to this conversation, and that pane is the " + "one never to kill or type into.", Safety.READONLY, + READ_ONLY, List.of(), call -> Listings.whoami(call.server(), call.caller(), call.ceiling()))); @@ -68,6 +71,7 @@ private static void discovery(List tools) { + "are not on this server: separate sockets cannot see each other. A truncated answer says " + "the scan cap left directory entries uninspected.", Safety.READONLY, + READ_ONLY, List.of(), call -> Listings.servers(call.server()))); @@ -76,6 +80,7 @@ private static void discovery(List tools) { "List sessions", "Lists sessions on this server with the windows in each.", Safety.READONLY, + READ_ONLY, List.of(), call -> Listings.sessions(call.connection()))); @@ -84,6 +89,7 @@ private static void discovery(List tools) { "List windows", "Lists windows with the id other tools take, optionally only those in one session.", Safety.READONLY, + READ_ONLY, List.of(optional("session", "Only windows in this session. Omit for every window on the server.")), Listings::windows)); @@ -94,6 +100,7 @@ private static void discovery(List tools) { + "where. Optionally narrowed by a filter document. This reads metadata, not screen " + "contents: to find a pane by what it is showing, use tmux_search_panes.", Safety.READONLY, + READ_ONLY, List.of(new Argument( "filter", "object", @@ -114,6 +121,7 @@ private static void discovery(List tools) { "Lists the terminals attached to this server. Use it to find out whether a person is " + "watching a session before changing what it is showing.", Safety.READONLY, + READ_ONLY, List.of(), Listings::clients)); } @@ -128,6 +136,7 @@ private static void reading(List tools) { + "pane again, pass that cursor to tmux_capture_since instead of calling this repeatedly " + "— this returns the whole screen every time.", Safety.READONLY, + READ_ONLY, List.of( paneId(), flag("history", "Include the pane's scrollback, not only the visible screen.", false), @@ -142,6 +151,7 @@ private static void reading(List tools) { + "costs the few lines it added, not the nine screens already read. Omit the cursor to " + "start from what the pane shows now.", Safety.READONLY, + READ_ONLY, List.of( paneId(), optional("cursor", "The cursor from a previous call on this pane. Omit to start here."), @@ -155,6 +165,7 @@ private static void reading(List tools) { + "answer \"which pane has the server in it\". Searches the visible screen, not " + "scrollback, so text that has scrolled away is not found.", Safety.READONLY, + READ_ONLY, List.of( required("pattern", "The text to look for."), flag("regex", "Treat the pattern as a regular expression rather than plain text.", false), @@ -176,6 +187,7 @@ private static void waiting(List tools) { + "command runs in a subshell of the pane's shell, so it sees that shell's environment " + "but a 'cd' or an export in it does not outlive the call — and neither does an 'exit'.", Safety.MUTATING, + DESTRUCTIVE, List.of( paneId(), required("command", "The shell command, run in the pane's own interactive shell."), @@ -203,6 +215,7 @@ private static void waiting(List tools) { + "there is one: without it a run that fails is waited on until the deadline. If you " + "wrote the command, use tmux_run instead.", Safety.READONLY, + READ_ONLY, List.of( paneId(), strings( @@ -228,6 +241,7 @@ private static void waiting(List tools) { + "tmux_send_keys, then wait here. The answer says why the wait ended, because tmux " + "reports a server that died under a waiter as a successful wake.", Safety.MUTATING, + DESTRUCTIVE, List.of( required("channel", "The channel name, which everything on this server shares."), seconds("timeout", "Seconds to wait before giving up.", 30), @@ -244,6 +258,7 @@ private static void waiting(List tools) { "Wakes whatever is waiting on a tmux channel. A signal sent when nothing is waiting is " + "remembered and satisfies the next wait.", Safety.MUTATING, + DESTRUCTIVE, List.of(required("channel", "The channel name.")), Channels::signal)); @@ -253,6 +268,7 @@ private static void waiting(List tools) { "Consumes a signal already waiting on a channel, so a leftover one cannot satisfy a wait " + "that has not happened yet.", Safety.MUTATING, + DESTRUCTIVE, List.of(required("channel", "The channel name.")), Channels::drain)); } @@ -267,6 +283,7 @@ private static void typing(List tools) { + "'Up' for the previous command. This is for controlling a program, not for running " + "commands: a command you wrote belongs in tmux_run, which waits for it.", Safety.MUTATING, + DESTRUCTIVE, List.of( paneId(), strings("keys", "The keys, as tmux names them, for example [\"C-c\"] or [\"y\", \"Enter\"]."), @@ -280,6 +297,7 @@ private static void typing(List tools) { + "anything that spells a key name arrive as the characters they are. Use it for an " + "editor, a REPL, or a here-document.", Safety.MUTATING, + DESTRUCTIVE, List.of( paneId(), required("text", "The text to paste."), @@ -295,6 +313,7 @@ private static void shaping(List tools) { "Create a session", "Creates a detached session and returns its first pane's id.", Safety.MUTATING, + DESTRUCTIVE, List.of( required("name", "The session name."), optional("path", "The directory its first pane starts in."), @@ -306,6 +325,7 @@ private static void shaping(List tools) { "Create a window", "Creates a window in a session without switching to it, and returns its first pane's id.", Safety.MUTATING, + DESTRUCTIVE, List.of( required("session", "The session to create it in."), optional("name", "The window name. Omit to let tmux name it after what runs in it."), @@ -319,6 +339,7 @@ private static void shaping(List tools) { "Splits a pane in two and returns the id of the new one. The direction says where the new " + "pane goes.", Safety.MUTATING, + DESTRUCTIVE, List.of( paneId(), optional("direction", "Where the new pane goes: below, above, left or right."), @@ -335,6 +356,7 @@ private static void shaping(List tools) { + "description tmux would refuse is refused before anything is half-built. Example:\n" + Workspaces.example(), Safety.MUTATING, + DESTRUCTIVE, List.of(required("workspace", "The YAML document describing the session.")), Workspaces::apply)); @@ -343,6 +365,7 @@ private static void shaping(List tools) { "Rename a window or session", "Renames a window given its @id, or a session given its name.", Safety.MUTATING, + DESTRUCTIVE, List.of( required("target", "A window id such as @1, or a session name."), required("name", "The new name.")), @@ -355,6 +378,7 @@ private static void shaping(List tools) { + "sees. Not needed to read or act on something: every other tool takes an id and works " + "whether or not the target is active.", Safety.MUTATING, + DESTRUCTIVE, List.of(required("target", "A pane id such as %1, or a window id such as @1.")), Shaping::select)); @@ -363,6 +387,7 @@ private static void shaping(List tools) { "Rearrange a window's panes", "Applies one of tmux's layouts to a window: " + String.join(", ", Shaping.layoutNames()) + ".", Safety.MUTATING, + DESTRUCTIVE, List.of(required("window_id", "The window id, such as @1."), required("layout", "The layout name.")), Shaping::selectLayout)); @@ -373,6 +398,7 @@ private static void shaping(List tools) { + "give up what it takes, so the size that results may not be the one asked for — the " + "answer says what it actually became.", Safety.MUTATING, + DESTRUCTIVE, List.of( paneId(), number("width", "Width in cells. Omit to leave it.", 0), @@ -389,6 +415,7 @@ private static void settings(List tools) { "Reads a set of tmux options. tmux keeps four sets and lets a lower one override the one " + "above, so say which scope you mean: global, server, session, window or pane.", Safety.READONLY, + READ_ONLY, List.of( optional("scope", "global, server, session, window or pane. Defaults to global."), optional("target", "Which session, window or pane, when the scope is one of those."), @@ -405,6 +432,7 @@ private static void settings(List tools) { + "not overridden it, including panes a person is using, so prefer the narrowest scope " + "that does what you need.", Safety.MUTATING, + DESTRUCTIVE, List.of( required("name", "The option name."), required("value", "The value to set."), @@ -418,6 +446,7 @@ private static void settings(List tools) { "Reads the hooks set in a scope. Read-only: a hook set over MCP would be gone when this " + "server restarts, so one that should last belongs in a tmux config file.", Safety.READONLY, + READ_ONLY, List.of( optional("scope", "global, server, session, window or pane. Defaults to global."), optional("target", "Which session, window or pane, when the scope is one of those.")), @@ -429,6 +458,7 @@ private static void settings(List tools) { "Reads the environment tmux passes to programs it starts, globally or for one session. This " + "is what a new pane will inherit, not what a running program currently has.", Safety.READONLY, + READ_ONLY, List.of(optional("session", "The session to read. Omit for the global environment.")), Settings::environment)); } @@ -444,6 +474,7 @@ private static void ending(List tools) { + "conversation is running through unless confirm_self is set — call tmux_whoami to see " + "which pane that is.", Safety.DESTRUCTIVE, + DESTRUCTIVE, List.of( required( "target", diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java index e32c267..556e9e3 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Instructions.java @@ -87,9 +87,9 @@ private static String ending(Safety ceiling) { + "keys, no creating or killing. Ask the operator to raise LIBTMUX_SAFETY if a change " + "is genuinely needed.\n"; case MUTATING -> - "\nSAFETY\nThis server can read and change tmux but cannot destroy: killing a " - + "pane, window, session or server is not offered. Ask the operator to set " - + "LIBTMUX_SAFETY=destructive if something really has to be ended.\n"; + "\nSAFETY\nThis server can read and change tmux, but tmux_kill is not offered. " + + "Commands and pane input can still end processes or delete data. Ask the operator to set " + + "LIBTMUX_SAFETY=destructive only when the dedicated kill tool is needed.\n"; case DESTRUCTIVE -> "\nSAFETY\nEverything is offered, including tmux_kill, which ends processes " + "and cannot be undone. It refuses to end the pane this conversation runs through " diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Safety.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Safety.java index 26f04e1..ffbc20f 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Safety.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Safety.java @@ -3,25 +3,24 @@ import java.util.Locale; /** - * How much damage a tool can do, and how much a server is willing to offer. + * Which classes of tool a server is willing to offer. * - *

The same scale answers both questions, so a launcher configured at {@link #READONLY} serves - * exactly the tools whose safety is {@code READONLY}. A tool above the configured ceiling is not - * listed at all rather than listed and refused: a model cannot be tempted by a tool it never saw, - * and an error it can do nothing about is wasted context. + *

This is an availability ceiling, not an effect annotation. {@link #MUTATING} includes commands + * and pane input whose effects may be destructive; their MCP annotations say so independently. A + * tool above the configured ceiling is not listed at all rather than listed and refused. * *

The names are the ones every port of libtmux uses, so an operator who has configured one has * configured all of them. */ public enum Safety { - /** Reads state. Running it twice tells you the same thing and changes nothing. */ + /** Offers only tools that read state. */ READONLY(0), - /** Changes state a user could undo: sends keys, creates windows, sets options. */ + /** Also offers tools that change state, run commands, or send input. */ MUTATING(1), - /** Destroys something that does not come back: kills a pane, a session, or the server. */ + /** Also offers dedicated tools that end a pane, session, or server. */ DESTRUCTIVE(2); /** diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSpec.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSpec.java index da9f8a1..5149baa 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSpec.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ToolSpec.java @@ -5,16 +5,17 @@ import java.util.function.Function; /** - * One tool: what a model is told about it, what it may do, and what it does. + * One tool: what a model is told about it, what an operator offers, and what it does. * - *

Declared in one place so the three never drift apart. A tool whose {@link Safety} says it - * destroys something also carries the annotation that says so, without anyone remembering to add - * it. + *

The operator's {@link Safety} ceiling and the protocol's {@link Effect} hint are separate: a + * tool may remain useful at a mutating ceiling while still warning a client that its update can be + * destructive. * * @param name the wire name, prefixed {@code tmux_} so it reads unambiguously beside other servers' * @param title what a client shows a person * @param description what a model reads to decide whether this is the tool it wants - * @param safety how much damage it can do + * @param safety the narrowest operator ceiling that offers it + * @param effect how it may update its environment * @param arguments what it takes * @param answer what it does, given the arguments a model sent */ @@ -23,32 +24,40 @@ record ToolSpec( String title, String description, Safety safety, + Effect effect, List arguments, Function answer) { + enum Effect { + READ_ONLY, + ADDITIVE, + DESTRUCTIVE + } + static ToolSpec of( String name, String title, String description, Safety safety, + Effect effect, List arguments, Function answer) { - return new ToolSpec(name, title, description, safety, List.copyOf(arguments), answer); + return new ToolSpec(name, title, description, safety, effect, List.copyOf(arguments), answer); } /** * The tool as the protocol describes it. * - *

The hints are derived from {@link Safety} rather than stated per tool. A client uses them to - * decide what to confirm with a person, so a tool that kills a session must never be able to - * describe itself as read-only by omission. + *

A client uses these hints to decide what to confirm with a person. {@link Safety} cannot + * supply them: it controls availability, while {@link Effect} describes the updates a call may + * make. */ McpSchema.Tool describe() { McpSchema.ToolAnnotations annotations = new McpSchema.ToolAnnotations( title, - safety == Safety.READONLY, - safety == Safety.DESTRUCTIVE, - safety == Safety.READONLY, + effect == Effect.READ_ONLY, + effect == Effect.DESTRUCTIVE, + effect == Effect.READ_ONLY, // tmux is a world this server does not own: another client may change it between two // calls, and a pane's contents come from programs nobody here started. true, diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java index bc8cf56..85f6ad1 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CatalogTest.java @@ -51,11 +51,42 @@ void whatAToolCanDestroyIsDeclaredToTheClient() { assertEquals( tool.safety() == Safety.READONLY, annotations.readOnlyHint(), - tool.name() + " disagrees with its own safety about being read-only"); + tool.name() + " disagrees with its ceiling about being read-only"); assertEquals( - tool.safety() == Safety.DESTRUCTIVE, + tool.effect() == ToolSpec.Effect.DESTRUCTIVE, annotations.destructiveHint(), - tool.name() + " disagrees with its own safety about being destructive"); + tool.name() + " disagrees with its declared effect about being destructive"); + } + } + + @Test + void toolsThatCanReplaceOrRemoveStateDeclareTheirFullEffect() { + for (String name : List.of( + "tmux_run", + "tmux_wait_for_channel", + "tmux_signal_channel", + "tmux_drain_channel", + "tmux_send_keys", + "tmux_paste_text", + "tmux_new_session", + "tmux_new_window", + "tmux_split_pane", + "tmux_apply_workspace", + "tmux_rename", + "tmux_select", + "tmux_select_layout", + "tmux_resize_pane", + "tmux_set_option", + "tmux_kill")) { + ToolSpec tool = + Objects.requireNonNull(Catalog.offered(Safety.DESTRUCTIVE).get(name), name); + McpSchema.ToolAnnotations annotations = + Objects.requireNonNull(tool.describe().annotations(), name); + + assertEquals(false, annotations.readOnlyHint(), name); + assertEquals(true, annotations.destructiveHint(), name); + assertEquals(false, annotations.idempotentHint(), name); + assertEquals(true, annotations.openWorldHint(), name); } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java index 71299ae..db91ca4 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/MainTest.java @@ -98,7 +98,7 @@ void theSafetyCeilingIsReadFromTheFlag() { assertEquals(Safety.DESTRUCTIVE, Main.safety(List.of("--socket-name", "work", "--safety", "destructive"))); } - /** Left unsaid, a server reads and changes tmux but cannot destroy anything. */ + /** Left unsaid, a server offers changes but not the dedicated kill tool. */ @Test void theCeilingLeftUnsaidStopsShortOfDestroying() { assertEquals(Safety.MUTATING, Main.safety(List.of("--socket-name", "work"))); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java index 4fc6ee7..6bbca4b 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java @@ -241,6 +241,7 @@ void everyToolArrivesWithItsRiskDeclared(Server server, TmuxSocketPath socket) { assertEquals(true, reading.annotations().readOnlyHint(), "reading a pane changes nothing"); assertEquals(false, running.annotations().readOnlyHint(), "running a command does"); + assertEquals(true, running.annotations().destructiveHint(), "a shell command may delete data"); } } From 9e1707889dfe7c4680f19a71060f603aab85b324 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:01:49 -0500 Subject: [PATCH 13/77] Core(fix[buffers]): Preserve exact buffer arguments Fail closed on tmux 3.2a and 3.3a because deleting an absent named buffer removes the top buffer. Protect trailing semicolons from tmux's command-group parser. --- .../it/BuffersAndClientIntegrationTest.java | 54 +++++++++++++++---- .../main/java/io/github/libtmux/Buffers.java | 38 ++++++++++--- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java index cee0b41..9529fdc 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java @@ -1,7 +1,6 @@ package io.github.libtmux.it; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -12,6 +11,8 @@ import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.Session; +import io.github.libtmux.TmuxVersion; +import io.github.libtmux.UnsupportedTmuxVersion; import io.github.libtmux.control.ControlClient; import io.github.libtmux.junit5.TmuxExtension; import java.nio.file.Files; @@ -29,6 +30,8 @@ @ExtendWith(TmuxExtension.class) final class BuffersAndClientIntegrationTest { + private static final TmuxVersion EXACT_NAMED_DELETE = new TmuxVersion(3, 4, ""); + // -------------------------------------------------------------------------------- buffers @Test @@ -42,6 +45,15 @@ void aBufferRoundTripsThroughTheServer(Server server) { "the buffer is in the listing"); } + @Test + void aTrailingSemicolonIsPartOfTheBufferName(Server server) { + server.buffers().set("literal;", "not a command separator;"); + + assertEquals("not a command separator;", server.buffers().show("literal;")); + assertTrue(server.buffers().list().stream() + .anyMatch(buffer -> buffer.name().equals("literal;"))); + } + @Test void aListingReportsEachBuffersSize(Server server) { server.buffers().set("sized", "12345"); @@ -66,24 +78,46 @@ void aBufferThatIsNotThereSaysSo(Server server) { @Test void deletingRemovesItFromTheListing(Server server) { - server.buffers().set("doomed", "x"); + server.buffers().set("doomed;", "x"); + + if (!server.version().atLeast(EXACT_NAMED_DELETE)) { + assertThrows(UnsupportedTmuxVersion.class, () -> server.buffers().delete("doomed;")); + assertEquals("x", server.buffers().show("doomed;"), "refusal leaves the buffer untouched"); + return; + } - server.buffers().delete("doomed"); + server.buffers().delete("doomed;"); + + assertEquals(List.of(), server.buffers().list()); + } + + @Test + void deletingAnAbsentBufferDoesNotDeleteTheTopBuffer(Server server) { + server.buffers().set("belongs-to-the-user", "keep me"); + + if (server.version().atLeast(EXACT_NAMED_DELETE)) { + assertThrows(ObjectDoesNotExist.class, () -> server.buffers().delete("never-set;")); + } else { + assertThrows(UnsupportedTmuxVersion.class, () -> server.buffers().delete("never-set;")); + } - assertFalse(server.buffers().list().stream() - .anyMatch(buffer -> buffer.name().equals("doomed"))); + assertEquals("keep me", server.buffers().show("belongs-to-the-user")); + assertEquals( + List.of("belongs-to-the-user"), + server.buffers().list().stream().map(BufferInfo::name).toList(), + "only the user's buffer remains"); } @Test void aBufferSurvivesAFileRoundTrip(Server server, @TempDir Path directory) throws Exception { - Path file = directory.resolve("buffer.txt"); - server.buffers().set("saved", "written to disk"); + Path file = directory.resolve("buffer;"); + server.buffers().set("saved;", "written to disk"); - server.buffers().save("saved", file); - server.buffers().load("reloaded", file); + server.buffers().save("saved;", file); + server.buffers().load("reloaded;", file); assertEquals("written to disk", Files.readString(file).stripTrailing()); - assertEquals("written to disk", server.buffers().show("reloaded")); + assertEquals("written to disk", server.buffers().show("reloaded;")); } @Test diff --git a/libtmux/src/main/java/io/github/libtmux/Buffers.java b/libtmux/src/main/java/io/github/libtmux/Buffers.java index 2ae4213..cea0f53 100644 --- a/libtmux/src/main/java/io/github/libtmux/Buffers.java +++ b/libtmux/src/main/java/io/github/libtmux/Buffers.java @@ -1,9 +1,11 @@ package io.github.libtmux; import io.github.libtmux.format.RowFormat; +import io.github.libtmux.transport.CommandResult; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Objects; /** * The tmux server's paste buffers. @@ -15,6 +17,7 @@ public final class Buffers { private static final RowFormat LISTING = RowFormat.of("buffer_name", "buffer_size"); + private static final TmuxVersion EXACT_NAMED_DELETE = new TmuxVersion(3, 4, ""); private final Server server; @@ -39,7 +42,7 @@ public List list() { /** Puts text in a named buffer, replacing whatever was there. */ public void set(String name, String contents) { - server.run(List.of("set-buffer", "-b", name, contents)); + server.run(List.of("set-buffer", "-b", argument(name), argument(contents))); } /** @@ -48,25 +51,48 @@ public void set(String name, String contents) { * @throws ObjectDoesNotExist if the server has no buffer by that name */ public String show(String name) { - var result = server.cmd(List.of("show-buffer", "-b", name)); + var result = server.cmd(List.of("show-buffer", "-b", argument(name))); if (!result.succeeded()) { throw new ObjectDoesNotExist("no buffer named '" + name + "'"); } return String.join("\n", result.stdout()); } - /** Removes a buffer. */ + /** + * Removes a buffer by its exact name. + * + * @throws ObjectDoesNotExist if the server has no buffer by that name + * @throws UnsupportedTmuxVersion before tmux 3.4, whose named deletion silently removes the top + * buffer when the name is absent + */ public void delete(String name) { - server.run(List.of("delete-buffer", "-b", name)); + String target = argument(name); + TmuxVersion running = server.version(); + if (!running.atLeast(EXACT_NAMED_DELETE)) { + throw new UnsupportedTmuxVersion("deleting a buffer by exact name", EXACT_NAMED_DELETE, running); + } + CommandResult result = server.cmd(List.of("delete-buffer", "-b", target)); + if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.equals("unknown buffer: " + name))) { + throw new ObjectDoesNotExist("no buffer named '" + name + "'"); + } + if (!result.succeeded()) { + throw new LibTmuxException("tmux delete-buffer failed: " + String.join("; ", result.stderr())); + } } /** Writes a buffer's contents to a file. */ public void save(String name, Path file) { - server.run(List.of("save-buffer", "-b", name, file.toString())); + server.run(List.of("save-buffer", "-b", argument(name), argument(file.toString()))); } /** Reads a file into a named buffer. */ public void load(String name, Path file) { - server.run(List.of("load-buffer", "-b", name, file.toString())); + server.run(List.of("load-buffer", "-b", argument(name), argument(file.toString()))); + } + + /** Protects a final semicolon from tmux's command-group parser on every transport. */ + private static String argument(String value) { + Objects.requireNonNull(value, "value"); + return value.endsWith(";") ? value.substring(0, value.length() - 1) + "\\;" : value; } } From 04c51d406dc7c14d748e88316d578e98134509cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:11:52 -0500 Subject: [PATCH 14/77] MCP(fix[paste]): Isolate temporary buffers Use one buffer per call and require tmux 3.4 so failures can clean up the exact buffer. Move typing coverage out of the aggregate real-tmux test. --- .../java/io/github/libtmux/mcp/Catalog.java | 3 +- .../java/io/github/libtmux/mcp/Typing.java | 12 +- .../libtmux/mcp/ToolsAgainstTmuxTest.java | 37 +--- .../io/github/libtmux/mcp/TypingTest.java | 173 ++++++++++++++++++ 4 files changed, 187 insertions(+), 38 deletions(-) create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index 2195076..0f5cc04 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -295,7 +295,8 @@ private static void typing(List tools) { "Paste text into a pane", "Puts text into a pane as a paste rather than as keystrokes, so brackets, newlines and " + "anything that spells a key name arrive as the characters they are. Use it for an " - + "editor, a REPL, or a here-document.", + + "editor, a REPL, or a here-document. Requires tmux 3.4 or newer so a failed paste " + + "can remove only its own temporary buffer.", Safety.MUTATING, DESTRUCTIVE, List.of( diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java index 1f48c55..425260d 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java @@ -1,8 +1,11 @@ package io.github.libtmux.mcp; +import io.github.libtmux.LibTmuxException; import io.github.libtmux.Pane; +import io.github.libtmux.TmuxVersion; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -14,6 +17,8 @@ */ final class Typing { + private static final TmuxVersion SAFE_PASTE_CLEANUP = new TmuxVersion(3, 4, ""); + private Typing() {} record Sent( @@ -69,10 +74,15 @@ static Sent sendKeys(Call call) { * history a person shares with the model. */ static Pasted pasteText(Call call) { + TmuxVersion running = call.server().version(); + if (!running.atLeast(SAFE_PASTE_CLEANUP)) { + throw new LibTmuxException("tmux_paste_text requires tmux 3.4, but this server runs " + running + + "; older releases cannot safely clean up a failed paste"); + } Pane pane = Targets.pane(call.server(), call.string("pane_id")); String text = call.string("text"); boolean enter = call.flag("enter", false); - String buffer = "libtmux-mcp-paste"; + String buffer = "libtmux-mcp-paste-" + UUID.randomUUID(); try { // tmux turns the line feeds in a buffer into carriage returns as it pastes, so a // trailing newline is what submits the text — there is no flag that means "and Enter". diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index 29c4f67..7acfad7 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -1,7 +1,6 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -367,41 +366,7 @@ void aWorkspaceNamingASessionThatExistsIsRefusedBeforeAnythingIsBuilt(Server ser assertEquals(1, server.sessions().size()); } - // ---------------------------------------------------------------- input and channels - - @Test - void keysAreSentByNameSoAnInterruptInterrupts(Server server) { - String pane = server.panes().get(0).id().value(); - server.run(List.of("send-keys", "-l", "-t", pane, "sleep 60")); - server.run(List.of("send-keys", "-t", pane, "Enter")); - - Typing.Sent sent = Typing.sendKeys(TestCalls.on(server, "pane_id", pane, "keys", List.of("C-c"))); - - assertEquals(1, sent.keys()); - assertFalse(sent.literal()); - assertTrue(String.valueOf(sent.note()).contains("not waited for"), String.valueOf(sent.note())); - } - - @Test - void sendingNoKeysAtAllSaysWhatWasWanted(Server server) { - String pane = server.panes().get(0).id().value(); - - IllegalArgumentException refused = assertThrows( - IllegalArgumentException.class, - () -> Typing.sendKeys(TestCalls.on(server, "pane_id", pane, "keys", List.of()))); - - assertTrue(String.valueOf(refused.getMessage()).contains("C-c"), refused.getMessage()); - } - - @Test - void pastedTextArrivesAsCharactersRatherThanKeyNames(Server server) { - String pane = server.panes().get(0).id().value(); - - Typing.Pasted pasted = Typing.pasteText(TestCalls.on(server, "pane_id", pane, "text", "Enter [C-c] done")); - - assertEquals(16, pasted.characters()); - assertTrue(String.valueOf(pasted.note()).contains("pass 'enter'"), String.valueOf(pasted.note())); - } + // ---------------------------------------------------------------- channels /** A signal outlives the moment it was sent, which is what draining exists to undo. */ @Test diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java new file mode 100644 index 0000000..7cf7291 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -0,0 +1,173 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import io.github.libtmux.LibTmuxException; +import io.github.libtmux.Server; +import io.github.libtmux.TmuxVersion; +import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(TmuxExtension.class) +final class TypingTest { + + private static final TmuxVersion SAFE_PASTE_CLEANUP = new TmuxVersion(3, 4, ""); + + @Test + void keysAreSentByNameSoAnInterruptInterrupts(Server server) { + String pane = server.panes().get(0).id().value(); + server.run(List.of("send-keys", "-l", "-t", pane, "sleep 60")); + server.run(List.of("send-keys", "-t", pane, "Enter")); + + Typing.Sent sent = Typing.sendKeys(TestCalls.on(server, "pane_id", pane, "keys", List.of("C-c"))); + + assertEquals(1, sent.keys()); + assertFalse(sent.literal()); + assertTrue(String.valueOf(sent.note()).contains("not waited for"), String.valueOf(sent.note())); + } + + @Test + void sendingNoKeysAtAllSaysWhatWasWanted(Server server) { + String pane = server.panes().get(0).id().value(); + + IllegalArgumentException refused = assertThrows( + IllegalArgumentException.class, + () -> Typing.sendKeys(TestCalls.on(server, "pane_id", pane, "keys", List.of()))); + + assertTrue(String.valueOf(refused.getMessage()).contains("C-c"), refused.getMessage()); + } + + @Test + void pastedTextArrivesWithoutClaimingAUsersBuffer(Server server) { + assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); + String pane = server.panes().get(0).id().value(); + server.buffers().set("libtmux-mcp-paste", "user-owned"); + + Typing.Pasted pasted = Typing.pasteText(TestCalls.on(server, "pane_id", pane, "text", "Enter [C-c] done")); + + assertEquals(16, pasted.characters()); + assertTrue(String.valueOf(pasted.note()).contains("pass 'enter'"), String.valueOf(pasted.note())); + assertEquals("user-owned", server.buffers().show("libtmux-mcp-paste")); + assertNoOwnedBuffers(server); + } + + @Test + void pasteRefusesUnsafeCleanupBeforeCreatingABuffer(Server server) { + assumeFalse(server.version().atLeast(SAFE_PASTE_CLEANUP)); + String pane = server.panes().get(0).id().value(); + server.buffers().set("libtmux-mcp-paste", "user-owned"); + + LibTmuxException refused = assertThrows( + LibTmuxException.class, + () -> Typing.pasteText(TestCalls.on(server, "pane_id", pane, "text", "must-not-be-buffered"))); + + assertTrue(String.valueOf(refused.getMessage()).contains("requires tmux 3.4"), refused.getMessage()); + assertEquals("user-owned", server.buffers().show("libtmux-mcp-paste")); + assertNoOwnedBuffers(server); + } + + @Test + void failedPasteDeletesOnlyItsOwnedBuffer(Server server) throws Exception { + assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); + String pane = server.panes().get(0).id().value(); + server.buffers().set("libtmux-mcp-paste", "user-owned"); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport failingPaste = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + if (request.argv().get(0).equals("paste-buffer")) { + return new CommandResult(1, List.of(), List.of("forced paste failure")); + } + return processes.execute(request); + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), failingPaste)) { + assertThrows( + LibTmuxException.class, + () -> Typing.pasteText(TestCalls.on(measured, "pane_id", pane, "text", "created-first"))); + } + } + + assertEquals("user-owned", server.buffers().show("libtmux-mcp-paste")); + assertNoOwnedBuffers(server); + } + + @Test + void concurrentPastesDoNotShareTheirServerGlobalBuffer(Server server) throws Exception { + assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); + String firstPane = server.panes().get(0).id().value(); + String secondPane = server.panes().get(0).split().id().value(); + Map contentsByBuffer = new ConcurrentHashMap<>(); + Map bufferByTarget = new ConcurrentHashMap<>(); + CountDownLatch bothBuffersSet = new CountDownLatch(2); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport interleaving = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + CommandResult result = processes.execute(request); + if (request.argv().get(0).equals("set-buffer")) { + contentsByBuffer.put( + request.argv().get(2), request.argv().get(3)); + bothBuffersSet.countDown(); + await(bothBuffersSet); + } else if (request.argv().get(0).equals("paste-buffer")) { + bufferByTarget.put(request.argv().get(5), request.argv().get(3)); + } + return result; + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), interleaving); + var calls = Executors.newVirtualThreadPerTaskExecutor()) { + var first = calls.submit(() -> + Typing.pasteText(TestCalls.on(measured, "pane_id", firstPane, "text", "first-paste-marker"))); + var second = calls.submit(() -> + Typing.pasteText(TestCalls.on(measured, "pane_id", secondPane, "text", "second-paste-marker"))); + + assertEquals(firstPane, first.get(10, TimeUnit.SECONDS).paneId()); + assertEquals(secondPane, second.get(10, TimeUnit.SECONDS).paneId()); + } + } + + assertEquals("first-paste-marker", contentsByBuffer.get(bufferByTarget.get(firstPane))); + assertEquals("second-paste-marker", contentsByBuffer.get(bufferByTarget.get(secondPane))); + assertNoOwnedBuffers(server); + } + + private static void assertNoOwnedBuffers(Server server) { + assertTrue(server.buffers().list().stream() + .noneMatch(buffer -> buffer.name().startsWith("libtmux-mcp-paste-"))); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out arranging concurrent pastes"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while arranging concurrent pastes", e); + } + } +} From dc222f049e4898498630cb08a46385890890f583 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:12:54 -0500 Subject: [PATCH 15/77] Workspace(fix[commands]): Reject undispatchable pane input Reject NUL in PaneSpec before workspace topology can change. Keep cleanup-failure coverage on an independent post-creation failure. --- .../java/io/github/libtmux/workspace/PaneSpec.java | 6 ++++++ .../libtmux/workspace/WorkspaceBuilderTest.java | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/PaneSpec.java b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/PaneSpec.java index 9558d79..8dfb0c4 100644 --- a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/PaneSpec.java +++ b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/PaneSpec.java @@ -11,5 +11,11 @@ public record PaneSpec(List commands) { public PaneSpec { commands = List.copyOf(commands); + for (int index = 0; index < commands.size(); index++) { + if (commands.get(index).indexOf('\0') >= 0) { + throw new IllegalArgumentException( + "pane command " + index + " contains NUL, which no process can carry"); + } + } } } diff --git a/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java b/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java index 19fe709..728cabb 100644 --- a/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java +++ b/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java @@ -81,6 +81,14 @@ void aPaneMayBeATextACommandListOrAMapping() { assertEquals(List.of("echo first", "echo second"), panes.get(1).commands()); } + @Test + void aPaneCommandRejectsAByteNoProcessCanCarry() { + IllegalArgumentException refused = assertThrows( + IllegalArgumentException.class, () -> new PaneSpec(List.of("echo valid", "invalid\0command"))); + + assertTrue(String.valueOf(refused.getMessage()).contains("command 1"), refused.getMessage()); + } + @Test void aWindowWithNoPanesStatedStillGetsTheOneTmuxMakes() { Workspace workspace = WorkspaceBuilder.parse(""" @@ -401,8 +409,7 @@ void cleanupFailureIsSuppressedOnTheApplicationFailure(Server server) { Workspace workspace = new Workspace( "vanishing", List.of( - new WindowSpec( - "first", Optional.empty(), List.of(new PaneSpec(List.of("invalid\u0000command")))), + new WindowSpec("first", Optional.empty(), List.of(new PaneSpec(List.of("echo never reached")))), new WindowSpec("second", Optional.empty(), List.of(new PaneSpec(List.of()))))); RuntimeException failure = From 5740c36f8ecc9cf7c96dee4cad88d65ac10dd8b4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:15:20 -0500 Subject: [PATCH 16/77] Core(fix[input]): Type lines literally and atomically Send literal text plus carriage return in one tmux operation. Protect option-shaped and semicolon-terminated lines across direct pane and command-chain delivery. --- .../it/CommandChainIntegrationTest.java | 26 +++++++++++++++++++ .../libtmux/it/OperationsIntegrationTest.java | 13 ++++++++++ .../java/io/github/libtmux/CommandChain.java | 3 ++- .../src/main/java/io/github/libtmux/Pane.java | 3 ++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java index 172b2dc..f93a303 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java @@ -47,6 +47,32 @@ void eachStepActsOnWhatTheLastOneMade(Server server) throws Exception { "the keys went to the pane the split produced, not to the one the chain started from"); } + @Test + void aLineThatIsAKeyNameStaysLiteralInsideAChain(Server server) throws Exception { + BatchResult result = server.chain() + .newWindow("literal-line") + .sendLine("Enter() { printf 'literal-chain-%s\\n' enter; }") + .sendLine("clear") + .sendLine("Enter") + .sendLine("-R") + .sendLine("printf 'literal-chain-%s\\n' semicolon;") + .run(); + + assertTrue(result.succeeded(), result.toString()); + Pane pane = server.windows().stream() + .filter(window -> window.name().equals("literal-line")) + .findFirst() + .orElseThrow() + .panes() + .get(0); + assertTrue( + await(() -> pane.capture().stream().anyMatch(line -> line.contains("literal-chain-enter"))), + "Enter was pressed instead of typed"); + assertTrue( + await(() -> pane.capture().stream().anyMatch(line -> line.contains("literal-chain-semicolon"))), + "a trailing semicolon became a command-group separator"); + } + @Test void theWholeChainIsOneInvocation(Server server) { BatchResult result = server.chain() diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java index 1beffbf..841fde0 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java @@ -1,5 +1,6 @@ package io.github.libtmux.it; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -71,6 +72,18 @@ void aPaneRunsWhatItIsSent(Server server) { assertTrue(awaitOutput(pane, "libtmux-was-here"), "the pane never showed the command's output"); } + @Test + void aLineThatIsAKeyNameIsTypedLiterally(Server server) { + Pane pane = session(server).windows().get(0).panes().get(0); + pane.sendLine("Enter() { printf 'literal-%s-command\\n' enter; }"); + pane.sendLine("clear"); + + pane.sendLine("Enter"); + assertDoesNotThrow(() -> pane.sendLine("-R"), "a line is not a send-keys option"); + + assertTrue(awaitOutput(pane, "literal-enter-command"), "Enter was pressed instead of typed"); + } + @Test void renamingChangesTheNameAndNotTheIdentity(Server server) { Session original = session(server); diff --git a/libtmux/src/main/java/io/github/libtmux/CommandChain.java b/libtmux/src/main/java/io/github/libtmux/CommandChain.java index 84a1301..25b6a9c 100644 --- a/libtmux/src/main/java/io/github/libtmux/CommandChain.java +++ b/libtmux/src/main/java/io/github/libtmux/CommandChain.java @@ -3,6 +3,7 @@ import io.github.libtmux.batch.Batch; import io.github.libtmux.batch.BatchResult; import java.util.List; +import java.util.Objects; /** * A sequence of tmux commands where each one acts on what the last one made. @@ -50,7 +51,7 @@ public CommandChain splitTopBottom() { /** Types a line into the current pane and presses Enter, which is how a command gets run. */ public CommandChain sendLine(String command) { - return then("send-keys", command, "Enter"); + return then("send-keys", "-l", "--", Objects.requireNonNull(command, "command") + "\r"); } /** diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 61f8920..6593d10 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -303,7 +303,8 @@ public void send(String keys) { /** Sends a line to this pane and presses Enter, which is how a command gets run. */ public void sendLine(String command) { - server.run(snapshot, List.of("send-keys", "-t", state.id().value(), command, "Enter")); + Objects.requireNonNull(command, "command"); + server.run(snapshot, List.of("send-keys", "-l", "-t", state.id().value(), "--", command + "\r")); } /** From 0d83e7978efd37c7ecf3b9726d1f91c0a009abea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:17:50 -0500 Subject: [PATCH 17/77] MCP(fix[run]): Deliver command lines atomically Reuse Pane.sendLine so a payload and its carriage return reach tmux in one operation. Prevent concurrent tmux_run calls on one pane from merging their shell input. --- .../github/libtmux/mcp/RunningCommands.java | 5 +- .../libtmux/mcp/RunningCommandsTest.java | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 98cb1f9..6aeb1e8 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -81,10 +81,7 @@ static Ran run(Call call) { Cursor before = Watching.from(pane).cursor(); String typed = payload(server, pane, command, nonce, startMark, endMark, statusOption, channel, suppressHistory); - // Literal, so a command that happens to spell a key name — "Enter", "C-c" — is typed rather - // than pressed. Enter is a separate send because it is the one keypress that is meant. - server.run(List.of("send-keys", "-l", "-t", pane.id().value(), typed)); - server.run(List.of("send-keys", "-t", pane.id().value(), "Enter")); + pane.sendLine(typed); long started = System.nanoTime(); WakeReason wake = server.waitFor(channel, timeout); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 35df613..81afcef 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -10,6 +10,13 @@ import io.github.libtmux.ObjectDoesNotExist; import io.github.libtmux.Server; import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -190,4 +197,52 @@ void aStatusOptionIsNotLeftBehindOnThePane(Server server) { server.panes().get(0).options().all().keySet().stream().anyMatch(name -> name.startsWith("@st_")), "the exit status is read and then cleared away"); } + + @Test + void concurrentRunsDoNotMergeTheirCommandLines(Server server) throws Exception { + String pane = server.panes().get(0).id().value(); + CountDownLatch bothLinesSent = new CountDownLatch(2); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport interleaving = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + CommandResult result = processes.execute(request); + if (request.argv().get(0).equals("send-keys") + && request.argv().contains("-l")) { + bothLinesSent.countDown(); + await(bothLinesSent); + } + return result; + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), interleaving); + var calls = Executors.newVirtualThreadPerTaskExecutor()) { + var first = calls.submit(() -> RunningCommands.run(TestCalls.on( + measured, "pane_id", pane, "command", "printf 'first-run-marker\\n'", "timeout", 2))); + var second = calls.submit(() -> RunningCommands.run(TestCalls.on( + measured, "pane_id", pane, "command", "printf 'second-run-marker\\n'", "timeout", 2))); + + assertEquals( + java.util.List.of("first-run-marker"), + first.get(10, TimeUnit.SECONDS).output()); + assertEquals( + java.util.List.of("second-run-marker"), + second.get(10, TimeUnit.SECONDS).output()); + } + } + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out arranging concurrent command delivery"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while arranging concurrent command delivery", e); + } + } } From 010414130587827cc56d0a2f271d8f5386ee69ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:21:52 -0500 Subject: [PATCH 18/77] MCP(fix[targets]): Disambiguate destructive targets Use stable session IDs for rename and kill, leaving literal server as the only non-ID destructive target. Keep session-name lookup only on arguments that explicitly request names. --- .../java/io/github/libtmux/mcp/Catalog.java | 8 ++-- .../java/io/github/libtmux/mcp/Listings.java | 2 +- .../java/io/github/libtmux/mcp/Settings.java | 8 ++-- .../java/io/github/libtmux/mcp/Shaping.java | 9 +++-- .../java/io/github/libtmux/mcp/Targets.java | 20 ++++++++-- .../libtmux/mcp/ToolsAgainstTmuxTest.java | 39 ++++++++++++++++++- 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index 0f5cc04..432852c 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -364,11 +364,11 @@ private static void shaping(List tools) { tools.add(ToolSpec.of( "tmux_rename", "Rename a window or session", - "Renames a window given its @id, or a session given its name.", + "Renames a window or session given its stable id.", Safety.MUTATING, DESTRUCTIVE, List.of( - required("target", "A window id such as @1, or a session name."), + required("target", "A window id such as @1, or a session id such as $1."), required("name", "The new name.")), Shaping::rename)); @@ -479,8 +479,8 @@ private static void ending(List tools) { List.of( required( "target", - "A pane id such as %1, a window id such as @1, a session name, or the word " - + "'server' to end every session on it."), + "A pane id such as %1, a window id such as @1, a session id such as $1, or the " + + "word 'server' to end the whole server."), flag( "confirm_self", "Go ahead even though the target holds the pane this MCP server runs in.", diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java index 4ce4edd..2381cde 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java @@ -145,7 +145,7 @@ static Windows windows(Call call) { Stream windows = server.windows().stream(); String session = call.maybe("session").orElse(null); if (session != null) { - Session wanted = Targets.session(server, session); + Session wanted = Targets.sessionNamed(server, session); windows = wanted.windows().stream(); } List summaries = windows.map(window -> new WindowSummary( diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Settings.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Settings.java index 7aa1776..98419f6 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Settings.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Settings.java @@ -67,7 +67,7 @@ static Environment environment(Call call) { Map global = readEnvironment(call.server(), null); return new Environment("(global)", global.size(), global); } - var session = Targets.session(call.server(), name); + var session = Targets.sessionNamed(call.server(), name); Map variables = readEnvironment(call.server(), session.name()); return new Environment(session.name(), variables.size(), variables); } @@ -88,7 +88,8 @@ private static Options optionsFor(Server server, String scope, @Nullable String return switch (scope) { case "global" -> server.globalOptions(); case "server" -> server.options(); - case "session" -> Targets.session(server, required(target, scope)).options(); + case "session" -> + Targets.sessionNamed(server, required(target, scope)).options(); case "window" -> Targets.window(server, required(target, scope)).options(); case "pane" -> Targets.pane(server, required(target, scope)).options(); default -> @@ -100,7 +101,8 @@ private static Options optionsFor(Server server, String scope, @Nullable String private static Hooks hooksFor(Server server, String scope, @Nullable String target) { return switch (scope) { case "global", "server" -> server.hooks(); - case "session" -> Targets.session(server, required(target, scope)).hooks(); + case "session" -> + Targets.sessionNamed(server, required(target, scope)).hooks(); case "window" -> Targets.window(server, required(target, scope)).hooks(); case "pane" -> Targets.pane(server, required(target, scope)).hooks(); default -> diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java index 292e15e..44d4c5f 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java @@ -53,7 +53,7 @@ static Made newSession(Call call) { } static Made newWindow(Call call) { - Session session = Targets.session(call.server(), call.string("session")); + Session session = Targets.sessionNamed(call.server(), call.string("session")); Window window = session.newWindow(spec -> { call.maybe("name").ifPresent(spec::named); call.maybe("path").ifPresent(path -> spec.in(Path.of(path))); @@ -111,7 +111,7 @@ static Changed rename(Call call) { window.rename(name); return new Changed("window", target, name, null); } - Session session = Targets.session(call.server(), target); + Session session = Targets.sessionById(call.server(), target); session.rename(name); return new Changed("session", session.id().value(), name, null); } @@ -184,7 +184,10 @@ static Ended kill(Call call) { "Every session on it is gone, and so is this connection's " + "server. Nothing else in this conversation can act on it."); } - Session session = Targets.session(server, target); + if (!target.startsWith("$")) { + throw new IllegalArgumentException("'target' must be a pane, window or session id, or the word 'server'"); + } + Session session = Targets.sessionById(server, target); guard( call, session.windows().stream() diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Targets.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Targets.java index 142b91c..3dda57d 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Targets.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Targets.java @@ -5,6 +5,7 @@ import io.github.libtmux.PaneId; import io.github.libtmux.Server; import io.github.libtmux.Session; +import io.github.libtmux.SessionId; import io.github.libtmux.Session_; import io.github.libtmux.Window; import io.github.libtmux.WindowId; @@ -16,9 +17,10 @@ *

Every failure here names the tool that produces a working target. A model that reads "no pane * %9" can guess; one that reads "call tmux_list_panes for the ids that exist" cannot get stuck. * - *

Targets are ids, never positions. A model works from a listing it read some turns ago, and - * indexes move as neighbours come and go, so a positional target would quietly act on a pane that - * was not the one it meant. + *

Object targets are ids, never positions. A model works from a listing it read some turns ago, + * and indexes move as neighbours come and go, so a positional target would quietly act on a pane + * that was not the one it meant. Session names are resolved only where an argument explicitly asks + * for one. */ final class Targets { @@ -44,7 +46,7 @@ static Window window(Server server, String id) { + " on this server; call tmux_list_windows for the " + windows.size() + " that exist")); } - static Session session(Server server, String name) { + static Session sessionNamed(Server server, String name) { List sessions = server.sessions(); return sessions.stream() .filter(Session_.name().is(name)) @@ -53,6 +55,16 @@ static Session session(Server server, String name) { + sessions.stream().map(Session::name).toList())); } + static Session sessionById(Server server, String id) { + SessionId wanted = new SessionId(id); + List sessions = server.sessions(); + return sessions.stream() + .filter(session -> session.id().equals(wanted)) + .findFirst() + .orElseThrow(() -> new ObjectDoesNotExist("no session " + id + + " on this server; call tmux_list_sessions for the " + sessions.size() + " that exist")); + } + /** * tmux reads a bare number as an index, so {@code 1} sent where {@code %1} was meant would act * on a real but unintended pane. Rejected before it reaches tmux, naming the shape wanted. diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index 7acfad7..f0e3d50 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -99,9 +99,10 @@ void aFieldThePaneModelLacksSaysWhichItHas(Server server) { void refusingToEndAContainerNamesWhatCanBeEnded(Server server) { String mine = server.panes().get(0).id().value(); String other = server.sessions().get(0).windows().get(0).split().id().value(); + String session = server.sessions().get(0).id().value(); IllegalStateException refused = assertThrows( - IllegalStateException.class, () -> Shaping.kill(TestCalls.asCaller(server, mine, "target", "libtmux"))); + IllegalStateException.class, () -> Shaping.kill(TestCalls.asCaller(server, mine, "target", session))); String message = String.valueOf(refused.getMessage()); assertTrue(message.contains(other), "the pane that could go is named: " + message); @@ -291,6 +292,42 @@ void aPaneThatIsNotTheCallersIsEndedWithoutCeremony(Server server) { assertEquals(1, server.panes().size()); } + @Test + void aSessionNamedServerIsKilledByItsListedIdWithoutEndingTheServer(Server server) { + var namedServer = server.newSession("server"); + + Shaping.Ended ended = + Shaping.kill(TestCalls.on(server, "target", namedServer.id().value())); + + assertEquals("session", ended.kind()); + assertTrue(server.isAlive(), "a session name must not become a request to kill the server"); + assertTrue(server.sessions().stream().noneMatch(session -> session.id().equals(namedServer.id()))); + } + + @Test + void sessionTargetsUseTheirListedIdsEvenWhenTheNameLooksLikeAWindowId(Server server) { + var ambiguous = server.newSession(server.windows().get(0).id().value()); + + Shaping.Changed renamed = + Shaping.rename(TestCalls.on(server, "target", ambiguous.id().value(), "name", "renamed-safely")); + Shaping.Ended ended = + Shaping.kill(TestCalls.on(server, "target", ambiguous.id().value())); + + assertEquals("session", renamed.kind()); + assertEquals("renamed-safely", renamed.what()); + assertEquals("session", ended.kind()); + assertTrue(server.isAlive()); + assertEquals(1, server.sessions().size()); + } + + @Test + void theServerTargetMeansTheWholeServer(Server server) { + Shaping.Ended ended = Shaping.kill(TestCalls.on(server, "target", "server")); + + assertEquals("server", ended.kind()); + assertEquals(false, server.isAlive()); + } + // ---------------------------------------------------------------- making things @Test From b7fc56ccc23424d33154d931ee6545139186f1b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:30:23 -0500 Subject: [PATCH 19/77] MCP(fix[run]): Keep completion state off panes Carry exit status in the framed end marker so late completion cannot leave a pane option behind. Keep the shell independent of Java cleanup after timeout or uncertain delivery. --- .../github/libtmux/mcp/RunningCommands.java | 96 +++++++------- .../libtmux/mcp/RunningCommandsTest.java | 123 ++++++++++++++---- 2 files changed, 144 insertions(+), 75 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 6aeb1e8..e9aca57 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -8,7 +8,6 @@ import java.util.ArrayList; import java.util.HexFormat; import java.util.List; -import java.util.Optional; import org.jspecify.annotations.Nullable; /** @@ -21,19 +20,18 @@ * *

How completion is known

* - *

The command is followed by two things the shell runs after it: one that records the exit - * status in a pane option, and one that signals a private tmux channel. Waiting is then tmux's own - * {@code wait-for}, which blocks server-side and returns on the signal itself — nothing is inferred - * from what the screen looks like. + *

The command is followed by two things the shell runs after it: an end marker carrying the exit + * status, and a signal on a private tmux channel. Waiting is then tmux's own {@code wait-for}, which + * blocks server-side and returns on the signal itself — completion is not inferred from what the + * screen looks like. * *

How the output is separated from the plumbing

* *

The shell echoes everything typed at it, so the plumbing appears on screen alongside the * output. It is cut out by framing: the command is bracketed by two lines that print a random - * nonce, and only lines strictly between them are returned. The echo of the whole payload - * contains the nonce, but no echo is ever equal to it, so exact-equality matching - * separates the two — including when the echo wraps across several rows, which is the case that - * defeats matching the plumbing by its shape. + * nonce, and only lines strictly between them are returned. The echo of the whole payload contains + * the nonce, but never as a complete start marker or an end marker followed only by a numeric + * status. Matching those forms separates the two even when the echo wraps across several rows. */ final class RunningCommands { @@ -44,7 +42,7 @@ private RunningCommands() {} /** * @param paneId the pane it ran in * @param outcome why the wait ended, which is never simply "successfully" - * @param exitStatus the command's status, absent when it had not finished + * @param exitStatus the command's status, absent when it had not finished or its marker was lost * @param output what the command printed, plumbing removed * @param truncated whether older output was dropped to fit the budget * @param linesDropped how many lines that cost @@ -76,21 +74,21 @@ static Ran run(Call call) { String startMark = nonce + "-s"; String endMark = nonce + "-e"; String channel = "ch_" + nonce; - String statusOption = "@st_" + nonce; Cursor before = Watching.from(pane).cursor(); - String typed = - payload(server, pane, command, nonce, startMark, endMark, statusOption, channel, suppressHistory); + String typed = payload(server, command, nonce, startMark, endMark, channel, suppressHistory); + // Never make the shell wait for Java cleanup: a transport can report UNKNOWN after tmux + // accepted this line, and that failure must not strand the pane at private plumbing. pane.sendLine(typed); long started = System.nanoTime(); WakeReason wake = server.waitFor(channel, timeout); double seconds = (System.nanoTime() - started) / 1_000_000_000.0; - Integer status = wake == WakeReason.SIGNALLED ? readStatus(pane, statusOption) : null; Watching.Fresh fresh = wake == WakeReason.SERVER_GONE ? null : Watching.since(pane, before, Trim.lineBudget(call)); - Framed framed = fresh == null ? new Framed(List.of(), false) : frame(fresh.lines(), startMark, endMark); + Framed framed = fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); + Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); return new Ran( @@ -135,12 +133,10 @@ static Ran run(Call call) { */ private static String payload( Server server, - Pane pane, String command, String nonce, String startMark, String endMark, - String statusOption, String channel, boolean suppressHistory) { // The config file is left off: it is read when a server starts and means nothing to a command @@ -149,17 +145,12 @@ private static String payload( List tmux = new ArrayList<>(List.of(server.config().binary())); tmux.addAll(server.config().endpoint().flags()); - // One tmux invocation carrying two commands rather than two invocations. tmux ends a command - // at a bare ';' argument, and halving the invocations halves what the shell echoes back. - String finish = - Shell.quoteAll(append(tmux, "set-option", "-p", "-t", pane.id().value(), statusOption)) - + " \"$" + nonce + "\" " + Shell.quote(";") + " " - + Shell.quoteAll(List.of("wait-for", "-S", channel)); + String finish = Shell.quoteAll(append(tmux, "wait-for", "-S", channel)); // The status is held in a shell variable named for the nonce, so nothing this types can // collide with a variable the person using the pane already had. return (suppressHistory ? " " : "") + "echo " + startMark + "; ( " + command + " ); " + nonce + "=$?; echo " - + endMark + "; " + finish; + + endMark + ":\"$" + nonce + "\"; " + finish; } private static List append(List base, String... more) { @@ -168,51 +159,56 @@ private static List append(List base, String... more) { return argv; } - private static @Nullable Integer readStatus(Pane pane, String option) { - Optional recorded = pane.options().get(option); - try { - return recorded.map(String::trim).map(Integer::parseInt).orElse(null); - } catch (NumberFormatException e) { - return null; - } finally { - try { - pane.options().unset(option); - } catch (RuntimeException e) { - // A leftover pane option costs nothing and is gone with the pane; failing the call - // over tidying up would throw away the answer the caller came for. - } - } - } - /** @param exact whether both markers were found, so what is returned is only the command's output */ - private record Framed(List lines, boolean exact) {} + private record Framed( + List lines, boolean exact, @Nullable Integer status) {} /** * Keeps what lies strictly between the two marker lines. * - *

Matched by equality after trimming, never by containment: the echo of the payload holds - * both markers as substrings and must not be mistaken for either. + *

Matched as complete marker forms after trimming, never by containment: the echo of the + * payload holds both markers as substrings and must not be mistaken for either. */ private static Framed frame(List lines, String startMark, String endMark) { int start = -1; - int end = -1; for (int index = 0; index < lines.size(); index++) { - String line = lines.get(index).trim(); - if (start < 0 && line.equals(startMark)) { + if (lines.get(index).trim().equals(startMark)) { start = index; - } else if (start >= 0 && line.equals(endMark)) { - end = index; break; } } + + int end = -1; + Integer status = null; + String endPrefix = endMark + ":"; + for (int index = start < 0 ? 0 : start + 1; index < lines.size(); index++) { + String line = lines.get(index).trim(); + if (!line.startsWith(endPrefix)) { + continue; + } + try { + int candidate = Integer.parseInt(line.substring(endPrefix.length())); + end = index; + status = candidate; + if (start >= 0) { + break; + } + } catch (NumberFormatException ignored) { + // A wrapped echo can begin with the prefix; only the numeric marker is plumbing. + } + } if (start < 0) { // The frame is gone: output outgrew the history, or the command cleared the screen. // Everything that is not obviously plumbing is better than nothing. return new Framed( - lines.stream().filter(line -> !line.contains(startMark)).toList(), false); + lines.stream() + .filter(line -> !line.contains(startMark) && !line.contains(endMark)) + .toList(), + false, + status); } int last = end < 0 ? lines.size() : end; - return new Framed(List.copyOf(lines.subList(start + 1, last)), end >= 0); + return new Framed(List.copyOf(lines.subList(start + 1, last)), end >= 0, status); } private static byte[] bytes() { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 81afcef..18badd6 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -12,11 +12,14 @@ import io.github.libtmux.junit5.TmuxExtension; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.DispatchOutcome; import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; +import io.github.libtmux.transport.TmuxTransportException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -93,6 +96,28 @@ void aPaneTooNarrowToShowTheCommandStillYieldsOnlyItsOutput(Server server) { "no part of the plumbing may reach the model: " + ran.output()); } + @Test + void exitStatusSurvivesWhenTheStartMarkerRolledOutOfHistory(Server server) { + server.run(java.util.List.of("set-option", "-g", "history-limit", "10")); + server.run(java.util.List.of("new-window", "-d", "-n", "shallow")); + String pane = server.panes().stream() + .filter(candidate -> candidate.window().name().equals("shallow")) + .findFirst() + .orElseThrow() + .id() + .value(); + + RunningCommands.Ran ran = + RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "seq 1 80; exit 7")); + + assertEquals("SIGNALLED", ran.outcome()); + assertEquals(7, ran.exitStatus()); + assertFalse(ran.framed(), "the old start marker must actually have rolled away"); + assertTrue( + ran.output().stream().noneMatch(line -> line.matches(".*lt[0-9a-f]{10}-[se].*")), + "no surviving marker may leak into output: " + ran.output()); + } + /** * A command still running at the deadline is not a failure to report as one. What it printed so * far is worth having, and the note has to say what to do next. @@ -112,6 +137,48 @@ void aCommandStillRunningAtTheDeadlineSaysSoAndHandsBackWhatItHas(Server server) assertTrue(ran.seconds() < 20, "it must return at its deadline, not at the command's end"); } + @Test + void aTimedOutCommandLeavesNoStatusWhenItEventuallyFinishes(Server server) throws Exception { + String pane = server.panes().get(0).id().value(); + RunningCommands.Ran ran = + RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "sleep 1", "timeout", 0.1)); + server.panes().get(0).sendLine("printf 'timeout-cleanup-%s\\n' finished"); + + assertEquals("TIMED_OUT", ran.outcome()); + assertTrue( + await(() -> server.panes().get(0).capture().stream() + .anyMatch(line -> line.contains("timeout-cleanup-finished"))), + "the timed-out command never released the pane's shell"); + assertFalse( + server.panes().get(0).options().all().keySet().stream().anyMatch(name -> name.startsWith("@st_")), + "the eventual exit status was left on the pane"); + } + + @Test + void uncertainCommandDeliveryDoesNotLeaveThePaneBlocked(Server server) throws Exception { + String pane = server.panes().get(0).id().value(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport uncertain = borrowing(request -> { + CommandResult result = processes.execute(request); + if (request.argv().stream().anyMatch(argument -> argument.contains("ch_lt"))) { + throw new TmuxTransportException("simulated failure after delivery", DispatchOutcome.UNKNOWN, null); + } + return result; + }); + try (Server measured = Server.using(server.config(), uncertain)) { + assertThrows( + TmuxTransportException.class, + () -> RunningCommands.run(TestCalls.on(measured, "pane_id", pane, "command", "true"))); + server.panes().get(0).sendLine("printf 'uncertain-cleanup-%s\\n' finished"); + + assertTrue( + await(() -> server.panes().get(0).capture().stream() + .anyMatch(line -> line.contains("uncertain-cleanup-finished"))), + "an ambiguously delivered command left the pane's shell waiting for cleanup"); + } + } + } + @Test void theTimeoutIsClampedToTheCeilingAndTheAnswerSaysWhatWasEnforced(Server server) { String pane = server.panes().get(0).id().value(); @@ -187,37 +254,20 @@ void aPaneThatIsNotThereSaysWhichToolFindsOne(Server server) { assertTrue(message.contains("tmux_list_panes"), message); } - @Test - void aStatusOptionIsNotLeftBehindOnThePane(Server server) { - String pane = server.panes().get(0).id().value(); - - RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "true")); - - assertFalse( - server.panes().get(0).options().all().keySet().stream().anyMatch(name -> name.startsWith("@st_")), - "the exit status is read and then cleared away"); - } - @Test void concurrentRunsDoNotMergeTheirCommandLines(Server server) throws Exception { String pane = server.panes().get(0).id().value(); CountDownLatch bothLinesSent = new CountDownLatch(2); try (ProcessTransport processes = new ProcessTransport()) { - TmuxTransport interleaving = new TmuxTransport() { - @Override - public CommandResult execute(CommandRequest request) { - CommandResult result = processes.execute(request); - if (request.argv().get(0).equals("send-keys") - && request.argv().contains("-l")) { - bothLinesSent.countDown(); - await(bothLinesSent); - } - return result; + TmuxTransport interleaving = borrowing(request -> { + CommandResult result = processes.execute(request); + String argv = String.join("\0", request.argv()); + if (argv.contains("send-keys") && argv.contains("ch_lt")) { + bothLinesSent.countDown(); + await(bothLinesSent); } - - @Override - public void close() {} - }; + return result; + }); try (Server measured = Server.using(server.config(), interleaving); var calls = Executors.newVirtualThreadPerTaskExecutor()) { var first = calls.submit(() -> RunningCommands.run(TestCalls.on( @@ -245,4 +295,27 @@ private static void await(CountDownLatch latch) { throw new IllegalStateException("interrupted while arranging concurrent command delivery", e); } } + + private static boolean await(BooleanSupplier condition) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + Thread.sleep(20); + } + return condition.getAsBoolean(); + } + + private static TmuxTransport borrowing(java.util.function.Function execute) { + return new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return execute.apply(request); + } + + @Override + public void close() {} + }; + } } From e781531e1a96af5d38a14945712e92a3db30a7b4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:47:59 -0500 Subject: [PATCH 20/77] MCP(fix[stdio]): End sessions when output fails Treat failed protocol output as a disconnect, including broken-pipe errors hidden by PrintStream. Centralize exactly-once session completion across output and transport shutdown while preserving primary failures. --- .../github/libtmux/mcp/SessionLifetime.java | 187 ++++++++++++++++++ .../io/github/libtmux/mcp/TmuxMcpServer.java | 105 +--------- .../github/libtmux/mcp/McpLauncherTest.java | 65 ++++-- .../github/libtmux/mcp/TmuxMcpServerTest.java | 66 +++++++ 4 files changed, 311 insertions(+), 112 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java new file mode 100644 index 0000000..a60e24c --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java @@ -0,0 +1,187 @@ +package io.github.libtmux.mcp; + +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import reactor.core.publisher.Mono; + +/** Makes every way a protocol session can end converge on one callback. */ +final class SessionLifetime implements Runnable { + + private final Runnable ended; + private final AtomicBoolean signalled = new AtomicBoolean(); + + SessionLifetime(Runnable ended) { + this.ended = Objects.requireNonNull(ended, "ended"); + } + + OutputStream observe(OutputStream output) { + return new ObservedOutput(output, this); + } + + McpServerTransportProvider observe(McpServerTransportProvider provider) { + return new ObservedProvider(provider, this); + } + + @Override + public void run() { + if (signalled.compareAndSet(false, true)) { + ended.run(); + } + } + + @SuppressWarnings("ReferenceEquality") + private void runAfter(Throwable failure) { + try { + run(); + } catch (RuntimeException | Error callbackFailure) { + if (callbackFailure != failure) { + failure.addSuppressed(callbackFailure); + } + } + } + + private record ObservedProvider(McpServerTransportProvider delegate, SessionLifetime lifetime) + implements McpServerTransportProvider { + + @Override + public void setSessionFactory(io.modelcontextprotocol.spec.McpServerSession.Factory factory) { + delegate.setSessionFactory(transport -> factory.create(new ObservedTransport(transport, lifetime))); + } + + @Override + public Mono notifyClients(String method, Object params) { + return delegate.notifyClients(method, params); + } + + @Override + public Mono notifyClient(String sessionId, String method, Object params) { + return delegate.notifyClient(sessionId, method, params); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully().doFinally(ignored -> lifetime.run()); + } + + @Override + public void close() { + try { + delegate.close(); + } catch (RuntimeException | Error failure) { + lifetime.runAfter(failure); + throw failure; + } + lifetime.run(); + } + + @Override + public List protocolVersions() { + return delegate.protocolVersions(); + } + } + + private record ObservedTransport(McpServerTransport delegate, SessionLifetime lifetime) + implements McpServerTransport { + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return delegate.sendMessage(message); + } + + @Override + public T unmarshalFrom(Object value, TypeRef type) { + return delegate.unmarshalFrom(value, type); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully().doFinally(ignored -> lifetime.run()); + } + + @Override + public void close() { + try { + delegate.close(); + } catch (RuntimeException | Error failure) { + lifetime.runAfter(failure); + throw failure; + } + lifetime.run(); + } + + @Override + public List protocolVersions() { + return delegate.protocolVersions(); + } + } + + private static final class ObservedOutput extends OutputStream { + + private final OutputStream delegate; + private final SessionLifetime lifetime; + + ObservedOutput(OutputStream delegate, SessionLifetime lifetime) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.lifetime = lifetime; + } + + @Override + public void write(int value) throws IOException { + attempt(() -> delegate.write(value)); + } + + @Override + public void write(byte[] bytes) throws IOException { + attempt(() -> delegate.write(bytes)); + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + attempt(() -> delegate.write(bytes, offset, length)); + } + + @Override + public void flush() throws IOException { + attempt(delegate::flush); + if (delegate instanceof PrintStream stream && stream.checkError()) { + IOException failure = new IOException("protocol output is no longer writable"); + lifetime.runAfter(failure); + throw failure; + } + } + + @Override + public void close() throws IOException { + try { + delegate.close(); + } catch (IOException | RuntimeException | Error failure) { + lifetime.runAfter(failure); + throw failure; + } + lifetime.run(); + } + + private void attempt(IoAction action) throws IOException { + try { + action.run(); + } catch (IOException | RuntimeException | Error failure) { + lifetime.runAfter(failure); + throw failure; + } + } + } + + @FunctionalInterface + private interface IoAction { + + void run() throws IOException; + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index ac5c7c1..61e3cc9 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -3,22 +3,19 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.github.libtmux.LibTmuxException; import io.github.libtmux.Server; -import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpServerTransport; import io.modelcontextprotocol.spec.McpServerTransportProvider; import java.io.InputStream; +import java.io.OutputStream; import java.time.Duration; -import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import org.jspecify.annotations.Nullable; -import reactor.core.publisher.Mono; /** * Exposes a tmux server to a model over the Model Context Protocol. @@ -77,13 +74,15 @@ public static McpSyncServer overStdio(Server server, InputStream in, Safety ceil static McpSyncServer overStdio( Server server, InputStream in, Safety ceiling, boolean watching, Runnable onSessionEnd) { - return serving( - server, - ceiling, - watching, - new SessionEndedProvider( - new StdioServerTransportProvider(new JacksonMcpJsonMapper(new ObjectMapper()), in, System.out), - onSessionEnd)); + return overStdio(server, in, System.out, ceiling, watching, onSessionEnd); + } + + static McpSyncServer overStdio( + Server server, InputStream in, OutputStream out, Safety ceiling, boolean watching, Runnable onSessionEnd) { + SessionLifetime lifetime = new SessionLifetime(onSessionEnd); + var provider = new StdioServerTransportProvider( + new JacksonMcpJsonMapper(new ObjectMapper()), in, lifetime.observe(out)); + return serving(server, ceiling, watching, lifetime.observe(provider)); } /** Serves a tmux server over a caller-supplied transport. */ @@ -185,90 +184,6 @@ public void close() { } } - /** Makes the SDK's actual protocol-session lifetime observable to a stdio launcher. */ - private static final class SessionEndedProvider implements McpServerTransportProvider { - - private final McpServerTransportProvider delegate; - private final Runnable ended; - - SessionEndedProvider(McpServerTransportProvider delegate, Runnable ended) { - this.delegate = delegate; - AtomicBoolean signalled = new AtomicBoolean(); - this.ended = () -> { - if (signalled.compareAndSet(false, true)) { - ended.run(); - } - }; - } - - @Override - public void setSessionFactory(io.modelcontextprotocol.spec.McpServerSession.Factory factory) { - delegate.setSessionFactory(transport -> factory.create(new SessionEndedTransport(transport, ended))); - } - - @Override - public Mono notifyClients(String method, Object params) { - return delegate.notifyClients(method, params); - } - - @Override - public Mono notifyClient(String sessionId, String method, Object params) { - return delegate.notifyClient(sessionId, method, params); - } - - @Override - public Mono closeGracefully() { - return delegate.closeGracefully().doFinally(ignored -> ended.run()); - } - - @Override - public void close() { - try { - delegate.close(); - } finally { - ended.run(); - } - } - - @Override - public List protocolVersions() { - return delegate.protocolVersions(); - } - } - - /** Signals both graceful and immediate session termination without changing transport behavior. */ - private record SessionEndedTransport(McpServerTransport delegate, Runnable ended) implements McpServerTransport { - - @Override - public Mono sendMessage(McpSchema.JSONRPCMessage message) { - return delegate.sendMessage(message); - } - - @Override - public T unmarshalFrom(Object value, TypeRef type) { - return delegate.unmarshalFrom(value, type); - } - - @Override - public Mono closeGracefully() { - return delegate.closeGracefully().doFinally(ignored -> ended.run()); - } - - @Override - public void close() { - try { - delegate.close(); - } finally { - ended.run(); - } - } - - @Override - public List protocolVersions() { - return delegate.protocolVersions(); - } - } - /** * Runs one tool and turns whatever happens into something a model can act on. * diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java index 6bbca4b..69ef4ad 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java @@ -18,6 +18,7 @@ import io.modelcontextprotocol.client.transport.StdioClientTransport; import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -47,18 +48,7 @@ final class McpLauncherTest { @Test void malformedInputDoesNotLeaveTheLauncherWaitingForEndOfInput(Server server, TmuxSocketPath socket) throws Exception { - Process launcher = new ProcessBuilder(List.of( - Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-classpath", - System.getProperty("java.class.path"), - Main.class.getName(), - "--socket", - socket.path().toString(), - "--tmux", - TMUX)) - .redirectOutput(ProcessBuilder.Redirect.DISCARD) - .redirectError(ProcessBuilder.Redirect.DISCARD) - .start(); + Process launcher = rawLauncher(socket.path(), ProcessBuilder.Redirect.DISCARD); try { launcher.getOutputStream().write("{not-json}\n".getBytes(StandardCharsets.UTF_8)); launcher.getOutputStream().flush(); @@ -67,11 +57,29 @@ void malformedInputDoesNotLeaveTheLauncherWaitingForEndOfInput(Server server, Tm launcher.waitFor(5, TimeUnit.SECONDS), "the protocol session ended, but the launcher was still waiting for stdin EOF"); } finally { - launcher.getOutputStream().close(); - if (!launcher.waitFor(5, TimeUnit.SECONDS)) { - launcher.destroyForcibly(); - launcher.waitFor(5, TimeUnit.SECONDS); - } + stop(launcher); + } + } + + /** Broken stdout is also a disconnect, even if the client leaves stdin open. */ + @Test + void brokenOutputDoesNotLeaveTheLauncherWaitingForEndOfInput(Server server, TmuxSocketPath socket) + throws Exception { + Process launcher = rawLauncher(socket.path(), ProcessBuilder.Redirect.PIPE); + try { + launcher.getInputStream().close(); + String initialize = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{" + + "\"protocolVersion\":\"" + ProtocolVersions.MCP_2025_11_25 + + "\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1\"}}}\n"; + launcher.getOutputStream().write(initialize.getBytes(StandardCharsets.UTF_8)); + launcher.getOutputStream().flush(); + + assertTrue( + launcher.waitFor(5, TimeUnit.SECONDS), + "stdout failed, but the launcher was still waiting for stdin EOF"); + assertEquals(0, launcher.exitValue()); + } finally { + stop(launcher); } } @@ -449,6 +457,29 @@ private static McpSyncClient launch( .build(); } + private static Process rawLauncher(Path socket, ProcessBuilder.Redirect output) throws IOException { + return new ProcessBuilder(List.of( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-classpath", + System.getProperty("java.class.path"), + Main.class.getName(), + "--socket", + socket.toString(), + "--tmux", + TMUX)) + .redirectOutput(output) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + } + + private static void stop(Process launcher) throws InterruptedException, IOException { + launcher.getOutputStream().close(); + if (!launcher.waitFor(5, TimeUnit.SECONDS)) { + launcher.destroyForcibly(); + launcher.waitFor(5, TimeUnit.SECONDS); + } + } + private static Server openNamed(String name, Path directory) throws IOException { Path config = directory.resolve(name + ".conf"); Files.writeString(config, ""); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java index 743fefe..b02c729 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java @@ -1,6 +1,8 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; @@ -14,10 +16,18 @@ import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.ProtocolVersions; import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -74,6 +84,62 @@ void closingAnEmbeddedMcpServerClosesItsWatcher(Server server) throws Exception } } + @Test + void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throws Exception { + CountDownLatch ended = new CountDownLatch(1); + AtomicInteger endCalls = new AtomicInteger(); + PipedInputStream input = new PipedInputStream(); + try (PipedOutputStream client = new PipedOutputStream(input); + PrintStream output = new PrintStream(brokenOutput(), true, StandardCharsets.UTF_8)) { + McpSyncServer mcp = TmuxMcpServer.overStdio(server, input, output, Safety.MUTATING, false, () -> { + endCalls.incrementAndGet(); + ended.countDown(); + }); + try { + String initialize = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{" + + "\"protocolVersion\":\"" + ProtocolVersions.MCP_2025_11_25 + + "\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1\"}}}\n"; + client.write(initialize.getBytes(StandardCharsets.UTF_8)); + client.flush(); + + assertTrue(ended.await(3, TimeUnit.SECONDS), "stdout failed but the protocol session stayed alive"); + } finally { + mcp.close(); + } + assertEquals(1, endCalls.get(), "one failed session reported more than one end"); + } + } + + @Test + void callbackFailureDoesNotHideProtocolOutputFailure() { + IOException outputFailure = new IOException("client stopped reading"); + IllegalStateException callbackFailure = new IllegalStateException("session-end callback failed"); + OutputStream output = new SessionLifetime(() -> { + throw callbackFailure; + }) + .observe(new OutputStream() { + @Override + public void write(int value) throws IOException { + throw outputFailure; + } + }); + + IOException thrown = assertThrows(IOException.class, () -> output.write(0)); + + assertSame(outputFailure, thrown); + assertEquals(1, thrown.getSuppressed().length); + assertSame(callbackFailure, thrown.getSuppressed()[0]); + } + + private static OutputStream brokenOutput() { + return new OutputStream() { + @Override + public void write(int value) throws IOException { + throw new IOException("client stopped reading"); + } + }; + } + private static boolean await(java.util.function.BooleanSupplier condition) throws InterruptedException { long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); while (System.nanoTime() < deadline) { From c9e4807012be92efb9bc3d6caba35dc83f6e42a3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:49:22 -0500 Subject: [PATCH 21/77] MCP(fix[input]): Bound protocol message size Upgrade to MCP SDK 2.0.1 so unterminated stdio messages have a hard size limit. Pin the bounded constructor with a focused session-lifecycle regression. --- gradle/libs.versions.toml | 2 +- .../github/libtmux/mcp/TmuxMcpServerTest.java | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a207f0..06adfd9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ jspecify = "1.0.1" kotlin = "2.4.10" junit = "5.14.4" jackson = "2.21.5" -mcp = "2.0.0" +mcp = "2.0.1" slf4j = "2.0.17" errorprone = "2.50.0" nullaway = "0.13.8" diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java index b02c729..db1b297 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java @@ -110,6 +110,26 @@ void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throw } } + @Test + void oversizedStdioInputEndsTheSessionBeforeNewline(Server server) throws Exception { + CountDownLatch ended = new CountDownLatch(1); + PipedInputStream input = new PipedInputStream(); + SessionLifetime lifetime = new SessionLifetime(ended::countDown); + try (PipedOutputStream client = new PipedOutputStream(input)) { + var transport = new StdioServerTransportProvider( + new JacksonMcpJsonMapper(new ObjectMapper()), input, new ByteArrayOutputStream(), 64); + McpSyncServer mcp = TmuxMcpServer.serving(server, Safety.MUTATING, lifetime.observe(transport)); + try { + client.write("x".repeat(65).getBytes(StandardCharsets.UTF_8)); + client.flush(); + + assertTrue(ended.await(3, TimeUnit.SECONDS), "oversized input kept buffering without a newline"); + } finally { + mcp.close(); + } + } + } + @Test void callbackFailureDoesNotHideProtocolOutputFailure() { IOException outputFailure = new IOException("client stopped reading"); From 7117d4837b3790fbfaa0e3d8458ef3576855caa9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:04:44 -0500 Subject: [PATCH 22/77] MCP(fix[stdio]): Own session resources through disconnect Close the watcher, transport, and input when either stdio side ends or startup fails. Signal the launcher before cleanup and preserve primary failures across immediate and graceful shutdown. --- .../github/libtmux/mcp/SessionLifetime.java | 97 ++++++++++++-- .../io/github/libtmux/mcp/TmuxMcpServer.java | 44 +++++-- .../libtmux/mcp/SessionLifetimeTest.java | 114 +++++++++++++++++ .../github/libtmux/mcp/TmuxMcpServerTest.java | 121 ++++++++++++++---- 4 files changed, 328 insertions(+), 48 deletions(-) create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/SessionLifetimeTest.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java index a60e24c..e1cd874 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java @@ -7,15 +7,18 @@ import java.io.IOException; import java.io.OutputStream; import java.io.PrintStream; +import java.util.ArrayDeque; import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; +import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; /** Makes every way a protocol session can end converge on one callback. */ final class SessionLifetime implements Runnable { private final Runnable ended; + private final ArrayDeque owned = new ArrayDeque<>(); private final AtomicBoolean signalled = new AtomicBoolean(); SessionLifetime(Runnable ended) { @@ -30,22 +33,90 @@ McpServerTransportProvider observe(McpServerTransportProvider provider) { return new ObservedProvider(provider, this); } + /** Closes a session-scoped resource now or when the first end signal arrives. */ + void own(AutoCloseable resource) { + Objects.requireNonNull(resource, "resource"); + synchronized (owned) { + if (!signalled.get()) { + owned.addLast(resource); + return; + } + } + close(resource); + } + @Override public void run() { if (signalled.compareAndSet(false, true)) { - ended.run(); + @Nullable Throwable failure = finish(null); + if (failure instanceof RuntimeException runtime) { + throw runtime; + } + if (failure instanceof Error error) { + throw error; + } + if (failure != null) { + throw new IllegalStateException("could not close protocol session", failure); + } } } - @SuppressWarnings("ReferenceEquality") - private void runAfter(Throwable failure) { + void endAfter(Throwable failure) { + if (signalled.compareAndSet(false, true)) { + finish(failure); + } + } + + private @Nullable Throwable finish(@Nullable Throwable failure) { try { - run(); + ended.run(); } catch (RuntimeException | Error callbackFailure) { - if (callbackFailure != failure) { - failure.addSuppressed(callbackFailure); + failure = suppress(failure, callbackFailure); + } + AutoCloseable resource; + while ((resource = takeOwned()) != null) { + try { + resource.close(); + } catch (Exception | Error closeFailure) { + failure = suppress(failure, closeFailure); } } + return failure; + } + + private @Nullable AutoCloseable takeOwned() { + synchronized (owned) { + return owned.pollLast(); + } + } + + private Mono endWith(Mono closing) { + return closing.onErrorResume(failure -> { + endAfter(failure); + return Mono.error(failure); + }) + .then(Mono.fromRunnable(this)); + } + + private static void close(AutoCloseable resource) { + try { + resource.close(); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Exception failure) { + throw new IllegalStateException("could not close protocol session resource", failure); + } + } + + @SuppressWarnings("ReferenceEquality") + static Throwable suppress(@Nullable Throwable primary, Throwable secondary) { + if (primary == null) { + return secondary; + } + if (secondary != primary) { + primary.addSuppressed(secondary); + } + return primary; } private record ObservedProvider(McpServerTransportProvider delegate, SessionLifetime lifetime) @@ -68,7 +139,7 @@ public Mono notifyClient(String sessionId, String method, Object params) { @Override public Mono closeGracefully() { - return delegate.closeGracefully().doFinally(ignored -> lifetime.run()); + return lifetime.endWith(delegate.closeGracefully()); } @Override @@ -76,7 +147,7 @@ public void close() { try { delegate.close(); } catch (RuntimeException | Error failure) { - lifetime.runAfter(failure); + lifetime.endAfter(failure); throw failure; } lifetime.run(); @@ -103,7 +174,7 @@ public T unmarshalFrom(Object value, TypeRef type) { @Override public Mono closeGracefully() { - return delegate.closeGracefully().doFinally(ignored -> lifetime.run()); + return lifetime.endWith(delegate.closeGracefully()); } @Override @@ -111,7 +182,7 @@ public void close() { try { delegate.close(); } catch (RuntimeException | Error failure) { - lifetime.runAfter(failure); + lifetime.endAfter(failure); throw failure; } lifetime.run(); @@ -153,7 +224,7 @@ public void flush() throws IOException { attempt(delegate::flush); if (delegate instanceof PrintStream stream && stream.checkError()) { IOException failure = new IOException("protocol output is no longer writable"); - lifetime.runAfter(failure); + lifetime.endAfter(failure); throw failure; } } @@ -163,7 +234,7 @@ public void close() throws IOException { try { delegate.close(); } catch (IOException | RuntimeException | Error failure) { - lifetime.runAfter(failure); + lifetime.endAfter(failure); throw failure; } lifetime.run(); @@ -173,7 +244,7 @@ private void attempt(IoAction action) throws IOException { try { action.run(); } catch (IOException | RuntimeException | Error failure) { - lifetime.runAfter(failure); + lifetime.endAfter(failure); throw failure; } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index 61e3cc9..b461a3e 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -66,6 +66,9 @@ public static McpSyncServer overStdio(Server server) { *

Taking the stream lets a launcher notice end of input for itself. A client that disconnects * closes this end, and a server that did not notice would outlive it. * + *

The returned server owns and closes the input when startup fails, either protocol stream + * disconnects, or the server closes. + * * @param watching whether to attach a control client and push notifications as tmux changes */ public static McpSyncServer overStdio(Server server, InputStream in, Safety ceiling, boolean watching) { @@ -80,9 +83,16 @@ static McpSyncServer overStdio( static McpSyncServer overStdio( Server server, InputStream in, OutputStream out, Safety ceiling, boolean watching, Runnable onSessionEnd) { SessionLifetime lifetime = new SessionLifetime(onSessionEnd); - var provider = new StdioServerTransportProvider( - new JacksonMcpJsonMapper(new ObjectMapper()), in, lifetime.observe(out)); - return serving(server, ceiling, watching, lifetime.observe(provider)); + lifetime.own(in); + try { + var provider = new StdioServerTransportProvider( + new JacksonMcpJsonMapper(new ObjectMapper()), in, lifetime.observe(out)); + lifetime.own(provider::close); + return serving(server, ceiling, watching, lifetime.observe(provider), lifetime); + } catch (RuntimeException | Error failure) { + lifetime.endAfter(failure); + throw failure; + } } /** Serves a tmux server over a caller-supplied transport. */ @@ -93,22 +103,38 @@ public static McpSyncServer serving(Server server, Safety ceiling, McpServerTran /** Serves a tmux server over a caller-supplied transport, optionally watching it for changes. */ public static McpSyncServer serving( Server server, Safety ceiling, boolean watching, McpServerTransportProvider transport) { + return serving(server, ceiling, watching, transport, null); + } + + private static McpSyncServer serving( + Server server, + Safety ceiling, + boolean watching, + McpServerTransportProvider transport, + @Nullable SessionLifetime lifetime) { Connection connection = Connection.to(server, ceiling); if (watching) { Watches watches = Watches.prepare(connection); + if (lifetime != null) { + lifetime.own(watches); + } @Nullable WatchedMcpServer owned = null; try { McpSyncServer built = build(connection, true, transport); owned = new WatchedMcpServer(built, watches); watches.start(new McpNotifier(owned)); return owned; - } catch (RuntimeException | Error e) { - if (owned == null) { - watches.close(); - } else { - owned.close(); + } catch (RuntimeException | Error failure) { + try { + if (owned == null) { + watches.close(); + } else { + owned.close(); + } + } catch (RuntimeException | Error cleanupFailure) { + SessionLifetime.suppress(failure, cleanupFailure); } - throw e; + throw failure; } } return build(connection, false, transport); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SessionLifetimeTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SessionLifetimeTest.java new file mode 100644 index 0000000..6fd72d7 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SessionLifetimeTest.java @@ -0,0 +1,114 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +final class SessionLifetimeTest { + + @Test + void callbackFailureDoesNotHideProtocolOutputFailure() { + IOException outputFailure = new IOException("client stopped reading"); + IllegalStateException callbackFailure = new IllegalStateException("session-end callback failed"); + OutputStream output = new SessionLifetime(() -> { + throw callbackFailure; + }) + .observe(new OutputStream() { + @Override + public void write(int value) throws IOException { + throw outputFailure; + } + }); + + IOException thrown = assertThrows(IOException.class, () -> output.write(0)); + + assertSame(outputFailure, thrown); + assertEquals(1, thrown.getSuppressed().length); + assertSame(callbackFailure, thrown.getSuppressed()[0]); + } + + @Test + void blockingOwnedCleanupCannotDelayTheEndSignal() throws Exception { + CountDownLatch ended = new CountDownLatch(1); + CountDownLatch closing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + SessionLifetime lifetime = new SessionLifetime(ended::countDown); + lifetime.own(() -> { + closing.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("test did not release cleanup"); + } + }); + Thread ending = Thread.ofVirtual().start(() -> lifetime.endAfter(new IOException("client disconnected"))); + try { + assertTrue(closing.await(1, TimeUnit.SECONDS), "owned cleanup never started"); + assertTrue(ended.await(1, TimeUnit.SECONDS), "owned cleanup blocked the session-end signal"); + } finally { + release.countDown(); + ending.join(); + } + } + + @Test + void gracefulCloseReportsSessionEndFailure() { + IllegalStateException endFailure = new IllegalStateException("session-end callback failed"); + SessionLifetime lifetime = new SessionLifetime(() -> { + throw endFailure; + }); + McpServerTransportProvider observed = lifetime.observe(providerClosingWith(Mono.empty())); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, () -> observed.closeGracefully().block()); + + assertSame(endFailure, thrown); + } + + @Test + void gracefulClosePreservesProviderFailure() { + IllegalStateException providerFailure = new IllegalStateException("provider close failed"); + IllegalStateException callbackFailure = new IllegalStateException("session-end callback failed"); + IllegalStateException cleanupFailure = new IllegalStateException("owned cleanup failed"); + SessionLifetime lifetime = new SessionLifetime(() -> { + throw callbackFailure; + }); + lifetime.own(() -> { + throw cleanupFailure; + }); + McpServerTransportProvider observed = lifetime.observe(providerClosingWith(Mono.error(providerFailure))); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, () -> observed.closeGracefully().block()); + + assertSame(providerFailure, thrown); + assertTrue(Arrays.asList(thrown.getSuppressed()).contains(callbackFailure)); + assertTrue(Arrays.asList(thrown.getSuppressed()).contains(cleanupFailure)); + } + + private static McpServerTransportProvider providerClosingWith(Mono closing) { + return new McpServerTransportProvider() { + @Override + public void setSessionFactory(McpServerSession.Factory factory) {} + + @Override + public Mono notifyClients(String method, Object params) { + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return closing; + } + }; + } +} diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java index db1b297..0d73556 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java @@ -1,7 +1,6 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -96,10 +95,7 @@ void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throw ended.countDown(); }); try { - String initialize = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{" - + "\"protocolVersion\":\"" + ProtocolVersions.MCP_2025_11_25 - + "\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1\"}}}\n"; - client.write(initialize.getBytes(StandardCharsets.UTF_8)); + client.write(initialize()); client.flush(); assertTrue(ended.await(3, TimeUnit.SECONDS), "stdout failed but the protocol session stayed alive"); @@ -110,6 +106,58 @@ void brokenOutputEndsAStdioSessionEvenWhileInputRemainsOpen(Server server) throw } } + @Test + void closingAStdioServerUnblocksItsInputReader(Server server) throws Exception { + BlockingInput input = new BlockingInput(); + McpSyncServer mcp = + TmuxMcpServer.overStdio(server, input, new ByteArrayOutputStream(), Safety.MUTATING, false, () -> {}); + try { + assertTrue(input.reading.await(3, TimeUnit.SECONDS), "the protocol reader never started"); + + mcp.close(); + + assertTrue(input.closed.await(3, TimeUnit.SECONDS), "closing MCP left its input stream open"); + assertTrue(input.readEnded.await(3, TimeUnit.SECONDS), "closing MCP left its input reader blocked"); + } finally { + input.close(); + mcp.close(); + } + } + + @Test + void failedStdioStartupClosesItsOwnedInput(Server server) throws Exception { + server.sessions().getFirst().kill(); + BlockingInput input = new BlockingInput(); + try { + assertThrows( + IllegalStateException.class, + () -> TmuxMcpServer.overStdio( + server, input, new ByteArrayOutputStream(), Safety.MUTATING, true, () -> {})); + + assertTrue(input.closed.await(1, TimeUnit.SECONDS), "failed startup left its input stream open"); + } finally { + input.close(); + } + } + + @Test + void brokenOutputDetachesTheOwnedWatcherWhileInputRemainsOpen(Server server) throws Exception { + PipedInputStream input = new PipedInputStream(); + try (PipedOutputStream client = new PipedOutputStream(input); + PrintStream output = new PrintStream(brokenOutput(), true, StandardCharsets.UTF_8)) { + McpSyncServer mcp = TmuxMcpServer.overStdio(server, input, output, Safety.MUTATING, true, () -> {}); + try { + assertTrue(await(() -> !server.clients().isEmpty()), "the watcher never attached"); + client.write(initialize()); + client.flush(); + + assertTrue(await(() -> server.clients().isEmpty()), "stdout failed but the watcher stayed attached"); + } finally { + mcp.close(); + } + } + } + @Test void oversizedStdioInputEndsTheSessionBeforeNewline(Server server) throws Exception { CountDownLatch ended = new CountDownLatch(1); @@ -130,27 +178,6 @@ void oversizedStdioInputEndsTheSessionBeforeNewline(Server server) throws Except } } - @Test - void callbackFailureDoesNotHideProtocolOutputFailure() { - IOException outputFailure = new IOException("client stopped reading"); - IllegalStateException callbackFailure = new IllegalStateException("session-end callback failed"); - OutputStream output = new SessionLifetime(() -> { - throw callbackFailure; - }) - .observe(new OutputStream() { - @Override - public void write(int value) throws IOException { - throw outputFailure; - } - }); - - IOException thrown = assertThrows(IOException.class, () -> output.write(0)); - - assertSame(outputFailure, thrown); - assertEquals(1, thrown.getSuppressed().length); - assertSame(callbackFailure, thrown.getSuppressed()[0]); - } - private static OutputStream brokenOutput() { return new OutputStream() { @Override @@ -160,6 +187,48 @@ public void write(int value) throws IOException { }; } + private static byte[] initialize() { + String request = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{" + + "\"protocolVersion\":\"" + ProtocolVersions.MCP_2025_11_25 + + "\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1\"}}}\n"; + return request.getBytes(StandardCharsets.UTF_8); + } + + private static final class BlockingInput extends java.io.InputStream { + + private final CountDownLatch reading = new CountDownLatch(1); + private final CountDownLatch closed = new CountDownLatch(1); + private final CountDownLatch readEnded = new CountDownLatch(1); + + @Override + public int read() { + return awaitClose(); + } + + @Override + public int read(byte[] bytes, int offset, int length) { + return awaitClose(); + } + + private int awaitClose() { + reading.countDown(); + try { + closed.await(); + return -1; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return -1; + } finally { + readEnded.countDown(); + } + } + + @Override + public void close() { + closed.countDown(); + } + } + private static boolean await(java.util.function.BooleanSupplier condition) throws InterruptedException { long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); while (System.nanoTime() < deadline) { From f77d5430d47e4d9b73f6f0f35abd5823e278039e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:08:20 -0500 Subject: [PATCH 23/77] MCP(fix[launcher]): Release transport after startup failure Own the tmux Server lexically so failed watcher or protocol setup cannot strand its non-daemon transport threads. Cover the empty-server watch failure through a real child process. --- .../main/java/io/github/libtmux/mcp/Main.java | 33 ++++++------ .../github/libtmux/mcp/McpLauncherTest.java | 53 +++++++++++++++---- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java index 30e7b7b..a5f8c1d 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Main.java @@ -54,24 +54,25 @@ public static void main(String[] args) { System.exit(2); return; } - // The server outlives this call: the MCP transport reads stdin until the client closes it. - Server server = Server.open(config); - Runtime.getRuntime().addShutdownHook(new Thread(server::close, "libtmux-mcp-shutdown")); - System.err.println("libtmux-mcp: serving " + server.identity() + " at safety " + ceiling.wireName() + " (" - + Catalog.offered(ceiling).size() + " tools)"); + // The server outlives setup: the MCP transport reads stdin until the client closes it. + // Lexical ownership also releases its process transport when protocol startup fails. + try (Server server = Server.open(config)) { + Runtime.getRuntime().addShutdownHook(new Thread(server::close, "libtmux-mcp-shutdown")); + System.err.println("libtmux-mcp: serving " + server.identity() + " at safety " + ceiling.wireName() + " (" + + Catalog.offered(ceiling).size() + " tools)"); - // A client that disconnects closes this end. Without noticing that, the process outlives the - // client that launched it, and an MCP client leaves one behind every time it restarts. - CountDownLatch disconnected = new CountDownLatch(1); - var mcp = TmuxMcpServer.overStdio(server, System.in, ceiling, watching, disconnected::countDown); - Runtime.getRuntime().addShutdownHook(new Thread(mcp::close, "libtmux-mcp-protocol-shutdown")); - try { - disconnected.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + // A client that disconnects closes this end. Without noticing that, the process outlives + // the client that launched it, and an MCP client leaves one behind every time it restarts. + CountDownLatch disconnected = new CountDownLatch(1); + var mcp = TmuxMcpServer.overStdio(server, System.in, ceiling, watching, disconnected::countDown); + Runtime.getRuntime().addShutdownHook(new Thread(mcp::close, "libtmux-mcp-protocol-shutdown")); + try { + disconnected.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + mcp.closeGracefully(); } - mcp.closeGracefully(); - server.close(); System.exit(0); } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java index 69ef4ad..8ac68b9 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java @@ -44,6 +44,28 @@ final class McpLauncherTest { /** Longer than any single call needs, short enough that a hung launcher fails as itself. */ private static final int PATIENCE_SECONDS = 60; + @Test + void failedWatchStartupDoesNotLeaveTheLauncherAlive(Server server, TmuxSocketPath socket) throws Exception { + assertTrue(server.cmd("set-option", "-g", "exit-empty", "off").succeeded()); + server.sessions().getFirst().kill(); + assertTrue(server.sessions().isEmpty(), "the fixture still had a session for the watcher to attach to"); + + Process launcher = + rawLauncher(socket.path(), ProcessBuilder.Redirect.DISCARD, ProcessBuilder.Redirect.PIPE, "--watch"); + try { + assertTrue( + launcher.waitFor(5, TimeUnit.SECONDS), + "watch startup failed, but the launcher stayed alive on its tmux transport threads"); + assertTrue(launcher.exitValue() != 0, "failed watch startup reported success"); + String diagnostic = new String(launcher.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue( + diagnostic.contains("tmux list-windows failed: no current target"), + "the launcher failed for the wrong reason: " + diagnostic); + } finally { + stop(launcher); + } + } + /** Protocol failure ends the session even when the client forgets to close its stdin pipe. */ @Test void malformedInputDoesNotLeaveTheLauncherWaitingForEndOfInput(Server server, TmuxSocketPath socket) @@ -457,18 +479,27 @@ private static McpSyncClient launch( .build(); } - private static Process rawLauncher(Path socket, ProcessBuilder.Redirect output) throws IOException { - return new ProcessBuilder(List.of( - Path.of(System.getProperty("java.home"), "bin", "java").toString(), - "-classpath", - System.getProperty("java.class.path"), - Main.class.getName(), - "--socket", - socket.toString(), - "--tmux", - TMUX)) + private static Process rawLauncher(Path socket, ProcessBuilder.Redirect output, String... extra) + throws IOException { + return rawLauncher(socket, output, ProcessBuilder.Redirect.DISCARD, extra); + } + + private static Process rawLauncher( + Path socket, ProcessBuilder.Redirect output, ProcessBuilder.Redirect error, String... extra) + throws IOException { + List args = new java.util.ArrayList<>(List.of( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-classpath", + System.getProperty("java.class.path"), + Main.class.getName(), + "--socket", + socket.toString(), + "--tmux", + TMUX)); + args.addAll(List.of(extra)); + return new ProcessBuilder(args) .redirectOutput(output) - .redirectError(ProcessBuilder.Redirect.DISCARD) + .redirectError(error) .start(); } From 4d39f3d6465d3ffeb93446bdc57f890c6de14a92 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:20:48 -0500 Subject: [PATCH 24/77] Core(fix[waits]): Reserve transport capacity for signals Limit externally signalled waits to all but one process so signal and observation calls remain dispatchable without increasing the transport bound. Fail excess waits before dispatch and preserve dispatch certainty across close races. --- .../java/io/github/libtmux/mcp/Channels.java | 2 +- .../main/java/io/github/libtmux/Server.java | 36 ++++- .../libtmux/transport/ProcessTransport.java | 72 ++++++++-- .../libtmux/transport/TmuxTransport.java | 11 ++ .../java/io/github/libtmux/ServerTest.java | 37 +++++ .../transport/ProcessTransportTest.java | 128 ++++++++++++++++++ 6 files changed, 270 insertions(+), 16 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Channels.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Channels.java index 25da2a8..5ba82ea 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Channels.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Channels.java @@ -39,7 +39,7 @@ static Woke waitFor(Call call) { call.server().drain(channel); } long started = System.nanoTime(); - WakeReason wake = call.server().waitFor(channel, timeout); + WakeReason wake = call.server().waitForWithSignalCapacity(channel, timeout); double seconds = (System.nanoTime() - started) / 1_000_000_000.0; return new Woke( channel, wake.name(), Math.round(seconds * 100) / 100.0, Waits.asSeconds(timeout), note(wake, drained)); diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 837ba19..db7c7db 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -11,6 +11,7 @@ import io.github.libtmux.snapshot.WindowState; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.DispatchOutcome; import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; import java.nio.file.Path; @@ -399,9 +400,36 @@ public List listKeys() { * @return why the wait ended, which is never simply "successfully" */ public WakeReason waitFor(String channel, Duration timeout) { + return waitFor(channel, timeout, false); + } + + /** + * Waits while preserving process capacity for a call through this server that signals the + * channel. + * + *

Use this when the waiter and its release share a bounded transport. A wait released outside + * that transport should use {@link #waitFor}; reserving capacity for it only rejects useful + * concurrency. + * + * @throws io.github.libtmux.transport.TmuxTransportException if the wait could not be + * dispatched + */ + public WakeReason waitForWithSignalCapacity(String channel, Duration timeout) { + return waitFor(channel, timeout, true); + } + + private WakeReason waitFor(String channel, Duration timeout, boolean reserveSignalCapacity) { try { - cmd(List.of("wait-for", channel), timeout); + CommandRequest request = request(List.of("wait-for", channel), timeout); + if (reserveSignalCapacity) { + transport.executeWaiting(request); + } else { + transport.execute(request); + } } catch (io.github.libtmux.transport.TmuxTimeoutException e) { + if (reserveSignalCapacity && e.outcome() == DispatchOutcome.NOT_DISPATCHED) { + throw e; + } // The transport killed the waiting client at the deadline; nothing signalled it. return isAlive() ? WakeReason.TIMED_OUT : WakeReason.SERVER_GONE; } @@ -532,13 +560,17 @@ public CommandResult cmd(List argv) { /** Runs one tmux command against this server, overriding the configured deadline. */ public CommandResult cmd(List argv, Duration timeout) { + return transport.execute(request(argv, timeout)); + } + + private CommandRequest request(List argv, Duration timeout) { if (closed.get()) { throw new IllegalStateException("server is closed"); } List endpoint = config.endpointCommand(); List command = new ArrayList<>(endpoint.size()); command.addAll(endpoint); - return transport.execute(new CommandRequest(command, argv, timeout)); + return new CommandRequest(command, argv, timeout); } /** diff --git a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java index a2799a3..cb8c5f5 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java @@ -38,6 +38,11 @@ * caller can start a child whose pipes nobody is reading, and a child whose pipe fills stops * instead of exiting. * + *

A request declared as waiting also takes one of all but one admission permits. The remaining + * process stays available for the ordinary request that observes or releases those waits, without + * increasing the total process or pump bound. An additional waiter is refused before dispatch, so + * its caller knows it was never registered. + * *

The caller itself may be a virtual thread: on JDK 21 {@code Process.waitFor} takes a * {@link ReentrantLock}, so blocking there releases the carrier. The drains may not be, for two * independent reasons. A process pipe read is monitor-locked, and — more decisively — a library @@ -57,6 +62,7 @@ public final class ProcessTransport implements TmuxTransport { private static final ProcessStarter SYSTEM_STARTER = command -> new ProcessBuilder(command).start(); private final Semaphore admission; + private final @Nullable Semaphore waitingAdmission; private final ThreadPoolExecutor pumps; private final int maxOutputBytes; private final ProcessStarter starter; @@ -79,6 +85,7 @@ public ProcessTransport() { /** * @param maxConcurrentProcesses how many tmux processes may run at once + * ({@code executeWaiting} requires at least two) */ public ProcessTransport(int maxConcurrentProcesses) { this(maxConcurrentProcesses, DEFAULT_MAX_OUTPUT_BYTES); @@ -100,6 +107,7 @@ public ProcessTransport(int maxConcurrentProcesses, int maxOutputBytes) { throw new IllegalArgumentException("maxOutputBytes is not positive"); } this.admission = new Semaphore(maxConcurrentProcesses); + this.waitingAdmission = maxConcurrentProcesses == 1 ? null : new Semaphore(maxConcurrentProcesses - 1); this.pumps = (ThreadPoolExecutor) Executors.newFixedThreadPool(2 * maxConcurrentProcesses, factory()); this.maxOutputBytes = maxOutputBytes; this.starter = Objects.requireNonNull(starter, "starter"); @@ -108,16 +116,39 @@ public ProcessTransport(int maxConcurrentProcesses, int maxOutputBytes) { @Override public CommandResult execute(CommandRequest request) { + return execute(request, false); + } + + @Override + public CommandResult executeWaiting(CommandRequest request) { + return execute(request, true); + } + + private CommandResult execute(CommandRequest request, boolean waiting) { requireOpen(); requireDispatchable(request.argv()); + @Nullable Semaphore waitingPermit = waiting ? waitingAdmission : null; + if (waiting && waitingPermit == null) { + throw new TmuxTransportException( + "transport capacity leaves no process for ordinary work", DispatchOutcome.NOT_DISPATCHED, null); + } long deadline = deadlineAfter(request.timeout()); - admit(deadline); + if (waitingPermit != null) { + admitWaiting(waitingPermit); + } + try { + admit(admission, deadline, "admission timed out"); + } catch (RuntimeException | Error failure) { + release(waitingPermit); + throw failure; + } RunningProcess process; try { process = launch(request, deadline); - } catch (RuntimeException e) { + } catch (RuntimeException | Error failure) { admission.release(); - throw e; + release(waitingPermit); + throw failure; } Drains drains = null; try { @@ -131,6 +162,7 @@ public CommandResult execute(CommandRequest request) { // cancelled FutureTask reports itself done while its worker is still inside the read. if (drains == null || drains.reclaimed()) { admission.release(); + release(waitingPermit); } } } @@ -230,22 +262,35 @@ private static void requireDispatchable(List argv) { } } - private void admit(long deadline) { + private void admit(Semaphore permits, long deadline, String timeoutMessage) { long remaining = remainingNanos(deadline); if (remaining == 0) { - throw admissionTimeout(); + throw admissionTimeout(timeoutMessage); } try { - if (!admission.tryAcquire(remaining, TimeUnit.NANOSECONDS)) { - throw admissionTimeout(); + if (!permits.tryAcquire(remaining, TimeUnit.NANOSECONDS)) { + throw admissionTimeout(timeoutMessage); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new TmuxTransportException("interrupted before dispatch", DispatchOutcome.NOT_DISPATCHED, e); } if (remainingNanos(deadline) == 0) { - admission.release(); - throw admissionTimeout(); + permits.release(); + throw admissionTimeout(timeoutMessage); + } + } + + private static void admitWaiting(Semaphore permits) { + if (!permits.tryAcquire()) { + throw new TmuxTransportException( + "waiting capacity is full; retry after another wait ends", DispatchOutcome.NOT_DISPATCHED, null); + } + } + + private static void release(@Nullable Semaphore permit) { + if (permit != null) { + permit.release(); } } @@ -254,10 +299,11 @@ private RunningProcess launch(CommandRequest request, long deadline) { gate.lock(); try { if (closed) { - throw new IllegalStateException("transport is closed"); + throw new TmuxTransportException( + "transport closed before dispatch", DispatchOutcome.NOT_DISPATCHED, null); } if (remainingNanos(deadline) == 0) { - throw admissionTimeout(); + throw admissionTimeout("admission timed out"); } launching++; } finally { @@ -360,8 +406,8 @@ private long remainingNanos(long deadline) { return Math.max(0, deadline - nanoTime.getAsLong()); } - private static TmuxTimeoutException admissionTimeout() { - return new TmuxTimeoutException("admission timed out", DispatchOutcome.NOT_DISPATCHED, null); + private static TmuxTimeoutException admissionTimeout(String message) { + return new TmuxTimeoutException(message, DispatchOutcome.NOT_DISPATCHED, null); } // --------------------------------------------------------------------------- destruction diff --git a/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransport.java index 10e00c5..cae921b 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransport.java @@ -20,6 +20,17 @@ public interface TmuxTransport extends AutoCloseable { */ CommandResult execute(CommandRequest request); + /** + * Runs a request expected to remain blocked until another request through this transport + * releases it. + * + *

The default shares ordinary admission. A transport with bounded concurrency may override + * this to keep release and observation requests from queuing behind every waiter. + */ + default CommandResult executeWaiting(CommandRequest request) { + return execute(request); + } + /** * Names the execution realm this transport reaches tmux through. * diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 1531239..710c641 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -13,6 +13,7 @@ import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; import io.github.libtmux.transport.DispatchOutcome; +import io.github.libtmux.transport.TmuxTimeoutException; import io.github.libtmux.transport.TmuxTransport; import io.github.libtmux.transport.TmuxTransportException; import java.io.IOException; @@ -183,6 +184,42 @@ public void close() {} } } + @Test + void aWaitWithSignalCapacityPreservesAPredispatchTimeout(@TempDir Path directory) throws IOException { + TmuxTimeoutException failure = + new TmuxTimeoutException("waiting admission timed out", DispatchOutcome.NOT_DISPATCHED, null); + java.util.concurrent.atomic.AtomicBoolean waiting = new java.util.concurrent.atomic.AtomicBoolean(); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + if (request.argv().contains("wait-for")) { + throw failure; + } + return new CommandResult(1, List.of(), List.of("no server running")); + } + + @Override + public CommandResult executeWaiting(CommandRequest request) { + waiting.set(true); + throw failure; + } + + @Override + public void close() {} + }; + + try (Server server = Server.using(config(directory), transport)) { + assertEquals(WakeReason.SERVER_GONE, server.waitFor("self-signalled", java.time.Duration.ofSeconds(1))); + assertFalse(waiting.get(), "an ordinary wait consumed reserved signal capacity"); + assertSame( + failure, + assertThrows( + TmuxTimeoutException.class, + () -> server.waitForWithSignalCapacity("channel", java.time.Duration.ofSeconds(1)))); + assertTrue(waiting.get(), "wait-for used ordinary transport admission"); + } + } + @Test void malformedOrInconsistentListingsRespectStrictAndLenientBoundaries(@TempDir Path directory) throws IOException { String separator = RowFormat.of("field").separator(); diff --git a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java index a39b047..f67e5ae 100644 --- a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java +++ b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java @@ -542,6 +542,123 @@ void admissionTimeoutIsTypedAndKnownNotDispatched(@TempDir Path directory) throw } } + @Test + void waitingRequestsReserveOneProcessForOrdinaryWork() throws Exception { + int bound = 4; + List blocked = java.util.stream.IntStream.range(0, bound - 1) + .mapToObj(ignored -> new GatedInputStream()) + .toList(); + AtomicInteger starts = new AtomicInteger(); + ProcessTransport.ProcessStarter starter = command -> { + int index = starts.getAndIncrement(); + return index < blocked.size() ? new StubProcess(blocked.get(index)) : new ProcessBuilder(command).start(); + }; + ProcessTransport transport = new ProcessTransport(bound, 1_024, starter, System::nanoTime); + ExecutorService callers = Executors.newFixedThreadPool(bound - 1); + List> waiting = new ArrayList<>(); + try { + for (int index = 0; index < bound - 1; index++) { + waiting.add(callers.submit(() -> transport.executeWaiting(shell("ignored", GENEROUS)))); + } + for (GatedInputStream output : blocked) { + assertTrue(output.readStarted.await(5, TimeUnit.SECONDS), "a waiting request never started"); + } + + TmuxTransportException refused = assertThrows( + TmuxTransportException.class, + () -> transport.executeWaiting(shell("echo should-not-start", Duration.ofMillis(50)))); + + assertEquals(TmuxTransportException.class, refused.getClass(), "a full wait lane queued to its timeout"); + assertEquals("waiting capacity is full; retry after another wait ends", refused.getMessage()); + assertEquals(DispatchOutcome.NOT_DISPATCHED, refused.outcome()); + assertEquals(bound - 1, starts.get(), "a fourth waiting process crossed the reserved boundary"); + assertEquals( + List.of("ordinary"), + transport + .execute(shell("printf ordinary", Duration.ofSeconds(2))) + .stdout(), + "ordinary work could not use the reserved process"); + } finally { + blocked.forEach(GatedInputStream::release); + transport.close(); + callers.shutdownNow(); + assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS), "waiting callers did not stop"); + assertTrue(waiting.stream().allMatch(Future::isDone), "a waiting call remained incomplete"); + } + } + + @Test + void aSingleProcessTransportRefusesAWaitBeforeDispatch() { + AtomicInteger starts = new AtomicInteger(); + ProcessTransport.ProcessStarter starter = command -> { + starts.incrementAndGet(); + return new ProcessBuilder(command).start(); + }; + try (ProcessTransport transport = new ProcessTransport(1, 1_024, starter, System::nanoTime)) { + TmuxTransportException refused = assertThrows( + TmuxTransportException.class, + () -> transport.executeWaiting(shell("echo should-not-start", Duration.ofSeconds(1)))); + + assertEquals(DispatchOutcome.NOT_DISPATCHED, refused.outcome()); + assertEquals(0, starts.get(), "the impossible waiting request was dispatched"); + } + } + + @Test + void aWaitingCallAdmittedBeforeCloseKeepsItsDispatchCertainty() throws Exception { + int bound = 2; + List blocked = List.of(new GatedInputStream(), new GatedInputStream()); + AtomicInteger starts = new AtomicInteger(); + ProcessTransport.ProcessStarter starter = command -> { + int index = starts.getAndIncrement(); + return new StubProcess(blocked.get(index)); + }; + ProcessTransport transport = new ProcessTransport(bound, 1_024, starter, System::nanoTime); + ExecutorService callers = Executors.newFixedThreadPool(4); + List> occupying = new ArrayList<>(); + Future waiting = null; + Thread waitingThread = null; + Future closing = null; + try { + for (int index = 0; index < bound; index++) { + occupying.add(callers.submit(() -> transport.execute(shell("ignored", GENEROUS)))); + } + for (GatedInputStream output : blocked) { + assertTrue(output.readStarted.await(5, TimeUnit.SECONDS), "an occupying request never started"); + } + FutureTask admitted = + new FutureTask<>(() -> transport.executeWaiting(shell("never-started", GENEROUS))); + Thread admittedCaller = Thread.ofVirtual().start(admitted); + waiting = admitted; + waitingThread = admittedCaller; + assertTrue( + awaitTimedWait(admitted, admittedCaller), + "the admitted waiting call never blocked on ordinary admission"); + + closing = callers.submit(transport::close); + assertTrue(awaitClosed(transport), "close never barred new requests"); + blocked.forEach(GatedInputStream::release); + + ExecutionException ended = assertThrows(ExecutionException.class, () -> admitted.get(5, TimeUnit.SECONDS)); + TmuxTransportException failure = assertInstanceOf(TmuxTransportException.class, ended.getCause()); + assertEquals(DispatchOutcome.NOT_DISPATCHED, failure.outcome()); + assertEquals(bound, starts.get(), "the blocked waiting call reached the process starter"); + closing.get(5, TimeUnit.SECONDS); + } finally { + blocked.forEach(GatedInputStream::release); + transport.close(); + callers.shutdownNow(); + assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS), "transport callers did not stop"); + if (waitingThread != null) { + waitingThread.join(TimeUnit.SECONDS.toMillis(10)); + assertFalse(waitingThread.isAlive(), "the admitted waiting caller did not stop"); + } + assertTrue(occupying.stream().allMatch(Future::isDone), "an occupying call remained incomplete"); + assertTrue(waiting == null || waiting.isDone(), "the admitted waiting call remained incomplete"); + assertTrue(closing == null || closing.isDone(), "transport close remained incomplete"); + } + } + @Test void interruptedReclamationReturnsItsAdmissionPermit() throws Exception { GatedInputStream stdout = new GatedInputStream(); @@ -690,6 +807,17 @@ private static boolean awaitReclamation(Future request, Thread caller) throws return false; } + private static boolean awaitTimedWait(Future request, Thread caller) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!request.isDone() && System.nanoTime() < deadline) { + if (caller.getState() == Thread.State.TIMED_WAITING) { + return true; + } + Thread.sleep(1); + } + return false; + } + private static final class GatedInputStream extends InputStream { private final CountDownLatch readStarted = new CountDownLatch(1); private final CountDownLatch released = new CountDownLatch(1); From 0e43d78ca1f0df2216aa60276ede20dd9cf62985 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:23:06 -0500 Subject: [PATCH 25/77] Core(fix[snapshots]): Preserve live empty servers Stop hierarchy capture after an empty session listing because tmux refuses child listings without a current target. Keep the observed process identity and let MCP watch startup report its session requirement. --- .../github/libtmux/mcp/McpLauncherTest.java | 2 +- .../main/java/io/github/libtmux/Server.java | 7 +++- .../java/io/github/libtmux/ServerTest.java | 35 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java index 8ac68b9..61fb08e 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java @@ -59,7 +59,7 @@ void failedWatchStartupDoesNotLeaveTheLauncherAlive(Server server, TmuxSocketPat assertTrue(launcher.exitValue() != 0, "failed watch startup reported success"); String diagnostic = new String(launcher.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); assertTrue( - diagnostic.contains("tmux list-windows failed: no current target"), + diagnostic.contains("watching requires a tmux session to attach to"), "the launcher failed for the wrong reason: " + diagnostic); } finally { stop(launcher); diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index db7c7db..5ea54ae 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -574,7 +574,8 @@ private CommandRequest request(List argv, Duration timeout) { } /** - * Captures the whole hierarchy in four listings, retrying once if the server is replaced. + * Captures the whole hierarchy in at most four listings, retrying once if the server is + * replaced. * *

One server-wide listing per kind of object, so ordering and membership stay tmux's decision * rather than being re-derived from another listing's rows. @@ -633,6 +634,10 @@ private ServerSnapshot captureSnapshot(ServerProcess process) { positiveCount(row.get(2), "session_attached"), Integer.parseInt(row.get(3)))); } + if (sessions.isEmpty()) { + return ServerSnapshot.of( + Instant.now(), process.pid(), process.version(), sessions, List.of(), List.of(), List.of()); + } List windows = new ArrayList<>(); for (List row : rows(WINDOWS, "list-windows", "-a")) { windows.add(new WindowState( diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 710c641..89db623 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -263,6 +263,41 @@ void snapshotDistinguishesAnAbsentServerFromAnIdentityProbeFailure(@TempDir Path } } + @Test + void snapshotKeepsTheIdentityOfALiveServerWithNoSessions(@TempDir Path directory) throws IOException { + AtomicInteger impossibleListings = new AtomicInteger(); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return switch (request.argv().getFirst()) { + case "display-message" -> + new CommandResult( + 0, List.of(String.join(RowFormat.of("field").separator(), "4242", "3.2a")), List.of()); + case "list-sessions" -> new CommandResult(0, List.of(), List.of()); + default -> { + impossibleListings.incrementAndGet(); + yield new CommandResult(1, List.of(), List.of("no current target")); + } + }; + } + + @Override + public void close() {} + }; + + try (Server server = Server.using(config(directory), transport)) { + var snapshot = server.snapshot(); + + assertEquals(4242L, snapshot.serverPid().orElseThrow()); + assertEquals(TmuxVersion.parse("3.2a"), snapshot.serverVersion().orElseThrow()); + assertTrue(snapshot.sessions().isEmpty()); + assertTrue(snapshot.windows().isEmpty()); + assertTrue(snapshot.panes().isEmpty()); + assertTrue(snapshot.clients().isEmpty()); + assertEquals(0, impossibleListings.get(), "tmux cannot list children without a current target"); + } + } + @Test void snapshotRetriesAChangedIncarnationAndKeepsOnlyTheSecondCapture(@TempDir Path directory) throws IOException { String separator = RowFormat.of("field").separator(); From d4f196c8f833ff46c1fca52945e32473b47ec4e0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:31:17 -0500 Subject: [PATCH 26/77] MCP(fix[lifecycle]): Complete every owned cleanup Run later watcher, attachment, transport, and SDK cleanup even when an earlier owner fails. Preserve the first failure and suppress later ones through one package-private cleanup boundary. --- .../java/io/github/libtmux/mcp/Cleanup.java | 57 +++++++++++++++++++ .../github/libtmux/mcp/SessionLifetime.java | 26 ++------- .../io/github/libtmux/mcp/TmuxMcpServer.java | 34 +++++------ .../github/libtmux/mcp/WatchAttachment.java | 39 +++++++------ .../java/io/github/libtmux/mcp/Watches.java | 16 ++++-- .../io/github/libtmux/mcp/CleanupTest.java | 36 ++++++++++++ 6 files changed, 142 insertions(+), 66 deletions(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cleanup.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/CleanupTest.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cleanup.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cleanup.java new file mode 100644 index 0000000..71908e6 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cleanup.java @@ -0,0 +1,57 @@ +package io.github.libtmux.mcp; + +import org.jspecify.annotations.Nullable; + +/** Runs every cleanup while retaining the first failure as the primary one. */ +final class Cleanup { + + private @Nullable Throwable failure; + + Cleanup() {} + + Cleanup(@Nullable Throwable failure) { + this.failure = failure; + } + + void run(Runnable action) { + try { + action.run(); + } catch (RuntimeException | Error next) { + add(next); + } + } + + void close(AutoCloseable resource) { + try { + resource.close(); + } catch (Exception | Error next) { + add(next); + } + } + + @Nullable + Throwable failure() { + return failure; + } + + void throwIfFailed() { + if (failure instanceof RuntimeException runtime) { + throw runtime; + } + if (failure instanceof Error error) { + throw error; + } + if (failure != null) { + throw new IllegalStateException("cleanup failed", failure); + } + } + + @SuppressWarnings("ReferenceEquality") + private void add(Throwable next) { + if (failure == null) { + failure = next; + } else if (failure != next) { + failure.addSuppressed(next); + } + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java index e1cd874..099caa0 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SessionLifetime.java @@ -68,20 +68,13 @@ void endAfter(Throwable failure) { } private @Nullable Throwable finish(@Nullable Throwable failure) { - try { - ended.run(); - } catch (RuntimeException | Error callbackFailure) { - failure = suppress(failure, callbackFailure); - } + Cleanup cleanup = new Cleanup(failure); + cleanup.run(ended); AutoCloseable resource; while ((resource = takeOwned()) != null) { - try { - resource.close(); - } catch (Exception | Error closeFailure) { - failure = suppress(failure, closeFailure); - } + cleanup.close(resource); } - return failure; + return cleanup.failure(); } private @Nullable AutoCloseable takeOwned() { @@ -108,17 +101,6 @@ private static void close(AutoCloseable resource) { } } - @SuppressWarnings("ReferenceEquality") - static Throwable suppress(@Nullable Throwable primary, Throwable secondary) { - if (primary == null) { - return secondary; - } - if (secondary != primary) { - primary.addSuppressed(secondary); - } - return primary; - } - private record ObservedProvider(McpServerTransportProvider delegate, SessionLifetime lifetime) implements McpServerTransportProvider { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index b461a3e..5d17f1d 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -125,14 +125,11 @@ private static McpSyncServer serving( watches.start(new McpNotifier(owned)); return owned; } catch (RuntimeException | Error failure) { - try { - if (owned == null) { - watches.close(); - } else { - owned.close(); - } - } catch (RuntimeException | Error cleanupFailure) { - SessionLifetime.suppress(failure, cleanupFailure); + Cleanup cleanup = new Cleanup(failure); + if (owned == null) { + cleanup.run(watches::close); + } else { + cleanup.run(owned::close); } throw failure; } @@ -189,23 +186,20 @@ private static final class WatchedMcpServer extends McpSyncServer { @Override public void closeGracefully() { - if (closed.compareAndSet(false, true)) { - try { - watches.close(); - } finally { - super.closeGracefully(); - } - } + closeBoth(() -> super.closeGracefully()); } @Override public void close() { + closeBoth(() -> super.close()); + } + + private void closeBoth(Runnable closeServer) { if (closed.compareAndSet(false, true)) { - try { - watches.close(); - } finally { - super.close(); - } + Cleanup cleanup = new Cleanup(); + cleanup.run(watches::close); + cleanup.run(closeServer); + cleanup.throwIfFailed(); } } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java index e60d977..bf480f2 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WatchAttachment.java @@ -75,12 +75,14 @@ private static WatchAttachment openWhileHidden(Watches owner, Connection connect throw new IllegalStateException("tmux refused the pane-state watch"); } return new WatchAttachment(owner, connection, session, client, output, events, name); - } catch (RuntimeException e) { + } catch (RuntimeException | Error failure) { + Cleanup cleanup = new Cleanup(failure); + cleanup.run(client::close); if (name != null) { - connection.reveal(name); + String hidden = name; + cleanup.run(() -> connection.reveal(hidden)); } - client.close(); - throw e; + throw failure; } } @@ -152,23 +154,24 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } - output.close(); - events.close(); - outputConsumer.interrupt(); - eventConsumer.interrupt(); - connection.changeClients(() -> { - try { - client.close(); - } finally { - if (clientName != null) { - connection.reveal(clientName); - } + Cleanup cleanup = new Cleanup(); + cleanup.run(output::close); + cleanup.run(events::close); + cleanup.run(outputConsumer::interrupt); + cleanup.run(eventConsumer::interrupt); + cleanup.run(() -> connection.changeClients(() -> { + Cleanup clientCleanup = new Cleanup(); + clientCleanup.run(client::close); + if (clientName != null) { + clientCleanup.run(() -> connection.reveal(clientName)); } - }); + clientCleanup.throwIfFailed(); + })); if (started.get()) { - join(outputConsumer); - join(eventConsumer); + cleanup.run(() -> join(outputConsumer)); + cleanup.run(() -> join(eventConsumer)); } + cleanup.throwIfFailed(); } private static void join(Thread thread) { diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java index 9fe2604..2b79220 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watches.java @@ -78,7 +78,8 @@ static Watches prepare(Connection connection) { watches.projection = ResourceInvalidations.project(current, connection::isOurs); return watches; } catch (RuntimeException | Error e) { - watches.close(); + Cleanup cleanup = new Cleanup(e); + cleanup.run(watches::close); throw new IllegalStateException("could not start the requested tmux watcher", e); } } @@ -103,7 +104,8 @@ static Watches start(Connection connection, Notifier notifier) { watches.start(notifier); return watches; } catch (RuntimeException | Error e) { - watches.close(); + Cleanup cleanup = new Cleanup(e); + cleanup.run(watches::close); throw e; } } @@ -298,13 +300,15 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } - signals.offer(Signal.STOP); - supervisor.interrupt(); - attachments.values().forEach(WatchAttachment::close); + Cleanup cleanup = new Cleanup(); + cleanup.run(() -> signals.offer(Signal.STOP)); + cleanup.run(supervisor::interrupt); + attachments.values().forEach(cleanup::close); attachments.clear(); if (supervisor.getState() != Thread.State.NEW && !Thread.currentThread().equals(supervisor)) { - join(supervisor); + cleanup.run(() -> join(supervisor)); } + cleanup.throwIfFailed(); } private static void join(Thread thread) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CleanupTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CleanupTest.java new file mode 100644 index 0000000..1908829 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/CleanupTest.java @@ -0,0 +1,36 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +final class CleanupTest { + + @Test + void everyCleanupRunsAndLaterFailuresAreSuppressed() { + List attempted = new ArrayList<>(); + IllegalStateException first = new IllegalStateException("first failed"); + IllegalArgumentException second = new IllegalArgumentException("second failed"); + Cleanup cleanup = new Cleanup(); + + cleanup.close(() -> { + attempted.add("first"); + throw first; + }); + cleanup.run(() -> attempted.add("middle")); + cleanup.close(() -> { + attempted.add("second"); + throw second; + }); + + RuntimeException thrown = assertThrows(RuntimeException.class, cleanup::throwIfFailed); + assertSame(first, thrown); + assertEquals(List.of("first", "middle", "second"), attempted); + assertEquals(1, thrown.getSuppressed().length); + assertSame(second, thrown.getSuppressed()[0]); + } +} From 98f3df68c458f05ad078e1eacccba8c68b48d0ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:36:07 -0500 Subject: [PATCH 27/77] MCP(fix[startup]): Close accepted transports on failure Transfer caller-supplied transport ownership on entry and roll it back when SDK construction fails. Close the built server after acceptance, preserve the startup failure, and detach any prepared watcher. --- .../io/github/libtmux/mcp/TmuxMcpServer.java | 65 +++++++++++++------ .../github/libtmux/mcp/TmuxMcpServerTest.java | 42 ++++++++++++ 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index 5d17f1d..eaff008 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -14,6 +14,7 @@ import java.io.OutputStream; import java.time.Duration; import java.util.Map; +import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; import org.jspecify.annotations.Nullable; @@ -95,12 +96,22 @@ static McpSyncServer overStdio( } } - /** Serves a tmux server over a caller-supplied transport. */ + /** + * Serves a tmux server over a caller-supplied transport. + * + *

The returned server owns the transport. Ownership transfers on entry, so failed startup + * closes it too. + */ public static McpSyncServer serving(Server server, Safety ceiling, McpServerTransportProvider transport) { return serving(server, ceiling, false, transport); } - /** Serves a tmux server over a caller-supplied transport, optionally watching it for changes. */ + /** + * Serves a tmux server over a caller-supplied transport, optionally watching it for changes. + * + *

The returned server owns the transport. Ownership transfers on entry, so failed startup + * closes it too. + */ public static McpSyncServer serving( Server server, Safety ceiling, boolean watching, McpServerTransportProvider transport) { return serving(server, ceiling, watching, transport, null); @@ -112,29 +123,43 @@ private static McpSyncServer serving( boolean watching, McpServerTransportProvider transport, @Nullable SessionLifetime lifetime) { - Connection connection = Connection.to(server, ceiling); - if (watching) { - Watches watches = Watches.prepare(connection); - if (lifetime != null) { - lifetime.own(watches); + Objects.requireNonNull(transport, "transport"); + @Nullable Watches watches = null; + @Nullable McpSyncServer built = null; + try { + Connection connection = Connection.to(server, ceiling); + if (watching) { + watches = Watches.prepare(connection); + if (lifetime != null) { + lifetime.own(watches); + } } - @Nullable WatchedMcpServer owned = null; - try { - McpSyncServer built = build(connection, true, transport); - owned = new WatchedMcpServer(built, watches); - watches.start(new McpNotifier(owned)); - return owned; - } catch (RuntimeException | Error failure) { - Cleanup cleanup = new Cleanup(failure); - if (owned == null) { - cleanup.run(watches::close); + built = build(connection, watching, transport); + if (watches == null) { + return built; + } + WatchedMcpServer owned = new WatchedMcpServer(built, watches); + watches.start(new McpNotifier(owned)); + return owned; + } catch (RuntimeException | Error failure) { + Cleanup cleanup = new Cleanup(failure); + if (lifetime == null) { + if (watches != null) { + Watches prepared = watches; + cleanup.run(prepared::close); + } + if (built == null) { + cleanup.run(transport::close); } else { - cleanup.run(owned::close); + McpSyncServer accepted = built; + cleanup.run(accepted::close); } - throw failure; + } else if (built != null) { + McpSyncServer accepted = built; + cleanup.run(accepted::close); } + throw failure; } - return build(connection, false, transport); } private static McpSyncServer build(Connection connection, boolean watching, McpServerTransportProvider transport) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java index 0d73556..3f8c6d3 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TmuxMcpServerTest.java @@ -1,6 +1,7 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -15,6 +16,8 @@ import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.ProtocolVersions; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -29,6 +32,7 @@ import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; /** * What the protocol layer tells a model, checked against what the library will actually accept. @@ -140,6 +144,44 @@ void failedStdioStartupClosesItsOwnedInput(Server server) throws Exception { } } + @Test + void failedStartupClosesAnyAcceptedTransport(Server server) throws Exception { + for (boolean watching : new boolean[] {false, true}) { + AtomicInteger closes = new AtomicInteger(); + IllegalStateException startupFailure = new IllegalStateException("session factory failed"); + McpServerTransportProvider transport = new McpServerTransportProvider() { + @Override + public void setSessionFactory(McpServerSession.Factory factory) { + throw startupFailure; + } + + @Override + public Mono notifyClients(String method, Object params) { + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + }; + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> TmuxMcpServer.serving(server, Safety.MUTATING, watching, transport), + "watching=" + watching); + + assertSame(startupFailure, thrown); + assertEquals(1, closes.get(), "accepted transport was not closed exactly once"); + assertTrue(await(() -> server.clients().isEmpty()), "failed startup left a watcher attached"); + } + } + @Test void brokenOutputDetachesTheOwnedWatcherWhileInputRemainsOpen(Server server) throws Exception { PipedInputStream input = new PipedInputStream(); From 8d2a9c38d58babed80c7b245f2a3f068ec4b7d08 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:46:37 -0500 Subject: [PATCH 28/77] MCP(fix[stdio]): Serialize outbound sends why: SDK 2.0.1 rejects concurrent emissions to its unicast stdio sink, so a response can be lost when progress or watcher output races it. what: - Queue each session's sends without blocking a worker thread - Prove ordering and failure release with deterministic tests --- .../mcp/SerializedTransportProvider.java | 136 ++++++++++++++++++ .../io/github/libtmux/mcp/TmuxMcpServer.java | 2 +- .../mcp/SerializedTransportProviderTest.java | 104 ++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java new file mode 100644 index 0000000..9b759c3 --- /dev/null +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java @@ -0,0 +1,136 @@ +package io.github.libtmux.mcp; + +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Objects; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoSink; + +/** Serializes per-session sends because the pinned SDK's stdio sink rejects concurrent emissions. */ +final class SerializedTransportProvider implements McpServerTransportProvider { + + private final McpServerTransportProvider delegate; + + SerializedTransportProvider(McpServerTransportProvider delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + public void setSessionFactory(McpServerSession.Factory factory) { + delegate.setSessionFactory(transport -> factory.create(serialize(transport))); + } + + static McpServerTransport serialize(McpServerTransport transport) { + return new SerializedTransport(transport); + } + + @Override + public Mono notifyClients(String method, Object params) { + return delegate.notifyClients(method, params); + } + + @Override + public Mono notifyClient(String sessionId, String method, Object params) { + return delegate.notifyClient(sessionId, method, params); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public List protocolVersions() { + return delegate.protocolVersions(); + } + + private static final class SerializedTransport implements McpServerTransport { + + private final McpServerTransport delegate; + private final Object sends = new Object(); + private final ArrayDeque pending = new ArrayDeque<>(); + private boolean sending; + + SerializedTransport(McpServerTransport delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.create(sink -> enqueue(new PendingSend(message, sink))); + } + + private void enqueue(PendingSend added) { + synchronized (sends) { + pending.addLast(added); + if (sending) { + return; + } + sending = true; + } + sendNext(); + } + + private void sendNext() { + PendingSend next; + synchronized (sends) { + next = pending.removeFirst(); + } + try { + delegate.sendMessage(next.message()) + .subscribe(ignored -> {}, failure -> finish(next, failure), () -> finish(next, null)); + } catch (RuntimeException | Error failure) { + finish(next, failure); + } + } + + private void finish(PendingSend completed, @Nullable Throwable failure) { + boolean hasNext; + synchronized (sends) { + hasNext = !pending.isEmpty(); + sending = hasNext; + } + if (failure == null) { + completed.sink().success(); + } else { + completed.sink().error(failure); + } + if (hasNext) { + sendNext(); + } + } + + @Override + public T unmarshalFrom(Object value, TypeRef type) { + return delegate.unmarshalFrom(value, type); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public List protocolVersions() { + return delegate.protocolVersions(); + } + + private record PendingSend(McpSchema.JSONRPCMessage message, MonoSink sink) {} + } +} diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index eaff008..f419df4 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -163,7 +163,7 @@ private static McpSyncServer serving( } private static McpSyncServer build(Connection connection, boolean watching, McpServerTransportProvider transport) { - var specification = McpServer.sync(transport) + var specification = McpServer.sync(new SerializedTransportProvider(transport)) .serverInfo("libtmux", version()) .instructions(Instructions.forServer(connection.ceiling(), watching)) .requestTimeout(REQUEST_TIMEOUT) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java new file mode 100644 index 0000000..52321be --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java @@ -0,0 +1,104 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransport; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +final class SerializedTransportProviderTest { + + @Test + void sendsOneMessageAtATime() { + PausingTransport delegate = new PausingTransport(); + McpServerTransport transport = SerializedTransportProvider.serialize(delegate); + AtomicInteger completed = new AtomicInteger(); + + Disposable first = transport + .sendMessage(notification("first")) + .subscribe(ignored -> {}, failure -> {}, completed::incrementAndGet); + Disposable second = transport + .sendMessage(notification("second")) + .subscribe(ignored -> {}, failure -> {}, completed::incrementAndGet); + + assertEquals(1, delegate.started(), "the second send overlapped the first"); + delegate.succeed(0); + assertEquals(2, delegate.started(), "the queued send did not start after the first"); + delegate.succeed(1); + assertEquals(2, completed.get()); + + first.dispose(); + second.dispose(); + } + + @Test + void failedSendReleasesTheNextMessage() { + PausingTransport delegate = new PausingTransport(); + McpServerTransport transport = SerializedTransportProvider.serialize(delegate); + AtomicInteger failed = new AtomicInteger(); + AtomicInteger completed = new AtomicInteger(); + + Disposable first = transport + .sendMessage(notification("first")) + .subscribe(ignored -> {}, failure -> failed.incrementAndGet()); + Disposable second = transport + .sendMessage(notification("second")) + .subscribe(ignored -> {}, failure -> {}, completed::incrementAndGet); + + delegate.fail(0); + assertEquals(1, failed.get()); + assertEquals(2, delegate.started(), "a failed send left the queue stalled"); + delegate.succeed(1); + assertEquals(1, completed.get()); + + first.dispose(); + second.dispose(); + } + + private static McpSchema.JSONRPCNotification notification(String value) { + return new McpSchema.JSONRPCNotification("test/notification", value); + } + + private static final class PausingTransport implements McpServerTransport { + + private final List> completions = new ArrayList<>(); + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.defer(() -> { + Sinks.One completion = Sinks.one(); + completions.add(completion); + return completion.asMono(); + }); + } + + int started() { + return completions.size(); + } + + void succeed(int index) { + completions.get(index).tryEmitEmpty(); + } + + void fail(int index) { + completions.get(index).tryEmitError(new IllegalStateException("send failed")); + } + + @Override + public T unmarshalFrom(Object value, TypeRef type) { + throw new UnsupportedOperationException(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + } +} From ef2e7222f5e10feab7412cc8bed5f1a106d14683 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:51:54 -0500 Subject: [PATCH 29/77] MCP(fix[timeouts]): Restore SDK request bound why: The five-minute override governs server-to-client requests, not tool calls. SDK 2.0.1 can issue roots/list implicitly, turning a ten-second upstream wait into five minutes. what: - Remove the misdirected five-minute request timeout - Record the effective ten-second bound without wall-clock delay --- .../io/github/libtmux/mcp/TmuxMcpServer.java | 10 -- .../libtmux/mcp/ServerRequestTimeoutTest.java | 126 ++++++++++++++++++ 2 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerRequestTimeoutTest.java diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java index f419df4..abbce99 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/TmuxMcpServer.java @@ -12,7 +12,6 @@ import io.modelcontextprotocol.spec.McpServerTransportProvider; import java.io.InputStream; import java.io.OutputStream; -import java.time.Duration; import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; @@ -34,14 +33,6 @@ */ public final class TmuxMcpServer { - /** - * How long the SDK waits for a client to answer something this server asked it. - * - *

Not a bound on a tool call: those bound themselves. Generous because the wait tools may - * legitimately hold a request open to the wait ceiling. - */ - private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(5); - private TmuxMcpServer() {} /** @@ -166,7 +157,6 @@ private static McpSyncServer build(Connection connection, boolean watching, McpS var specification = McpServer.sync(new SerializedTransportProvider(transport)) .serverInfo("libtmux", version()) .instructions(Instructions.forServer(connection.ceiling(), watching)) - .requestTimeout(REQUEST_TIMEOUT) .capabilities(McpSchema.ServerCapabilities.builder() .tools(true) // Subscription is offered only when something is actually watching tmux. diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerRequestTimeoutTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerRequestTimeoutTest.java new file mode 100644 index 0000000..fe4aeb8 --- /dev/null +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ServerRequestTimeoutTest.java @@ -0,0 +1,126 @@ +package io.github.libtmux.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import io.github.libtmux.Server; +import io.github.libtmux.junit5.TmuxExtension; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import io.modelcontextprotocol.spec.McpServerTransport; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +@ExtendWith(TmuxExtension.class) +final class ServerRequestTimeoutTest { + + @Test + void serverRequestsRetainTheSdkDefaultBound(Server server) { + CapturingProvider provider = new CapturingProvider(); + McpSyncServer mcp = TmuxMcpServer.serving(server, Safety.MUTATING, provider); + RecordingScheduler scheduler = new RecordingScheduler(); + Schedulers.Snapshot snapshot = Schedulers.setFactoryWithSnapshot(scheduler); + try { + McpServerSession session = provider.factory().create(new NoReplyTransport()); + AtomicReference failure = new AtomicReference<>(); + + Disposable request = session.sendRequest("test/request", Map.of(), new TypeRef() {}) + .subscribe(ignored -> {}, failure::set); + + assertEquals(Duration.ofSeconds(10), scheduler.requested()); + assertInstanceOf(TimeoutException.class, failure.get()); + request.dispose(); + } finally { + Schedulers.resetFrom(snapshot); + mcp.close(); + } + } + + private static final class CapturingProvider implements McpServerTransportProvider { + + private final AtomicReference factory = new AtomicReference<>(); + + @Override + public void setSessionFactory(McpServerSession.Factory value) { + factory.set(value); + } + + McpServerSession.Factory factory() { + return Objects.requireNonNull(factory.get()); + } + + @Override + public Mono notifyClients(String method, Object params) { + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + } + + private static final class NoReplyTransport implements McpServerTransport { + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object value, TypeRef type) { + throw new UnsupportedOperationException(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + } + + private static final class RecordingScheduler implements Scheduler, Schedulers.Factory { + + private final AtomicReference requested = new AtomicReference<>(); + + @Override + public Disposable schedule(Runnable task) { + return Schedulers.immediate().schedule(task); + } + + @Override + public Disposable schedule(Runnable task, long delay, TimeUnit unit) { + requested.set(Duration.ofNanos(unit.toNanos(delay))); + task.run(); + return Disposables.disposed(); + } + + @Override + public Scheduler.Worker createWorker() { + return Schedulers.immediate().createWorker(); + } + + @Override + public Scheduler newParallel(int parallelism, ThreadFactory threadFactory) { + return this; + } + + Duration requested() { + return Objects.requireNonNull(requested.get()); + } + } +} From f9d88bab1c792dd2634dd6a65b408937f5c4c86c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:55:01 -0500 Subject: [PATCH 30/77] MCP(docs[contracts]): State failure boundaries why: SDK 2.0.1 does not cancel running synchronous handlers, and workspace rollback cannot undo commands already started. Existing prose promised both. what: - Distinguish client abandonment from handler termination - Describe workspace validation and cleanup as best effort - Trim duplicated wait rationale --- docs/guide/mcp.md | 13 ++++++++----- libtmux-mcp/README.md | 11 ++++++++--- .../src/main/java/io/github/libtmux/mcp/Call.java | 2 +- .../main/java/io/github/libtmux/mcp/Prompts.java | 5 +++-- .../src/main/java/io/github/libtmux/mcp/Waits.java | 13 +------------ .../github/libtmux/workspace/WorkspaceApplier.java | 2 +- 6 files changed, 22 insertions(+), 24 deletions(-) diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 6578d5d..e061de5 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -13,8 +13,9 @@ Not tmux commands. Context, and turns. A model driving a terminal has two costs nobody bills it for: every line it reads stays in its context for the rest of the conversation, and every tool call is a round trip it cannot take back once it has started. Nearly every design decision -here follows from those two, and from one more: MCP gives an agent no way to -sleep and no way to cancel a call it is inside. +here follows from those two, and from one more: MCP gives an agent no sleep +primitive, and Java SDK 2.0.1 does not propagate cancellation into a synchronous +handler after it starts. So a wait that is not a tool does not disappear. It moves into the agent's turn loop as a polling cycle, where it costs a call per look and has no ceiling at all. @@ -64,9 +65,11 @@ Writing the same handlers reactively is where it goes wrong: a `Mono` that block pins the single reactor thread, serves nothing at all, and stretched the blocking call itself from 6.2 to 9.4 seconds. **This server is synchronous on purpose.** -What an unbounded wait really costs is the turn: the agent picks the wrong thing -to wait for once, and has no way to change its mind mid-call. The ceiling makes -that mistake cheap and repeatable instead of terminal. +A client's request deadline is separate. The Java SDK 2.0.1 client defaults to +20 seconds, so configure it above a longer wait before requesting one. Cancelling +or timing out abandons the answer but does not stop the synchronous handler or +undo tmux changes it already dispatched. The server ceiling keeps that abandoned +work bounded. ## Telling output apart from the plumbing diff --git a/libtmux-mcp/README.md b/libtmux-mcp/README.md index fb06611..955c4ab 100644 --- a/libtmux-mcp/README.md +++ b/libtmux-mcp/README.md @@ -179,6 +179,11 @@ Every wait is capped (30 s by default, 2 minutes hard) and reports the ceiling i actually enforced. The cap protects the agent's turn, not the connection: a tool call that blocks does not stop this server answering anything else. +A client's request deadline is separate. The Java SDK 2.0.1 client defaults to +20 seconds, so configure it above any longer wait you request. With that SDK, +cancelling or timing out abandons the answer but does not stop the synchronous +handler or undo tmux changes it already dispatched. + ## Watching, instead of polling With `--watch`, this server attaches a tmux control client and asks tmux to @@ -294,9 +299,9 @@ windows: - docker compose logs -f ``` -One call instead of a dozen. A call cannot half-succeed, and a layout tmux would -refuse is refused while the description is still text — before any session exists -to leave half-built. +One call instead of a dozen. The document and layouts are validated before any +session exists. If a later creation step or command fails, cleanup is best effort; +commands already started cannot be undone. ## Embedding it diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java index 99925ef..b532ea5 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Call.java @@ -27,7 +27,7 @@ Safety ceiling() { return connection.ceiling(); } - /** Reports how a slow tool is going, so a client can show it and a person can cancel it. */ + /** Reports how a slow tool is going while the client is still listening. */ interface Progress { /** A no-op for callers outside the protocol, which is every test that exercises a tool. */ diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Prompts.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Prompts.java index 5d053bc..5bc582a 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Prompts.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Prompts.java @@ -96,8 +96,9 @@ static List all() { Build a tmux session for: %s Send it as one document to tmux_apply_workspace rather than creating windows \ - and panes one call at a time. One call cannot half-succeed, and a layout tmux \ - would refuse is refused before anything exists. + and panes one call at a time. The document and layouts are validated before \ + creation. If a later step fails, cleanup is best effort; commands already \ + started cannot be undone. %s The commands in it are started, not waited for. To check one came up, watch its \ diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Waits.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Waits.java index 22362cb..d85943f 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Waits.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Waits.java @@ -2,18 +2,7 @@ import java.time.Duration; -/** - * How long a wait may last. - * - *

Every wait is bounded, and an over-large request is clamped rather than refused. The bound - * protects the agent's turn, not the connection: a tool call that blocks forever gives a model no - * way to change its mind, because MCP has no way to cancel a call it is inside. A ceiling makes - * choosing the wrong thing to wait for cheap and repeatable instead of terminal. - * - *

The connection itself is never at risk. Measured against this SDK, one tool call blocking for - * six seconds served twenty interleaved calls in the same window, because the SDK runs a synchronous - * handler on {@code Schedulers.boundedElastic} rather than on the thread reading the transport. - */ +/** Bounds a wait because SDK 2.0.1 does not cancel a running synchronous handler. */ final class Waits { /** What a caller gets when it names no timeout: long enough for a test run, short enough to retry. */ diff --git a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceApplier.java b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceApplier.java index 44400e6..d9883a0 100644 --- a/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceApplier.java +++ b/libtmux-workspace/src/main/java/io/github/libtmux/workspace/WorkspaceApplier.java @@ -13,7 +13,7 @@ import java.util.Optional; import java.util.UUID; -/** Applies a validated workspace as one session, cleaning it up after any failure. */ +/** Applies a validated workspace as one session and attempts to remove it after a failure. */ final class WorkspaceApplier { private WorkspaceApplier() {} From 5a6d5e3ec9bf6c652d917e0e1faec869ac400afe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:56:01 -0500 Subject: [PATCH 31/77] Core(fix[lifecycle]): Reject reads after close Snapshot hydration wrapped the lifecycle guard, and lenient list accessors could then turn use after close into an empty graph. Check server state before hydration and cover every snapshot-backed accessor. --- libtmux/src/main/java/io/github/libtmux/Server.java | 11 ++++++++--- .../src/test/java/io/github/libtmux/ServerTest.java | 6 ++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 5ea54ae..150d8a8 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -564,15 +564,19 @@ public CommandResult cmd(List argv, Duration timeout) { } private CommandRequest request(List argv, Duration timeout) { - if (closed.get()) { - throw new IllegalStateException("server is closed"); - } + requireOpen(); List endpoint = config.endpointCommand(); List command = new ArrayList<>(endpoint.size()); command.addAll(endpoint); return new CommandRequest(command, argv, timeout); } + private void requireOpen() { + if (closed.get()) { + throw new IllegalStateException("server is closed"); + } + } + /** * Captures the whole hierarchy in at most four listings, retrying once if the server is * replaced. @@ -586,6 +590,7 @@ private CommandRequest request(List argv, Duration timeout) { * @throws LibTmuxException if a listing fails or the listings cannot form one valid snapshot */ public ServerSnapshot snapshot() { + requireOpen(); try { return hydrateSnapshot() .or(this::hydrateSnapshot) diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 89db623..81590ee 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -157,6 +157,12 @@ void operationsAfterCloseAreRejected(@TempDir Path directory) throws IOException server.close(); assertThrows(IllegalStateException.class, () -> server.cmd("list-sessions")); + assertThrows(IllegalStateException.class, server::snapshot); + assertThrows(IllegalStateException.class, server::sessions); + assertThrows(IllegalStateException.class, server::windows); + assertThrows(IllegalStateException.class, server::panes); + assertThrows(IllegalStateException.class, server::clients); + assertThrows(IllegalStateException.class, server::attachedSessions); } @Test From 9121c416525509c799168d61283ffc8ca1756c52 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:57:05 -0500 Subject: [PATCH 32/77] MCP(fix[publication]): Scope dependencies to the API why: TmuxMcpServer returns McpSyncServer and takes McpServerTransportProvider while mcp-core was an implementation dependency, so an external consumer got a POM that cannot compile a call to either entry point. The module also published an SLF4J provider, which chooses logging for the application that embeds it, and exported libtmux-jackson though no public signature mentions it. what: - Promote mcp-core to api, which is what its public signatures require - Demote libtmux-jackson to implementation; every use is package-private - Move slf4j-nop to a launcher-only configuration the distribution reads and the published metadata does not --- libtmux-mcp/build.gradle.kts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/libtmux-mcp/build.gradle.kts b/libtmux-mcp/build.gradle.kts index dc7910f..75cc7c4 100644 --- a/libtmux-mcp/build.gradle.kts +++ b/libtmux-mcp/build.gradle.kts @@ -12,22 +12,31 @@ application { applicationName = "libtmux-mcp" } +// What the launcher runs with and the library does not publish. The SDK logs through SLF4J, and a +// launcher speaking a protocol on stdout should not greet its client with provider warnings on +// stderr; an application embedding this module picks its own provider and must not inherit one. +val launcherRuntime = configurations.register("launcherRuntime") + +configurations.runtimeClasspath { extendsFrom(launcherRuntime.get()) } + dependencies { api(project(":libtmux")) - implementation(libs.mcp.core) + + // On this module's own signature: serving() takes a transport provider and every entry point + // returns the SDK's server, so compiling against this module means compiling against the SDK. + api(libs.mcp.core) + implementation(libs.mcp.json.jackson2) implementation(libs.jackson.databind) // A model sends a filter as the versioned JSON document, which is what this module reads it - // from. api rather than implementation: the filter type appears on the catalog's own signature. - api(project(":libtmux-jackson")) + // from. Every use of it is inside a package-private type, so it is not part of the API. + implementation(project(":libtmux-jackson")) // A whole session described in one document, which is what tmux_apply_workspace takes. implementation(project(":libtmux-workspace")) - // The SDK logs through SLF4J. A launcher speaking a protocol on stdout should not greet its - // client with warnings about missing logging providers on stderr either. - runtimeOnly(libs.slf4j.nop) + add(launcherRuntime.name, libs.slf4j.nop) testImplementation(project(":libtmux-junit5")) } From 95b7b937add77df530df3e43a42be45eb10fb02e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:57:36 -0500 Subject: [PATCH 33/77] Build(fix[docs]): Compile snippets as a consumer does why: The snippets compiled against the runtime classpath, which carries every implementation dependency, so a documented call whose types a published POM does not expose still compiled here. That is how libtmux-mcp shipped an unusable entry point. what: - Compile snippets against the compile classpath, the view a consumer gets - Leave execution on the test JVM's classpath, the view a consumer runs with Reverting the mcp-core scope fix now fails libtmux-mcp/README.md:311 with "cannot access io.modelcontextprotocol.server.McpSyncServer". --- docs-tests/build.gradle.kts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs-tests/build.gradle.kts b/docs-tests/build.gradle.kts index 15d2da3..36dd507 100644 --- a/docs-tests/build.gradle.kts +++ b/docs-tests/build.gradle.kts @@ -18,14 +18,17 @@ dependencies { testImplementation(project(":libtmux-workspace")) } -// The snippets are compiled against this module's own test classpath, which the compiler has to be -// told about explicitly: it runs in-process and does not inherit Gradle's. +// The snippets are compiled against the compile classpath, which the compiler has to be told about +// explicitly: it runs in-process and does not inherit Gradle's. Compile rather than runtime because +// that is what a consumer gets from a published POM — api dependencies and nothing more — so a +// snippet needing an implementation dependency to compile fails here rather than for a reader. +// Running one still uses the test JVM's classpath, which is what a consumer's runtime has. // // Every document this reads is an input. Without that, editing a README leaves the task up to date // and the check silently stops happening — which was true here until a deliberately broken snippet // failed to fail. tasks.withType().configureEach { - val classpath = sourceSets.test.get().runtimeClasspath + val classpath = sourceSets.test.get().compileClasspath val root = rootProject.layout.projectDirectory val documents = rootProject.fileTree(root) { From 878655c41b9237112e252b9b3269360e13e58446 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:05:26 -0500 Subject: [PATCH 34/77] Core(feat[panes]): Paste text as one invocation why: Pasting text needs a buffer and a paste that consumes it. Sent as two commands, a caller that stops in between leaves the text in the paste history every session on the server can read. tmux carries a command group to its server as one message, so a group cannot be left half applied. what: - Add Pane.paste(String text), which sends set-buffer and paste-buffer -d together and deletes the buffer itself if the paste fails - Rename Pane.paste(String bufferName) to pasteBuffer, so the two cannot be confused now that both take a string - Add Server.runTogether and CommandStrings.group, sharing the stale-handle guard with the single-command path - Refuse before tmux 3.4, whose delete-buffer removes an unrelated buffer when the name it is given is absent Verified on tmux 3.2a, 3.4 and 3.7b. Dropping -d, and dropping the cleanup after a failed paste, each fail one of the two new tests. --- .../it/BuffersAndClientIntegrationTest.java | 47 ++++++++++++++++- .../main/java/io/github/libtmux/Buffers.java | 4 +- .../src/main/java/io/github/libtmux/Pane.java | 52 +++++++++++++++++-- .../main/java/io/github/libtmux/Server.java | 39 ++++++++++---- .../libtmux/internal/CommandStrings.java | 17 ++++++ 5 files changed, 144 insertions(+), 15 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java index 9529fdc..b591546 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java @@ -7,6 +7,7 @@ import io.github.libtmux.BufferInfo; import io.github.libtmux.Client; import io.github.libtmux.ClientAttachment; +import io.github.libtmux.LibTmuxException; import io.github.libtmux.ObjectDoesNotExist; import io.github.libtmux.Pane; import io.github.libtmux.Server; @@ -125,13 +126,57 @@ void pastingPutsABufferIntoAPane(Server server) throws Exception { Pane pane = server.sessions().get(0).windows().get(0).panes().get(0); server.buffers().set("typed", "echo pasted-this\n"); - pane.paste("typed"); + pane.pasteBuffer("typed"); assertTrue( await(() -> pane.capture().stream().anyMatch(line -> line.contains("pasted-this"))), "the buffer never reached the pane"); } + @Test + void pastingTextLeavesNothingInTheBufferStack(Server server) throws Exception { + Pane pane = server.sessions().get(0).windows().get(0).panes().get(0); + server.buffers().set("belongs-to-the-user", "keep me"); + + if (!server.version().atLeast(EXACT_NAMED_DELETE)) { + assertThrows(UnsupportedTmuxVersion.class, () -> pane.paste("echo pasted-text\n")); + assertEquals( + List.of("belongs-to-the-user"), + server.buffers().list().stream().map(BufferInfo::name).toList(), + "refusal creates no buffer"); + return; + } + + pane.paste("echo pasted-text\n"); + + assertTrue( + await(() -> pane.capture().stream().anyMatch(line -> line.contains("pasted-text"))), + "the text never reached the pane"); + assertEquals( + List.of("belongs-to-the-user"), + server.buffers().list().stream().map(BufferInfo::name).toList(), + "the paste kept no buffer of its own"); + } + + /** The one case where the group's own cleanup cannot run, so the caller's has to. */ + @Test + void aPasteThatFailsRemovesOnlyTheBufferItMade(Server server) { + Pane doomed = server.sessions().get(0).windows().get(0).panes().get(0).split(); + server.buffers().set("belongs-to-the-user", "keep me"); + server.cmd("kill-pane", "-t", doomed.id().value()); + + if (!server.version().atLeast(EXACT_NAMED_DELETE)) { + assertThrows(UnsupportedTmuxVersion.class, () -> doomed.paste("never-arrives")); + } else { + assertThrows(LibTmuxException.class, () -> doomed.paste("never-arrives")); + } + + assertEquals( + List.of("belongs-to-the-user"), + server.buffers().list().stream().map(BufferInfo::name).toList(), + "a failed paste left its own buffer behind"); + } + @Test void sourcingAFileRunsTheCommandsInIt(Server server, @TempDir Path directory) throws Exception { Path script = directory.resolve("commands.conf"); diff --git a/libtmux/src/main/java/io/github/libtmux/Buffers.java b/libtmux/src/main/java/io/github/libtmux/Buffers.java index cea0f53..b31a867 100644 --- a/libtmux/src/main/java/io/github/libtmux/Buffers.java +++ b/libtmux/src/main/java/io/github/libtmux/Buffers.java @@ -17,7 +17,7 @@ public final class Buffers { private static final RowFormat LISTING = RowFormat.of("buffer_name", "buffer_size"); - private static final TmuxVersion EXACT_NAMED_DELETE = new TmuxVersion(3, 4, ""); + static final TmuxVersion EXACT_NAMED_DELETE = new TmuxVersion(3, 4, ""); private final Server server; @@ -91,7 +91,7 @@ public void load(String name, Path file) { } /** Protects a final semicolon from tmux's command-group parser on every transport. */ - private static String argument(String value) { + static String argument(String value) { Objects.requireNonNull(value, "value"); return value.endsWith(";") ? value.substring(0, value.length() - 1) + "\\;" : value; } diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 6593d10..7742a50 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.UUID; import java.util.function.Consumer; /** @@ -486,10 +487,55 @@ static String createdFormat() { } /** Pastes a named buffer into this pane, as though it had been typed. */ - public void paste(String bufferName) { + public void pasteBuffer(String name) { + Objects.requireNonNull(name, "name"); server.run( - snapshot, - List.of("paste-buffer", "-b", bufferName, "-t", state.id().value())); + snapshot, List.of("paste-buffer", "-b", name, "-t", state.id().value())); + } + + /** + * Pastes text into this pane as one block, leaving nothing behind on the server. + * + *

Nothing in the text is looked up as a key name, so a line containing {@code Enter} or a + * bracket arrives as those characters. That is what an editor, a REPL, or anything reading a + * here-document needs, and it is the difference from {@link #send}. + * + *

tmux needs a buffer to paste from, and this one travels in the same invocation as the paste + * that consumes it. A caller that stops in between therefore cannot leave the text in the paste + * history every session on the server can read. + * + * @throws UnsupportedTmuxVersion before tmux 3.4, where deleting the buffer left by a failed + * paste can remove one this did not create + */ + public void paste(String text) { + Objects.requireNonNull(text, "text"); + TmuxVersion running = server.version(snapshot); + if (!running.atLeast(Buffers.EXACT_NAMED_DELETE)) { + throw new UnsupportedTmuxVersion("pasting text", Buffers.EXACT_NAMED_DELETE, running); + } + String buffer = "libtmux-paste-" + UUID.randomUUID(); + try { + server.runTogether( + snapshot, + List.of( + List.of("set-buffer", "-b", buffer, Buffers.argument(text)), + // -d removes the buffer as it pastes, so the success path leaves nothing + // even when this is the last thing the caller manages to run. + List.of( + "paste-buffer", + "-d", + "-b", + buffer, + "-t", + state.id().value()))); + } catch (RuntimeException failure) { + try { + server.buffers().delete(buffer); + } catch (RuntimeException ignored) { + // Already gone, or the server is; neither changes what the caller is told. + } + throw failure; + } } /** Discards this pane's scrollback. */ diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 150d8a8..f604fe3 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; /** @@ -787,15 +788,7 @@ public CommandResult run(List argv) { } CommandResult cmd(ServerSnapshot snapshot, List argv) { - long pid = snapshot.serverPid() - .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); - String stale = "libtmux-stale-handle-" + pid; - CommandResult result = - cmd(List.of("if-shell", "-F", "#{==:#{pid}," + pid + "}", CommandStrings.stringify(argv), stale)); - if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { - throw new ObjectDoesNotExist("the tmux server this handle belonged to has ended"); - } - return result; + return guarded(snapshot, CommandStrings.stringify(argv)); } CommandResult run(ServerSnapshot snapshot, List argv) { @@ -806,6 +799,34 @@ CommandResult run(ServerSnapshot snapshot, List argv) { return result; } + /** + * Runs several commands in one invocation, so nothing of this caller's happens between them. + * + *

tmux carries a group to its server as one message and runs it there, so a caller that stops + * partway cannot leave the group half applied. That is what an operation whose second command + * cleans up after its first needs. + */ + CommandResult runTogether(ServerSnapshot snapshot, List> commands) { + CommandResult result = guarded(snapshot, CommandStrings.group(commands)); + if (!result.succeeded()) { + String verbs = commands.stream().map(argv -> argv.get(0)).collect(Collectors.joining(" then ")); + throw new LibTmuxException("tmux " + verbs + " failed: " + String.join("; ", result.stderr())); + } + return result; + } + + /** Refuses to reach a tmux server that is not the one this handle was made against. */ + private CommandResult guarded(ServerSnapshot snapshot, String command) { + long pid = snapshot.serverPid() + .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); + String stale = "libtmux-stale-handle-" + pid; + CommandResult result = cmd(List.of("if-shell", "-F", "#{==:#{pid}," + pid + "}", command, stale)); + if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { + throw new ObjectDoesNotExist("the tmux server this handle belonged to has ended"); + } + return result; + } + CommandResult run(ServerSnapshot snapshot, WindowContext expected, List argv) { long pid = snapshot.serverPid() .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); diff --git a/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java b/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java index fd67751..df56f40 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java @@ -19,6 +19,23 @@ public static String stringify(List argv) { return text.toString(); } + /** + * Several commands as the one string tmux's parser reads as a group. + * + *

Each argument is quoted, so a semicolon inside one stays part of it and only the separators + * between commands end a command. + */ + public static String group(List> commands) { + StringBuilder text = new StringBuilder(); + for (List argv : commands) { + if (text.length() > 0) { + text.append(" ; "); + } + text.append(stringify(argv)); + } + return text.toString(); + } + private static void appendArgument(StringBuilder text, String argument) { text.append('\''); for (int index = 0; index < argument.length(); index++) { From e35ff90a4a66d177cfa15295555c5e1499fc748d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:10:24 -0500 Subject: [PATCH 35/77] MCP(fix[paste]): Close the disconnect window why: tmux_paste_text set a buffer and pasted it as two commands. A client that disconnected in between closed the transport, so the paste never ran and the cleanup could not run either, leaving the pasted text on the server for every session to read. what: - Delegate to Pane.paste, which sends both commands in one invocation - Cover a disconnect arranged mid-paste - Move the failed-paste cleanup case to the core suite that now owns it --- .../java/io/github/libtmux/mcp/Typing.java | 35 ++-------- .../io/github/libtmux/mcp/TypingTest.java | 69 ++++++++++--------- 2 files changed, 41 insertions(+), 63 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java index 425260d..298d932 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Typing.java @@ -1,11 +1,8 @@ package io.github.libtmux.mcp; -import io.github.libtmux.LibTmuxException; import io.github.libtmux.Pane; -import io.github.libtmux.TmuxVersion; import java.util.ArrayList; import java.util.List; -import java.util.UUID; import org.jspecify.annotations.Nullable; /** @@ -17,8 +14,6 @@ */ final class Typing { - private static final TmuxVersion SAFE_PASTE_CLEANUP = new TmuxVersion(3, 4, ""); - private Typing() {} record Sent( @@ -70,36 +65,16 @@ static Sent sendKeys(Call call) { * delivered as one block with no key names looked up in it, so a line containing {@code Enter} * or a bracket arrives as those characters. * - *

The buffer is named for this call and deleted afterwards, so nothing is left in the paste - * history a person shares with the model. + *

The buffer tmux needs travels with the paste that consumes it, so a disconnected client + * cannot leave the text in the paste history a person shares with the model. */ static Pasted pasteText(Call call) { - TmuxVersion running = call.server().version(); - if (!running.atLeast(SAFE_PASTE_CLEANUP)) { - throw new LibTmuxException("tmux_paste_text requires tmux 3.4, but this server runs " + running - + "; older releases cannot safely clean up a failed paste"); - } Pane pane = Targets.pane(call.server(), call.string("pane_id")); String text = call.string("text"); boolean enter = call.flag("enter", false); - String buffer = "libtmux-mcp-paste-" + UUID.randomUUID(); - try { - // tmux turns the line feeds in a buffer into carriage returns as it pastes, so a - // trailing newline is what submits the text — there is no flag that means "and Enter". - call.server().buffers().set(buffer, enter ? text + "\n" : text); - // -d removes the buffer as part of the paste, so nothing is left in the paste history a - // person shares with the model even if this call is the last thing that runs. - call.server() - .run(List.of( - "paste-buffer", "-d", "-b", buffer, "-t", pane.id().value())); - } catch (RuntimeException e) { - try { - call.server().buffers().delete(buffer); - } catch (RuntimeException ignored) { - // Already gone, or the server is; neither changes what the caller is told. - } - throw e; - } + // tmux turns the line feeds in a buffer into carriage returns as it pastes, so a trailing + // newline is what submits the text — there is no flag that means "and Enter". + pane.paste(enter ? text + "\n" : text); return new Pasted( pane.id().value(), text.length(), diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index 7cf7291..cedbd04 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -16,11 +16,10 @@ import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -57,13 +56,13 @@ void sendingNoKeysAtAllSaysWhatWasWanted(Server server) { void pastedTextArrivesWithoutClaimingAUsersBuffer(Server server) { assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); String pane = server.panes().get(0).id().value(); - server.buffers().set("libtmux-mcp-paste", "user-owned"); + server.buffers().set("libtmux-paste", "user-owned"); Typing.Pasted pasted = Typing.pasteText(TestCalls.on(server, "pane_id", pane, "text", "Enter [C-c] done")); assertEquals(16, pasted.characters()); assertTrue(String.valueOf(pasted.note()).contains("pass 'enter'"), String.valueOf(pasted.note())); - assertEquals("user-owned", server.buffers().show("libtmux-mcp-paste")); + assertEquals("user-owned", server.buffers().show("libtmux-paste")); assertNoOwnedBuffers(server); } @@ -71,68 +70,68 @@ void pastedTextArrivesWithoutClaimingAUsersBuffer(Server server) { void pasteRefusesUnsafeCleanupBeforeCreatingABuffer(Server server) { assumeFalse(server.version().atLeast(SAFE_PASTE_CLEANUP)); String pane = server.panes().get(0).id().value(); - server.buffers().set("libtmux-mcp-paste", "user-owned"); + server.buffers().set("libtmux-paste", "user-owned"); LibTmuxException refused = assertThrows( LibTmuxException.class, () -> Typing.pasteText(TestCalls.on(server, "pane_id", pane, "text", "must-not-be-buffered"))); assertTrue(String.valueOf(refused.getMessage()).contains("requires tmux 3.4"), refused.getMessage()); - assertEquals("user-owned", server.buffers().show("libtmux-mcp-paste")); + assertEquals("user-owned", server.buffers().show("libtmux-paste")); assertNoOwnedBuffers(server); } + /** A client that goes away mid-paste is the case that decides whether the text can outlive it. */ @Test - void failedPasteDeletesOnlyItsOwnedBuffer(Server server) throws Exception { + void aDisconnectDuringAPasteLeavesNothingOnTheServer(Server server) { assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); String pane = server.panes().get(0).id().value(); - server.buffers().set("libtmux-mcp-paste", "user-owned"); try (ProcessTransport processes = new ProcessTransport()) { - TmuxTransport failingPaste = new TmuxTransport() { + AtomicReference pasting = new AtomicReference<>(); + TmuxTransport disconnecting = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - if (request.argv().get(0).equals("paste-buffer")) { - return new CommandResult(1, List.of(), List.of("forced paste failure")); + CommandResult result = processes.execute(request); + if (String.join(" ", request.argv()).contains("set-buffer")) { + pasting.get().close(); } - return processes.execute(request); + return result; } @Override public void close() {} }; - try (Server measured = Server.using(server.config(), failingPaste)) { - assertThrows( - LibTmuxException.class, - () -> Typing.pasteText(TestCalls.on(measured, "pane_id", pane, "text", "created-first"))); + Server cut = Server.using(server.config(), disconnecting); + pasting.set(cut); + try { + Typing.pasteText(TestCalls.on(cut, "pane_id", pane, "text", "secret-text")); + } catch (RuntimeException expected) { + // The disconnect is what this arranges; surviving it is not what is being asserted. } } - assertEquals("user-owned", server.buffers().show("libtmux-mcp-paste")); assertNoOwnedBuffers(server); + assertTrue( + String.join("\n", server.cmd("capture-pane", "-p", "-t", pane).stdout()) + .contains("secret-text")); } @Test - void concurrentPastesDoNotShareTheirServerGlobalBuffer(Server server) throws Exception { + void eachConcurrentPasteReachesOnlyItsOwnPane(Server server) throws Exception { assumeTrue(server.version().atLeast(SAFE_PASTE_CLEANUP)); String firstPane = server.panes().get(0).id().value(); String secondPane = server.panes().get(0).split().id().value(); - Map contentsByBuffer = new ConcurrentHashMap<>(); - Map bufferByTarget = new ConcurrentHashMap<>(); - CountDownLatch bothBuffersSet = new CountDownLatch(2); + CountDownLatch bothPending = new CountDownLatch(2); try (ProcessTransport processes = new ProcessTransport()) { TmuxTransport interleaving = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - CommandResult result = processes.execute(request); - if (request.argv().get(0).equals("set-buffer")) { - contentsByBuffer.put( - request.argv().get(2), request.argv().get(3)); - bothBuffersSet.countDown(); - await(bothBuffersSet); - } else if (request.argv().get(0).equals("paste-buffer")) { - bufferByTarget.put(request.argv().get(5), request.argv().get(3)); + if (String.join(" ", request.argv()).contains("paste-buffer")) { + // Hold both pastes open at once, so a shared buffer name would collide. + bothPending.countDown(); + await(bothPending); } - return result; + return processes.execute(request); } @Override @@ -150,14 +149,18 @@ public void close() {} } } - assertEquals("first-paste-marker", contentsByBuffer.get(bufferByTarget.get(firstPane))); - assertEquals("second-paste-marker", contentsByBuffer.get(bufferByTarget.get(secondPane))); + assertTrue(captureOf(server, firstPane).contains("first-paste-marker")); + assertTrue(captureOf(server, secondPane).contains("second-paste-marker")); assertNoOwnedBuffers(server); } + private static String captureOf(Server server, String pane) { + return String.join("\n", server.cmd("capture-pane", "-p", "-t", pane).stdout()); + } + private static void assertNoOwnedBuffers(Server server) { assertTrue(server.buffers().list().stream() - .noneMatch(buffer -> buffer.name().startsWith("libtmux-mcp-paste-"))); + .noneMatch(buffer -> buffer.name().startsWith("libtmux-paste-"))); } private static void await(CountDownLatch latch) { From 36c2da26905e88e0df4616a743bbf845a8b96037 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:13:01 -0500 Subject: [PATCH 36/77] MCP(test[races]): Close two suite races why: A full check failed twice on tests that pass alone. Both raced rather than found anything: one asked tmux for a new server while the one it had just killed was still exiting, which tmux answers with "server exited unexpectedly"; the other started a wait before the output it was meant to already see had reached the screen, so the text arrived after the call and matched. what: - Retry the replacement server until the old one has gone, since tmux leaves the socket file behind either way and gives no other signal - Wait for the text to be on screen before starting the wait that must not match it --- .../github/libtmux/mcp/WaitingForTextTest.java | 18 ++++++++++++++++++ .../io/github/libtmux/mcp/WatchesTest.java | 15 ++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WaitingForTextTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WaitingForTextTest.java index c98099b..67b1bc7 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WaitingForTextTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WaitingForTextTest.java @@ -43,6 +43,8 @@ void textThatArrivesIsMatchedAndTheWaitEndsAtOnce(Server server) { void textAlreadyOnScreenDoesNotSatisfyTheWait(Server server) { String pane = server.panes().get(0).id().value(); RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "echo already-ready", "timeout", 15)); + // The wait has to start from a screen that already says it, or this is not that case. + assertTrue(onScreen(server, pane, "already-ready"), "the output never reached the screen"); WaitingForText.Waited waited = WaitingForText.waitFor( TestCalls.on(server, "pane_id", pane, "patterns", List.of("already-ready"), "timeout", 2)); @@ -159,6 +161,22 @@ void theTimeoutIsClampedToTheCeiling(Server server) { assertTrue(waited.effectiveTimeout() <= Waits.CEILING.toSeconds()); } + private static boolean onScreen(Server server, String pane, String text) { + for (int attempt = 0; attempt < 100; attempt++) { + if (String.join("\n", server.cmd("capture-pane", "-p", "-t", pane).stdout()) + .contains(text)) { + return true; + } + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return false; + } + /** Sent without waiting, which is what makes this the tool for output nobody here authored. */ private static void send(Server server, String pane, String command) { server.run(List.of("send-keys", "-l", "-t", pane, command)); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java index 5ce2d6b..c8139bc 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/WatchesTest.java @@ -212,8 +212,12 @@ void watchingRecoversAfterTheTmuxServerRestarts(Server server) throws Exception try (Watches watching = Watches.start(Connection.to(server, Safety.MUTATING), heard)) { server.killServer(); + // tmux answers a socket whose server is still exiting with "server exited unexpectedly", + // and leaves the socket file behind either way, so the replacement is retried rather + // than assumed. What the watcher does once one exists is the subject here. + assertTrue(await(() -> restarted(server)), "no replacement server could be started"); Pane reborn = - server.newSession("reborn").windows().getFirst().panes().getFirst(); + server.sessions().getFirst().windows().getFirst().panes().getFirst(); String content = Resources.paneContentUri(reborn.id()); reborn.sendLine("echo after-restart"); @@ -265,6 +269,15 @@ void explicitlyRequestedWatchingFailsLoudlyWhenNothingCanBeAttached(Server serve assertTrue(String.valueOf(refused.getMessage()).contains("session"), refused.getMessage()); } + private static boolean restarted(Server server) { + try { + server.newSession("reborn"); + return true; + } catch (RuntimeException stillExiting) { + return false; + } + } + private static boolean await(BooleanSupplier condition) throws InterruptedException { // tmux checks a subscription about once a second, so this has to outlast that. for (int attempt = 0; attempt < 100; attempt++) { From 08c12165c2eb2bcefd630e3185e5c58e882dd50a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:13:21 -0500 Subject: [PATCH 37/77] Test(fix[input]): Wait for the shell to read why: A matrix run failed this case on tmux 3.2a and it passed alone on the same commit and on the one before it, so it races rather than found anything. The test defines a shell function and then uses its name, without establishing that anything had read the definition; when it has not, the failure reports that Enter was pressed, which points at the library rather than at the timing. what: - Prove the shell consumed the definition before invoking the name - Say so in its own assertion, so the two causes read differently The original failure was seen once under a full matrix and has not been reproduced in isolation. --- .../java/io/github/libtmux/it/OperationsIntegrationTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java index 841fde0..f892538 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java @@ -76,6 +76,10 @@ void aPaneRunsWhatItIsSent(Server server) { void aLineThatIsAKeyNameIsTypedLiterally(Server server) { Pane pane = session(server).windows().get(0).panes().get(0); pane.sendLine("Enter() { printf 'literal-%s-command\\n' enter; }"); + // The shell has to have read the definition before the name is used. Without this the same + // failure reports that Enter was pressed when the line was typed before anything was reading. + pane.sendLine("echo defined-the-function"); + assertTrue(awaitOutput(pane, "defined-the-function"), "the shell never read the definition"); pane.sendLine("clear"); pane.sendLine("Enter"); From f62c2afc6818c1e9375089c5de7c5de5700aa104 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:18:44 -0500 Subject: [PATCH 38/77] Core(feat[paste]): Send text on standard input why: A tmux command is bounded by MAX_IMSGSIZE, 16384 bytes for the whole packed argv, so pasting more than about 16 KB failed with "command too long" from tmux's own client. Carrying the text as an argument also meant quoting it into a command string, which is a parser to get right for no gain. what: - Give CommandRequest an input component and have ProcessTransport write it to tmux's standard input, after the drains so a large one cannot deadlock - Paste through load-buffer reading standard input, so the text never reaches tmux's parser and has no size bound - Refuse NUL explicitly, which the argument path used to refuse for us - Return Buffers.argument to private, having no caller outside its class again Verified on tmux 3.2a, 3.3a, 3.4 and 3.7b, including a 1 MB payload. Restoring the argument form fails the new size test. --- .../it/BuffersAndClientIntegrationTest.java | 32 +++++++++++++++++++ .../main/java/io/github/libtmux/Buffers.java | 2 +- .../src/main/java/io/github/libtmux/Pane.java | 11 ++++++- .../main/java/io/github/libtmux/Server.java | 26 ++++++++++++--- .../libtmux/transport/CommandRequest.java | 11 ++++++- .../libtmux/transport/ProcessTransport.java | 24 +++++++++++++- .../libtmux/transport/TmuxTransport.java | 6 +++- 7 files changed, 103 insertions(+), 9 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java index b591546..f7afaf7 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java @@ -158,6 +158,38 @@ void pastingTextLeavesNothingInTheBufferStack(Server server) throws Exception { "the paste kept no buffer of its own"); } + @Test + void pastedTextReachesThePaneExactly(Server server) throws Exception { + Pane pane = server.sessions().get(0).windows().get(0).panes().get(0); + if (!server.version().atLeast(EXACT_NAMED_DELETE)) { + return; + } + + // A semicolon ends a tmux command and a quote ends a quoted argument, so text carrying both + // is what shows the text never reaches tmux's parser. + pane.paste("printf 'a;b \"c\" d\\n'\n"); + + assertTrue( + await(() -> pane.capture().stream().anyMatch(line -> line.contains("a;b \"c\" d"))), + "the text did not arrive as written"); + assertThrows(IllegalArgumentException.class, () -> pane.paste("has\0nul"), "NUL is not typeable"); + } + + /** tmux refuses a command whose packed argv exceeds MAX_IMSGSIZE, which is 16384 bytes. */ + @Test + void pastedTextIsNotBoundedByTheSizeOfACommand(Server server) throws Exception { + Pane pane = server.sessions().get(0).windows().get(0).panes().get(0); + if (!server.version().atLeast(EXACT_NAMED_DELETE)) { + return; + } + + pane.paste("y".repeat(20_000) + "END-OF-A-LARGE-PASTE"); + + assertTrue( + await(() -> pane.capture().stream().anyMatch(line -> line.contains("END-OF-A-LARGE-PASTE"))), + "text larger than a tmux command never arrived"); + } + /** The one case where the group's own cleanup cannot run, so the caller's has to. */ @Test void aPasteThatFailsRemovesOnlyTheBufferItMade(Server server) { diff --git a/libtmux/src/main/java/io/github/libtmux/Buffers.java b/libtmux/src/main/java/io/github/libtmux/Buffers.java index b31a867..876a3f0 100644 --- a/libtmux/src/main/java/io/github/libtmux/Buffers.java +++ b/libtmux/src/main/java/io/github/libtmux/Buffers.java @@ -91,7 +91,7 @@ public void load(String name, Path file) { } /** Protects a final semicolon from tmux's command-group parser on every transport. */ - static String argument(String value) { + private static String argument(String value) { Objects.requireNonNull(value, "value"); return value.endsWith(";") ? value.substring(0, value.length() - 1) + "\\;" : value; } diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 7742a50..6c7d2c4 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -504,11 +504,19 @@ public void pasteBuffer(String name) { * that consumes it. A caller that stops in between therefore cannot leave the text in the paste * history every session on the server can read. * + *

The text goes to tmux on standard input rather than as an argument, so it is not bounded by + * the size of a tmux command and never reaches tmux's parser. + * + * @throws IllegalArgumentException if the text contains NUL, which a terminal cannot receive and + * {@link #send} refuses too * @throws UnsupportedTmuxVersion before tmux 3.4, where deleting the buffer left by a failed * paste can remove one this did not create */ public void paste(String text) { Objects.requireNonNull(text, "text"); + if (text.indexOf('\0') >= 0) { + throw new IllegalArgumentException("pasted text cannot contain NUL"); + } TmuxVersion running = server.version(snapshot); if (!running.atLeast(Buffers.EXACT_NAMED_DELETE)) { throw new UnsupportedTmuxVersion("pasting text", Buffers.EXACT_NAMED_DELETE, running); @@ -517,8 +525,9 @@ public void paste(String text) { try { server.runTogether( snapshot, + text, List.of( - List.of("set-buffer", "-b", buffer, Buffers.argument(text)), + List.of("load-buffer", "-b", buffer, "-"), // -d removes the buffer as it pastes, so the success path leaves nothing // even when this is the last thing the caller manages to run. List.of( diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index f604fe3..8e1fc9c 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -561,15 +561,23 @@ public CommandResult cmd(List argv) { /** Runs one tmux command against this server, overriding the configured deadline. */ public CommandResult cmd(List argv, Duration timeout) { - return transport.execute(request(argv, timeout)); + return cmd(argv, timeout, ""); + } + + private CommandResult cmd(List argv, Duration timeout, String input) { + return transport.execute(request(argv, timeout, input)); } private CommandRequest request(List argv, Duration timeout) { + return request(argv, timeout, ""); + } + + private CommandRequest request(List argv, Duration timeout, String input) { requireOpen(); List endpoint = config.endpointCommand(); List command = new ArrayList<>(endpoint.size()); command.addAll(endpoint); - return new CommandRequest(command, argv, timeout); + return new CommandRequest(command, argv, timeout, input); } private void requireOpen() { @@ -807,7 +815,12 @@ CommandResult run(ServerSnapshot snapshot, List argv) { * cleans up after its first needs. */ CommandResult runTogether(ServerSnapshot snapshot, List> commands) { - CommandResult result = guarded(snapshot, CommandStrings.group(commands)); + return runTogether(snapshot, "", commands); + } + + /** As {@link #runTogether}, with {@code input} on tmux's standard input for the group to read. */ + CommandResult runTogether(ServerSnapshot snapshot, String input, List> commands) { + CommandResult result = guarded(snapshot, CommandStrings.group(commands), input); if (!result.succeeded()) { String verbs = commands.stream().map(argv -> argv.get(0)).collect(Collectors.joining(" then ")); throw new LibTmuxException("tmux " + verbs + " failed: " + String.join("; ", result.stderr())); @@ -817,10 +830,15 @@ CommandResult runTogether(ServerSnapshot snapshot, List> commands) /** Refuses to reach a tmux server that is not the one this handle was made against. */ private CommandResult guarded(ServerSnapshot snapshot, String command) { + return guarded(snapshot, command, ""); + } + + private CommandResult guarded(ServerSnapshot snapshot, String command, String input) { long pid = snapshot.serverPid() .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); String stale = "libtmux-stale-handle-" + pid; - CommandResult result = cmd(List.of("if-shell", "-F", "#{==:#{pid}," + pid + "}", command, stale)); + CommandResult result = cmd( + List.of("if-shell", "-F", "#{==:#{pid}," + pid + "}", command, stale), config.defaultTimeout(), input); if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { throw new ObjectDoesNotExist("the tmux server this handle belonged to has ended"); } diff --git a/libtmux/src/main/java/io/github/libtmux/transport/CommandRequest.java b/libtmux/src/main/java/io/github/libtmux/transport/CommandRequest.java index da8b7c2..35730e5 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/CommandRequest.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/CommandRequest.java @@ -16,13 +16,17 @@ * @param endpoint the tmux executable and its server selection, such as {@code [tmux, -S, path]} * @param argv the command and its arguments, each already a separate element * @param timeout how long the caller will wait for the whole invocation + * @param input what the command reads from tmux's standard input, empty for the commands that + * read none. A tmux argument is bounded by MAX_IMSGSIZE, so text too large to be one + * travels here instead. */ -public record CommandRequest(List endpoint, List argv, Duration timeout) { +public record CommandRequest(List endpoint, List argv, Duration timeout, String input) { public CommandRequest { endpoint = List.copyOf(endpoint); argv = List.copyOf(argv); Objects.requireNonNull(timeout, "timeout"); + Objects.requireNonNull(input, "input"); if (endpoint.isEmpty()) { throw new IllegalArgumentException("endpoint has no executable"); } @@ -31,6 +35,11 @@ public record CommandRequest(List endpoint, List argv, Duration } } + /** A request for a command that reads nothing, which is every command but a buffer load. */ + public CommandRequest(List endpoint, List argv, Duration timeout) { + this(endpoint, argv, timeout, ""); + } + /** The full argv to hand a process builder. */ public List commandLine() { List line = new ArrayList<>(endpoint.size() + argv.size()); diff --git a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java index cb8c5f5..83d64ed 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java @@ -5,6 +5,8 @@ import java.io.Closeable; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; import java.util.Objects; @@ -152,8 +154,8 @@ private CommandResult execute(CommandRequest request, boolean waiting) { } Drains drains = null; try { - closeQuietly(process.process().getOutputStream(), null); drains = submit(process); + supplyInput(process, request.input()); return complete(process, drains, deadline); } finally { live.remove(process); @@ -472,6 +474,26 @@ private static void restoreInterrupt(AtomicBoolean interrupted) { } } + /** + * Writes what the command reads, then closes its standard input. + * + *

After the drains are running rather than before: tmux replies while it reads, and an + * input large enough to fill the pipe would otherwise wait on a stdout nobody is draining. + */ + private static void supplyInput(RunningProcess process, String input) { + OutputStream stdin = process.process().getOutputStream(); + try { + if (!input.isEmpty()) { + stdin.write(input.getBytes(StandardCharsets.UTF_8)); + stdin.flush(); + } + } catch (IOException stoppedReading) { + // What tmux made of it is in its exit status and stderr, which say more than this. + } finally { + closeQuietly(stdin, null); + } + } + private static void closeQuietly(Closeable stream, @Nullable TmuxTransportException failure) { try { stream.close(); diff --git a/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransport.java index cae921b..a44ce79 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/TmuxTransport.java @@ -12,7 +12,11 @@ public interface TmuxTransport extends AutoCloseable { /** * Runs the request to completion. * - * @param request what to run and how long to wait + *

An implementation writes {@link CommandRequest#input()} to tmux's standard input and + * closes it. A command that reads standard input and is given none reads end of file, which + * tmux reports as success over an empty result rather than as a failure. + * + * @param request what to run, what it reads, and how long to wait * @return the exit status and both channels; a nonzero exit is a result, not a failure * @throws TmuxTransportException if the command could not be run to completion, carrying how * certain it is that tmux applied it From 3b5f92b438b52922b8bdf4b80ba22c5d06fc3fb0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:21:47 -0500 Subject: [PATCH 39/77] MCP(fix[run]): Signal with this server's tmux why: tmux_run types "tmux ... wait-for -S" into the pane and waits on that channel. The pane resolves that name against the user's PATH, not this process's, so a different tmux release can answer. A client that does not match its server is dropped with "server exited unexpectedly", the signal is never delivered, and every call times out with no exit status while the command itself ran fine. what: - Add ServerConfig.binaryPath, the binary resolved as this process resolves it - Write that path into the payload rather than a name the pane re-resolves - Cover it with a pane whose PATH offers a tmux that refuses to run The new case fails as TIMED_OUT when the payload carries the bare name. --- .../github/libtmux/mcp/RunningCommands.java | 4 ++- .../libtmux/mcp/RunningCommandsTest.java | 27 +++++++++++++++++ .../java/io/github/libtmux/ServerConfig.java | 30 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index e9aca57..64b6513 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -142,7 +142,9 @@ private static String payload( // The config file is left off: it is read when a server starts and means nothing to a command // sent to one already running. Everything typed here is echoed by the shell onto the pane a // person may be watching, so the shortest correct command line is the kindest one. - List tmux = new ArrayList<>(List.of(server.config().binary())); + // A resolved path, not the name: the pane resolves a name against the user's PATH, and a + // client from another release than this server is dropped without delivering the signal. + List tmux = new ArrayList<>(List.of(server.config().binaryPath())); tmux.addAll(server.config().endpoint().flags()); String finish = Shell.quoteAll(append(tmux, "wait-for", "-S", channel)); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 18badd6..dc9e507 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -16,12 +16,15 @@ import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; import io.github.libtmux.transport.TmuxTransportException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; /** * Running a command and knowing how it ended, against real tmux. @@ -33,6 +36,30 @@ @ExtendWith(TmuxExtension.class) final class RunningCommandsTest { + /** + * The signal is sent by the pane, so it is the pane's PATH that decides which tmux sends it. A + * client from another release than this server is dropped without delivering it. + */ + @Test + void thePaneSignalsWithThisServersTmuxRatherThanItsOwn(Server server, @TempDir Path decoy) throws Exception { + Path impostor = decoy.resolve("tmux"); + Files.writeString(impostor, "#!/bin/sh\nexit 1\n"); + impostor.toFile().setExecutable(true); + server.cmd("set-environment", "-t", "libtmux", "PATH", decoy + ":" + System.getenv("PATH")); + String pane = server.sessions() + .get(0) + .newWindow("decoyed") + .panes() + .get(0) + .id() + .value(); + + RunningCommands.Ran ran = RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "echo routed")); + + assertEquals("SIGNALLED", ran.outcome(), "a tmux on the pane's PATH answered instead of this server's"); + assertEquals(0, ran.exitStatus()); + } + @Test void aCommandThatSucceedsComesBackWithItsOutputAndStatus(Server server) { String pane = server.panes().get(0).id().value(); diff --git a/libtmux/src/main/java/io/github/libtmux/ServerConfig.java b/libtmux/src/main/java/io/github/libtmux/ServerConfig.java index bc95838..2c0c2d6 100644 --- a/libtmux/src/main/java/io/github/libtmux/ServerConfig.java +++ b/libtmux/src/main/java/io/github/libtmux/ServerConfig.java @@ -1,5 +1,7 @@ package io.github.libtmux; +import java.io.File; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; @@ -45,6 +47,34 @@ public String binary() { return binary; } + /** + * The binary as a path, resolved the way this process resolves it. + * + *

What to write into a command a pane will run. A pane resolves a bare name against the + * user's {@code PATH} rather than this process's, and a tmux client built from a different + * release than the server it reaches is dropped rather than served. Falls back to the name when + * nothing on {@code PATH} matches, which leaves the caller no worse off. + */ + public String binaryPath() { + if (binary.contains(File.separator)) { + return binary; + } + String search = System.getenv("PATH"); + if (search == null) { + return binary; + } + for (String entry : search.split(File.pathSeparator, -1)) { + if (entry.isEmpty()) { + continue; + } + Path candidate = Path.of(entry, binary); + if (Files.isExecutable(candidate)) { + return candidate.toString(); + } + } + return binary; + } + /** Which tmux server to talk to. */ public ServerEndpoint endpoint() { return endpoint; From 718cbf5f63abb6d3b5b06f9a5403c6637652c2cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:45:57 -0500 Subject: [PATCH 40/77] Transport(fix[argv]): Keep a trailing semicolon why: tmux ends a command at an argument whose last byte is ';' and keeps the semicolon when a backslash precedes it, so a value ending in one was truncated with no error. Only Buffers escaped for that rule, so every other path carrying caller text lost the byte: creation names and shell commands, run-shell, if-shell, bind-key, and every batch operation. Batch also put its own ';' separators in the same flat argv, leaving the transport unable to tell syntax from data. what: - CommandRequest holds List> commands, each argv literal, and encodes for tmux's argv parser in commandLine(); the endpoint is passed through, since tmux reads it before parsing commands - CommandStrings.arguments escapes a trailing semicolon and separates commands; stringify no longer consumes an escape a caller supplied - Batch emits operations and markers as commands, not as one flat argv - Buffers.argument goes, being the caller obligation this replaces - ControlClient.isCommandGroup goes: an argv is one command by construction, so nothing a caller passes can strand a second reply - Cover the two encodings on tmux 3.2a and 3.7b --- docs/spikes/21-command-group-boundaries.md | 8 +++ .../it/ArgvIntegrityIntegrationTest.java | 69 +++++++++++++++++++ .../it/ControlModeIntegrationTest.java | 29 +++----- .../github/libtmux/mcp/ServerDiscovery.java | 2 +- .../libtmux/mcp/RunningCommandsTest.java | 4 +- .../io/github/libtmux/mcp/TypingTest.java | 4 +- .../workspace/WorkspaceBuilderTest.java | 14 ++-- .../main/java/io/github/libtmux/Buffers.java | 18 ++--- .../main/java/io/github/libtmux/Server.java | 13 ++-- .../java/io/github/libtmux/batch/Batch.java | 22 +++--- .../github/libtmux/control/ControlClient.java | 31 --------- .../libtmux/internal/CommandStrings.java | 35 +++++++++- .../libtmux/transport/CommandRequest.java | 51 +++++++++----- .../libtmux/transport/ProcessTransport.java | 12 ++-- .../java/io/github/libtmux/HandleTest.java | 4 +- .../java/io/github/libtmux/ServerTest.java | 14 ++-- .../libtmux/control/ControlClientTest.java | 50 ++------------ .../transport/CarrierStarvationTest.java | 2 +- .../libtmux/transport/CommandRequestTest.java | 52 ++++++++++---- .../transport/ProcessTransportTest.java | 16 ++--- 20 files changed, 255 insertions(+), 195 deletions(-) create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/ArgvIntegrityIntegrationTest.java diff --git a/docs/spikes/21-command-group-boundaries.md b/docs/spikes/21-command-group-boundaries.md index 6ec8f75..16b6712 100644 --- a/docs/spikes/21-command-group-boundaries.md +++ b/docs/spikes/21-command-group-boundaries.md @@ -1,5 +1,13 @@ # Where one tmux command ends and the next begins +> **Superseded in part.** tmux's rule, measured here, still holds and is still +> the reason this matters. What changed is who applies it: the escape was a +> caller obligation that only `Buffers` met, so every other path carrying caller +> text silently lost a trailing semicolon. `CommandRequest` now holds commands +> literally and each carrier encodes for its own parser, which is why +> `ControlClient.isCommandGroup` no longer exists. The "What changed" section +> below describes the design this replaced. + ## Verdict The carriers disagreed about it, which makes it the first real breach of the diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ArgvIntegrityIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ArgvIntegrityIntegrationTest.java new file mode 100644 index 0000000..301cc02 --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/ArgvIntegrityIntegrationTest.java @@ -0,0 +1,69 @@ +package io.github.libtmux.it; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.github.libtmux.Options; +import io.github.libtmux.Server; +import io.github.libtmux.Session; +import io.github.libtmux.Window; +import io.github.libtmux.batch.BatchResult; +import io.github.libtmux.junit5.TmuxExtension; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Text that survives the trip to tmux unchanged. + * + *

tmux ends a command at an argument whose last byte is {@code ;}, keeping the argument up to it, + * so a value ending in a semicolon is truncated rather than refused. Every value below ends in one. + */ +@ExtendWith(TmuxExtension.class) +final class ArgvIntegrityIntegrationTest { + + @Test + void aWindowNameKeepsItsTrailingSemicolon(Server server) { + Window window = session(server).newWindow("placeholder"); + + assertEquals("build;", window.rename("build;").name()); + } + + @Test + void anOptionValueKeepsItsTrailingSemicolon(Server server) { + Options options = session(server).options(); + + options.set("status-left", "one;"); + + assertEquals(Optional.of("one;"), options.get("status-left")); + } + + @Test + void aSessionNameKeepsItsTrailingSemicolon(Server server) { + assertEquals("work;", server.newSession("work;").name()); + } + + @Test + void aPaneTitleKeepsItsTrailingSemicolon(Server server) { + assertEquals( + "shell;", + session(server).activePane().orElseThrow().retitle("shell;").title()); + } + + /** A batch separates its operations structurally, so caller text cannot end one early. */ + @Test + void anOperationEndingInASemicolonDoesNotEndTheBatch(Server server) { + Window window = session(server).newWindow("placeholder"); + + BatchResult results = server.batch() + .add("rename-window", "-t", window.id().value(), "named;") + .add("display-message", "-p", "-t", window.id().value(), "#{window_name}") + .run(); + + assertEquals(2, results.operations().size()); + assertEquals("named;", results.operations().get(1).stdout().get(0)); + } + + private static Session session(Server server) { + return server.sessions().get(0); + } +} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java index cc089e1..6d21a43 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java @@ -97,35 +97,28 @@ void anArgumentSurvivesTmuxsOwnLexer(Server server) { "a semicolon inside an argument is not a command separator"); assertEquals( List.of("trailing;"), - client.send("display-message", "-p", "trailing\\;").lines(), - "an escaped trailing semicolon is part of the argument, not a separator"); + client.send("display-message", "-p", "trailing;").lines(), + "a semicolon ending an argument is data, not a separator"); } } /** - * A reply is framed per command, so a request that is several commands has several replies and - * this client can only account for one of them. The rest go to whoever asks next. - * - *

Both spellings are refused, because tmux reads both: a semicolon standing alone between two - * commands, and one ending an argument of the first. + * A reply is framed per command, so this client sends exactly one. An argv is one command by + * construction: every word is quoted, so nothing a caller passes can open a second one and + * strand its reply for whoever asks next. */ @Test - void aRequestOfSeveralCommandsIsRefusedRatherThanMisframed(Server server) { + void anArgumentCannotOpenASecondCommandAndStrandItsReply(Server server) { try (ControlClient client = attach(server)) { - assertThrows( - IllegalArgumentException.class, - () -> client.send( - List.of("display-message", "-p", "first", ";", "display-message", "-p", "second")), - "a semicolon standing alone separates two commands"); - assertThrows( - IllegalArgumentException.class, - () -> client.send(List.of("new-window", "-d", "-n", "grouped;", "list-windows")), - "a semicolon ending an argument separates two commands just as well"); + assertEquals( + List.of("first ; display-message -p second"), + client.send(List.of("display-message", "-p", "first ; display-message -p second")) + .lines()); assertEquals( List.of("still answering"), client.send("display-message", "-p", "still answering").lines(), - "a refusal writes nothing, so the stream is still in step"); + "one command in, one reply out, so the stream is still in step"); } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java index e9bf183..fd0a9ee 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/ServerDiscovery.java @@ -217,7 +217,7 @@ private static boolean isSocket(Path path) { private KnownServer probe(ProcessTransport transport, String binary, Path socket) { try { - CommandResult result = transport.execute(new CommandRequest( + CommandResult result = transport.execute(CommandRequest.of( List.of(binary, "-S", socket.toString()), List.of("list-sessions", "-F", "#{session_id}"), probeTimeout)); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index dc9e507..604ec3b 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -187,7 +187,7 @@ void uncertainCommandDeliveryDoesNotLeaveThePaneBlocked(Server server) throws Ex try (ProcessTransport processes = new ProcessTransport()) { TmuxTransport uncertain = borrowing(request -> { CommandResult result = processes.execute(request); - if (request.argv().stream().anyMatch(argument -> argument.contains("ch_lt"))) { + if (request.commands().get(0).stream().anyMatch(argument -> argument.contains("ch_lt"))) { throw new TmuxTransportException("simulated failure after delivery", DispatchOutcome.UNKNOWN, null); } return result; @@ -288,7 +288,7 @@ void concurrentRunsDoNotMergeTheirCommandLines(Server server) throws Exception { try (ProcessTransport processes = new ProcessTransport()) { TmuxTransport interleaving = borrowing(request -> { CommandResult result = processes.execute(request); - String argv = String.join("\0", request.argv()); + String argv = String.join("\0", request.commands().get(0)); if (argv.contains("send-keys") && argv.contains("ch_lt")) { bothLinesSent.countDown(); await(bothLinesSent); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java index cedbd04..9fe5631 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TypingTest.java @@ -92,7 +92,7 @@ void aDisconnectDuringAPasteLeavesNothingOnTheServer(Server server) { @Override public CommandResult execute(CommandRequest request) { CommandResult result = processes.execute(request); - if (String.join(" ", request.argv()).contains("set-buffer")) { + if (String.join(" ", request.commands().get(0)).contains("set-buffer")) { pasting.get().close(); } return result; @@ -126,7 +126,7 @@ void eachConcurrentPasteReachesOnlyItsOwnPane(Server server) throws Exception { TmuxTransport interleaving = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - if (String.join(" ", request.argv()).contains("paste-buffer")) { + if (String.join(" ", request.commands().get(0)).contains("paste-buffer")) { // Hold both pastes open at once, so a shared buffer name would collide. bothPending.countDown(); await(bothPending); diff --git a/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java b/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java index 728cabb..2170767 100644 --- a/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java +++ b/libtmux-workspace/src/test/java/io/github/libtmux/workspace/WorkspaceBuilderTest.java @@ -329,7 +329,7 @@ void anUnsupportedBuiltInLayoutIsRejectedBeforeAnyEffect() { TmuxTransport transport = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - if (request.argv().get(0).equals("display-message")) { + if (request.commands().get(0).get(0).equals("display-message")) { return new CommandResult( 0, List.of(String.join(RowFormat.of("field").separator(), "4242", "3.4")), List.of()); } @@ -358,12 +358,16 @@ void anUncertainCreationStillTargetsItsUniqueStagingSessionForCleanup() { TmuxTransport transport = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - if (request.argv().get(0).equals("new-session")) { - staged.set(request.argv().get(request.argv().indexOf("-s") + 1)); + if (request.commands().get(0).get(0).equals("new-session")) { + staged.set(request.commands() + .get(0) + .get(request.commands().get(0).indexOf("-s") + 1)); throw new TmuxTransportException("reply lost", DispatchOutcome.UNKNOWN, null); } - if (request.argv().get(0).equals("kill-session")) { - cleaned.set(request.argv().get(request.argv().indexOf("-t") + 1)); + if (request.commands().get(0).get(0).equals("kill-session")) { + cleaned.set(request.commands() + .get(0) + .get(request.commands().get(0).indexOf("-t") + 1)); return new CommandResult(0, List.of(), List.of()); } return new CommandResult(0, List.of(), List.of()); diff --git a/libtmux/src/main/java/io/github/libtmux/Buffers.java b/libtmux/src/main/java/io/github/libtmux/Buffers.java index 876a3f0..43ca30e 100644 --- a/libtmux/src/main/java/io/github/libtmux/Buffers.java +++ b/libtmux/src/main/java/io/github/libtmux/Buffers.java @@ -5,7 +5,6 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Objects; /** * The tmux server's paste buffers. @@ -42,7 +41,7 @@ public List list() { /** Puts text in a named buffer, replacing whatever was there. */ public void set(String name, String contents) { - server.run(List.of("set-buffer", "-b", argument(name), argument(contents))); + server.run(List.of("set-buffer", "-b", name, contents)); } /** @@ -51,7 +50,7 @@ public void set(String name, String contents) { * @throws ObjectDoesNotExist if the server has no buffer by that name */ public String show(String name) { - var result = server.cmd(List.of("show-buffer", "-b", argument(name))); + var result = server.cmd(List.of("show-buffer", "-b", name)); if (!result.succeeded()) { throw new ObjectDoesNotExist("no buffer named '" + name + "'"); } @@ -66,12 +65,11 @@ public String show(String name) { * buffer when the name is absent */ public void delete(String name) { - String target = argument(name); TmuxVersion running = server.version(); if (!running.atLeast(EXACT_NAMED_DELETE)) { throw new UnsupportedTmuxVersion("deleting a buffer by exact name", EXACT_NAMED_DELETE, running); } - CommandResult result = server.cmd(List.of("delete-buffer", "-b", target)); + CommandResult result = server.cmd(List.of("delete-buffer", "-b", name)); if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.equals("unknown buffer: " + name))) { throw new ObjectDoesNotExist("no buffer named '" + name + "'"); } @@ -82,17 +80,11 @@ public void delete(String name) { /** Writes a buffer's contents to a file. */ public void save(String name, Path file) { - server.run(List.of("save-buffer", "-b", argument(name), argument(file.toString()))); + server.run(List.of("save-buffer", "-b", name, file.toString())); } /** Reads a file into a named buffer. */ public void load(String name, Path file) { - server.run(List.of("load-buffer", "-b", argument(name), argument(file.toString()))); - } - - /** Protects a final semicolon from tmux's command-group parser on every transport. */ - private static String argument(String value) { - Objects.requireNonNull(value, "value"); - return value.endsWith(";") ? value.substring(0, value.length() - 1) + "\\;" : value; + server.run(List.of("load-buffer", "-b", name, file.toString())); } } diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 8e1fc9c..30e1f0a 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -215,7 +215,7 @@ public void killServer() { * single exit status cannot say which command failed or which never ran. */ public Batch batch() { - return new Batch(argv -> cmd(argv, config.defaultTimeout())); + return new Batch(commands -> transport.execute(request(commands, config.defaultTimeout(), ""))); } /** @@ -565,19 +565,16 @@ public CommandResult cmd(List argv, Duration timeout) { } private CommandResult cmd(List argv, Duration timeout, String input) { - return transport.execute(request(argv, timeout, input)); + return transport.execute(request(List.of(argv), timeout, input)); } private CommandRequest request(List argv, Duration timeout) { - return request(argv, timeout, ""); + return request(List.of(argv), timeout, ""); } - private CommandRequest request(List argv, Duration timeout, String input) { + private CommandRequest request(List> commands, Duration timeout, String input) { requireOpen(); - List endpoint = config.endpointCommand(); - List command = new ArrayList<>(endpoint.size()); - command.addAll(endpoint); - return new CommandRequest(command, argv, timeout, input); + return new CommandRequest(config.endpointCommand(), commands, timeout, input); } private void requireOpen() { diff --git a/libtmux/src/main/java/io/github/libtmux/batch/Batch.java b/libtmux/src/main/java/io/github/libtmux/batch/Batch.java index 8ed7a7d..81f00cd 100644 --- a/libtmux/src/main/java/io/github/libtmux/batch/Batch.java +++ b/libtmux/src/main/java/io/github/libtmux/batch/Batch.java @@ -21,15 +21,15 @@ public final class Batch { private static final String MARKER = Tokens.perProcess(); - private final Function, CommandResult> dispatch; + private final Function>, CommandResult> dispatch; private final List> operations = new ArrayList<>(); /** * Collects operations to run together. * - * @param dispatch runs the assembled command group and returns tmux's raw reply + * @param dispatch runs the assembled commands in one invocation and returns tmux's raw reply */ - public Batch(Function, CommandResult> dispatch) { + public Batch(Function>, CommandResult> dispatch) { this.dispatch = dispatch; } @@ -65,18 +65,14 @@ public BatchResult run() { return attribute(reply); } - /** {@code op0 ; marker0 ; op1 ; marker1 ; …}, with each {@code ;} its own argv element. */ - private List assemble() { - List argv = new ArrayList<>(); + /** {@code op0, marker0, op1, marker1, …}: each operation followed by the marker that closes it. */ + private List> assemble() { + List> commands = new ArrayList<>(operations.size() * 2); for (int index = 0; index < operations.size(); index++) { - if (index > 0) { - argv.add(";"); - } - argv.addAll(operations.get(index)); - argv.add(";"); - argv.addAll(List.of("display-message", "-p", marker(index))); + commands.add(operations.get(index)); + commands.add(List.of("display-message", "-p", marker(index))); } - return argv; + return commands; } private static String marker(int index) { diff --git a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java index 858d0b2..f542a59 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java +++ b/libtmux/src/main/java/io/github/libtmux/control/ControlClient.java @@ -160,11 +160,6 @@ public ControlReply send(List argv, Duration timeout) { if (timeout.isZero() || timeout.isNegative()) { throw new IllegalArgumentException("timeout is not positive"); } - if (isCommandGroup(argv)) { - // Refused before anything is written, so the stream stays in step and the caller can - // send the commands one at a time — which is what this carrier is for. - throw new IllegalArgumentException("a control-mode request must contain one command"); - } if (closed.get() || failed) { throw new IllegalStateException("control client is not usable"); } @@ -270,36 +265,10 @@ private void closeAfterFailure(RuntimeException failure) { // -------------------------------------------------------------------------------- protocol - /** - * Whether this argv is more than one tmux command. - * - *

tmux ends a command at a semicolon that ends any argument, not only at one standing alone, - * and a backslash before it keeps the semicolon instead. That is the rule its own argv parser - * applies before a command runs, so {@code ["kill-window;", "list-windows"]} is two commands and - * {@code ["display-message", "-p", "done\\;"]} is one. - * - *

Public because a carrier has to make this judgement before choosing how to send. Control - * mode frames a reply per command, so a request of several has several replies and this client - * can account for only one. - */ - public static boolean isCommandGroup(List argv) { - for (String argument : argv) { - if (argument.endsWith(";") && !argument.endsWith("\\;")) { - return true; - } - } - return false; - } - /** * tmux parses a control-mode request as one line, so an argument has to survive its lexer. * Single quotes preserve everything except a single quote, which is closed, escaped and * reopened. - * - *

The backslash guarding a trailing semicolon is spent here rather than passed on. It exists - * for tmux's argv parser, which the process carrier goes through and this one does not, so - * quoting it would deliver a backslash the other carrier had already consumed and the two would - * disagree about what the argument was. */ static String line(List argv) { return ControlProtocol.line(argv); diff --git a/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java b/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java index df56f40..b5a9ce6 100644 --- a/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java +++ b/libtmux/src/main/java/io/github/libtmux/internal/CommandStrings.java @@ -1,20 +1,45 @@ package io.github.libtmux.internal; +import java.util.ArrayList; import java.util.List; -/** Lossless conversion from a tmux argv to the command string its parser accepts. */ +/** + * Encodes a tmux command for each of the two parsers tmux reads commands with. + * + *

Every argv this takes is literal: what a caller meant, with nothing escaped for either parser. + */ public final class CommandStrings { private CommandStrings() {} + /** + * Several commands as the flat argv tmux's own argv parser reads. + * + *

tmux ends a command at an argument whose last byte is {@code ;}, and keeps the semicolon + * instead when a backslash precedes it, so an argument ending in one is escaped here and a bare + * {@code ;} separates commands. + */ + public static List arguments(List> commands) { + List argv = new ArrayList<>(); + for (List command : commands) { + if (!argv.isEmpty()) { + argv.add(";"); + } + for (String argument : command) { + argv.add(escape(argument)); + } + } + return List.copyOf(argv); + } + + /** One command as the string tmux's command parser reads, which is what {@code if-shell} takes. */ public static String stringify(List argv) { StringBuilder text = new StringBuilder(); for (String argument : argv) { if (text.length() > 0) { text.append(' '); } - String literal = argument.endsWith("\\;") ? argument.substring(0, argument.length() - 2) + ';' : argument; - appendArgument(text, literal); + appendArgument(text, argument); } return text.toString(); } @@ -36,6 +61,10 @@ public static String group(List> commands) { return text.toString(); } + private static String escape(String argument) { + return argument.endsWith(";") ? argument.substring(0, argument.length() - 1) + "\\;" : argument; + } + private static void appendArgument(StringBuilder text, String argument) { text.append('\''); for (int index = 0; index < argument.length(); index++) { diff --git a/libtmux/src/main/java/io/github/libtmux/transport/CommandRequest.java b/libtmux/src/main/java/io/github/libtmux/transport/CommandRequest.java index 35730e5..2b41021 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/CommandRequest.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/CommandRequest.java @@ -1,5 +1,6 @@ package io.github.libtmux.transport; +import io.github.libtmux.internal.CommandStrings; import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -7,53 +8,67 @@ import java.util.Objects; /** - * One tmux invocation: how to reach the server, then the command to run there. + * One tmux invocation: how to reach the server, then the commands to run there. * - *

An argv list, never a shell string, so a semicolon or a space inside an argument is data - * rather than syntax. The timeout rides on the request instead of the transport because the same - * server serves both a listing that should answer immediately and an attach that never returns. + *

Commands are held literally. tmux ends a command at an argument whose last byte is {@code ;}, + * so a value ending in one would otherwise be truncated with no error; a transport encodes for the + * parser it feeds, and {@link #commands()} answers with what the caller meant. * - * @param endpoint the tmux executable and its server selection, such as {@code [tmux, -S, path]} - * @param argv the command and its arguments, each already a separate element + *

The timeout rides on the request instead of the transport because the same server serves both + * a listing that should answer immediately and an attach that never returns. + * + * @param endpoint the tmux executable and its server selection, such as {@code [tmux, -S, path]}. + * tmux reads these before it parses commands, so they are passed through untouched + * @param commands one or more tmux commands, each an argv of literal words. tmux runs them in + * order and discards the rest after the first failure * @param timeout how long the caller will wait for the whole invocation - * @param input what the command reads from tmux's standard input, empty for the commands that + * @param input what the commands read from tmux's standard input, empty for the commands that * read none. A tmux argument is bounded by MAX_IMSGSIZE, so text too large to be one * travels here instead. */ -public record CommandRequest(List endpoint, List argv, Duration timeout, String input) { +public record CommandRequest(List endpoint, List> commands, Duration timeout, String input) { public CommandRequest { endpoint = List.copyOf(endpoint); - argv = List.copyOf(argv); + commands = commands.stream().map(List::copyOf).toList(); Objects.requireNonNull(timeout, "timeout"); Objects.requireNonNull(input, "input"); if (endpoint.isEmpty()) { throw new IllegalArgumentException("endpoint has no executable"); } + if (commands.isEmpty() || commands.stream().anyMatch(List::isEmpty)) { + throw new IllegalArgumentException("a command has no words"); + } if (timeout.isZero() || timeout.isNegative()) { throw new IllegalArgumentException("timeout is not positive"); } } - /** A request for a command that reads nothing, which is every command but a buffer load. */ - public CommandRequest(List endpoint, List argv, Duration timeout) { - this(endpoint, argv, timeout, ""); + /** A request for one command, which is every request but a batch. */ + public static CommandRequest of(List endpoint, List argv, Duration timeout) { + return of(endpoint, argv, timeout, ""); + } + + /** A request for one command that reads {@code input} from tmux's standard input. */ + public static CommandRequest of(List endpoint, List argv, Duration timeout, String input) { + return new CommandRequest(endpoint, List.of(argv), timeout, input); } - /** The full argv to hand a process builder. */ + /** The full argv to hand a process builder, encoded for tmux's own argv parser. */ public List commandLine() { - List line = new ArrayList<>(endpoint.size() + argv.size()); + List encoded = CommandStrings.arguments(commands); + List line = new ArrayList<>(endpoint.size() + encoded.size()); line.addAll(endpoint); - line.addAll(argv); + line.addAll(encoded); return Collections.unmodifiableList(line); } /** - * Counts only. argv carries pane content and socket paths, and this value reaches log lines and - * failed assertions. + * Counts only. Commands carry pane content and socket paths, and this value reaches log lines + * and failed assertions. */ @Override public String toString() { - return "CommandRequest[argumentCount=" + argv.size() + ", timeout=" + timeout + "]"; + return "CommandRequest[commandCount=" + commands.size() + ", timeout=" + timeout + "]"; } } diff --git a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java index 83d64ed..70b104b 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java @@ -128,7 +128,7 @@ public CommandResult executeWaiting(CommandRequest request) { private CommandResult execute(CommandRequest request, boolean waiting) { requireOpen(); - requireDispatchable(request.argv()); + requireDispatchable(request.commands()); @Nullable Semaphore waitingPermit = waiting ? waitingAdmission : null; if (waiting && waitingPermit == null) { throw new TmuxTransportException( @@ -256,10 +256,12 @@ private void requireOpen() { } /** POSIX {@code execve} takes NUL-terminated strings, so an embedded NUL cannot survive. */ - private static void requireDispatchable(List argv) { - for (int index = 0; index < argv.size(); index++) { - if (argv.get(index).indexOf('\0') >= 0) { - throw new IllegalArgumentException("embedded null byte in argv element " + index); + private static void requireDispatchable(List> commands) { + for (List argv : commands) { + for (int index = 0; index < argv.size(); index++) { + if (argv.get(index).indexOf('\0') >= 0) { + throw new IllegalArgumentException("embedded null byte in argv element " + index); + } } } } diff --git a/libtmux/src/test/java/io/github/libtmux/HandleTest.java b/libtmux/src/test/java/io/github/libtmux/HandleTest.java index 0706299..925c3db 100644 --- a/libtmux/src/test/java/io/github/libtmux/HandleTest.java +++ b/libtmux/src/test/java/io/github/libtmux/HandleTest.java @@ -250,7 +250,7 @@ private static Server canned(ServerEndpoint endpoint) { private static String last(CountingTransport transport) { List argv = - transport.requests.get(transport.requests.size() - 1).argv(); + transport.requests.get(transport.requests.size() - 1).commands().get(0); return argv.get(argv.size() - 2); } @@ -272,7 +272,7 @@ private static final class CountingTransport implements TmuxTransport { public CommandResult execute(CommandRequest request) { calls.incrementAndGet(); requests.add(request); - String command = request.argv().get(0); + String command = request.commands().get(0).get(0); return new CommandResult(0, rows(command), List.of()); } diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 81590ee..d98e845 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -171,7 +171,7 @@ void aWaitPropagatesTransportFailuresThatAreNotItsDeadline(@TempDir Path directo TmuxTransport transport = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - if (request.argv().contains("wait-for")) { + if (request.commands().get(0).contains("wait-for")) { throw failure; } return new CommandResult(0, List.of("4242"), List.of()); @@ -198,7 +198,7 @@ void aWaitWithSignalCapacityPreservesAPredispatchTimeout(@TempDir Path directory TmuxTransport transport = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - if (request.argv().contains("wait-for")) { + if (request.commands().get(0).contains("wait-for")) { throw failure; } return new CommandResult(1, List.of(), List.of("no server running")); @@ -275,7 +275,7 @@ void snapshotKeepsTheIdentityOfALiveServerWithNoSessions(@TempDir Path directory TmuxTransport transport = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - return switch (request.argv().getFirst()) { + return switch (request.commands().get(0).getFirst()) { case "display-message" -> new CommandResult( 0, List.of(String.join(RowFormat.of("field").separator(), "4242", "3.2a")), List.of()); @@ -464,7 +464,7 @@ private static final class SurvivingTransport implements TmuxTransport { @Override public CommandResult execute(CommandRequest request) { - return request.argv().contains("kill-server") + return request.commands().get(0).contains("kill-server") ? new CommandResult(1, List.of(), List.of("permission denied")) : new CommandResult(0, List.of("4242"), List.of()); } @@ -495,7 +495,7 @@ private record SnapshotTransport(String sessionRow) implements TmuxTransport { @Override public CommandResult execute(CommandRequest request) { - return switch (request.argv().get(0)) { + return switch (request.commands().get(0).get(0)) { case "list-sessions" -> new CommandResult(0, List.of(sessionRow), List.of()); case "display-message" -> new CommandResult( @@ -522,7 +522,7 @@ private static final class SnapshotRaceTransport implements TmuxTransport { @Override public CommandResult execute(CommandRequest request) { - return switch (request.argv().get(0)) { + return switch (request.commands().get(0).get(0)) { case "display-message" -> identity(identities.get(identityReads.getAndIncrement())); case "list-sessions" -> new CommandResult(0, List.of(sessionRows.get(sessionReads.getAndIncrement())), List.of()); @@ -559,7 +559,7 @@ private static final class ReplacementDuringCaptureTransport implements TmuxTran @Override public CommandResult execute(CommandRequest request) { boolean firstCapture = identityReads.get() == 1; - return switch (request.argv().get(0)) { + return switch (request.commands().get(0).get(0)) { case "display-message" -> identityReads.getAndIncrement() == 0 ? identity("4242", failure == CaptureFailure.PANE_SHAPE ? "3.7" : "3.6") diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java index c020329..e6bef70 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java @@ -61,55 +61,15 @@ void aBlockedControlWriteDoesNotOccupyTheCallersCarrier(@TempDir Path directory) } } + /** Quoting carries a semicolon anywhere in an argument, so no escape reaches tmux. */ @Test - void aSemicolonEndingAnArgumentEndsTheCommand() { - assertTrue(ControlClient.isCommandGroup(List.of("kill-window;", "list-windows"))); - assertTrue(ControlClient.isCommandGroup(List.of("list-windows", ";", "list-panes"))); - } - - /** tmux looks at the end of an argument, so a semicolon anywhere else is just a character. */ - @Test - void aSemicolonAnywhereElseIsPartOfTheArgument() { - assertFalse(ControlClient.isCommandGroup(List.of("display-message", "-p", "semi;colon"))); - assertFalse(ControlClient.isCommandGroup(List.of("display-message", "-p", ";leading"))); - assertFalse(ControlClient.isCommandGroup(List.of("display-message", "-p", "plain"))); - } - - @Test - void aBackslashKeepsTheSemicolonInsteadOfEndingTheCommand() { - assertFalse(ControlClient.isCommandGroup(List.of("display-message", "-p", "trailing\\;"))); - } - - @Test - void aRejectedCommandGroupDoesNotDiscloseItsArguments(@TempDir Path directory) throws Exception { - String secret = "pane-secret;"; - ServerConfig config = fakeTmux(directory, """ - printf '%%begin 100 1 0\n%%end 100 1 0\n' - while IFS= read -r request; do :; done - """); - - try (ControlClient client = ControlClient.attach(config, new SessionId("$0"))) { - IllegalArgumentException failure = - assertThrows(IllegalArgumentException.class, () -> client.send(List.of("display-message", secret))); - - assertFalse(String.valueOf(failure.getMessage()).contains(secret)); - } - } - - /** - * The process carrier reaches tmux's argv parser and this one does not, so the backslash that - * parser would consume is consumed here instead. Passing it on would deliver a different - * argument than the other carrier did. - */ - @Test - void theEscapeGuardingATrailingSemicolonIsSpentRatherThanSent() { + void everyArgumentReachesTmuxExactlyAsGiven() { assertEquals( "'display-message' '-p' 'trailing;'", + ControlClient.line(List.of("display-message", "-p", "trailing;"))); + assertEquals( + "'display-message' '-p' 'trailing\\;'", ControlClient.line(List.of("display-message", "-p", "trailing\\;"))); - } - - @Test - void everyOtherArgumentReachesTmuxExactlyAsGiven() { assertEquals( "'display-message' '-p' 'semi;colon'", ControlClient.line(List.of("display-message", "-p", "semi;colon"))); diff --git a/libtmux/src/test/java/io/github/libtmux/transport/CarrierStarvationTest.java b/libtmux/src/test/java/io/github/libtmux/transport/CarrierStarvationTest.java index aea14c6..3f1007f 100644 --- a/libtmux/src/test/java/io/github/libtmux/transport/CarrierStarvationTest.java +++ b/libtmux/src/test/java/io/github/libtmux/transport/CarrierStarvationTest.java @@ -42,7 +42,7 @@ final class CarrierStarvationTest { + FLOOD_BYTES + " /dev/zero | tr '\\0' b >&2; wait"; private static CommandRequest flood() { - return new CommandRequest(List.of("/bin/sh"), List.of("-c", FLOOD), DEADLINE); + return CommandRequest.of(List.of("/bin/sh"), List.of("-c", FLOOD), DEADLINE); } @Test diff --git a/libtmux/src/test/java/io/github/libtmux/transport/CommandRequestTest.java b/libtmux/src/test/java/io/github/libtmux/transport/CommandRequestTest.java index 9601b62..f56db65 100644 --- a/libtmux/src/test/java/io/github/libtmux/transport/CommandRequestTest.java +++ b/libtmux/src/test/java/io/github/libtmux/transport/CommandRequestTest.java @@ -17,14 +17,14 @@ final class CommandRequestTest { @Test void theCommandLineIsTheEndpointFollowedByTheArguments() { - CommandRequest request = new CommandRequest(ENDPOINT, List.of("list-panes", "-a"), SECOND); + CommandRequest request = CommandRequest.of(ENDPOINT, List.of("list-panes", "-a"), SECOND); assertEquals(List.of("tmux", "-S", "/run/user/1000/tmux/default", "list-panes", "-a"), request.commandLine()); } @Test void argumentsStaySeparateElementsSoNothingIsEverShellParsed() { - CommandRequest request = new CommandRequest(ENDPOINT, List.of("send-keys", "echo one; echo two"), SECOND); + CommandRequest request = CommandRequest.of(ENDPOINT, List.of("send-keys", "echo one; echo two"), SECOND); assertEquals( "echo one; echo two", @@ -32,29 +32,55 @@ void argumentsStaySeparateElementsSoNothingIsEverShellParsed() { "a semicolon inside one element must not become a command separator"); } + /** tmux would otherwise take the semicolon as the end of the command and drop it from the value. */ + @Test + void anArgumentEndingInASemicolonIsEscapedForTmuxsArgvParser() { + CommandRequest request = CommandRequest.of(ENDPOINT, List.of("rename-window", "build;"), SECOND); + + assertEquals(List.of("rename-window", "build\\;"), request.commandLine().subList(3, 5)); + assertEquals(List.of("rename-window", "build;"), request.commands().get(0), "the request keeps what was meant"); + } + + @Test + void severalCommandsAreSeparatedByABareSemicolon() { + CommandRequest request = new CommandRequest( + ENDPOINT, List.of(List.of("kill-window", "-t", "@1"), List.of("list-windows")), SECOND, ""); + + assertEquals( + List.of("kill-window", "-t", "@1", ";", "list-windows"), + request.commandLine().subList(3, 8)); + } + @Test void mutatingTheListsAfterConstructionCannotChangeTheRequest() { List endpoint = new ArrayList<>(List.of("tmux")); List argv = new ArrayList<>(List.of("list-panes")); - CommandRequest request = new CommandRequest(endpoint, argv, SECOND); + CommandRequest request = CommandRequest.of(endpoint, argv, SECOND); endpoint.add("-S"); argv.add("-a"); assertEquals(List.of("tmux"), request.endpoint()); - assertEquals(List.of("list-panes"), request.argv()); + assertEquals(List.of(List.of("list-panes")), request.commands()); } @Test void anEndpointWithoutAnExecutableIsRejected() { - assertThrows(IllegalArgumentException.class, () -> new CommandRequest(List.of(), List.of("ls"), SECOND)); + assertThrows(IllegalArgumentException.class, () -> CommandRequest.of(List.of(), List.of("ls"), SECOND)); + } + + @Test + void aRequestWithNothingToRunIsRejected() { + assertThrows(IllegalArgumentException.class, () -> CommandRequest.of(ENDPOINT, List.of(), SECOND)); + assertThrows(IllegalArgumentException.class, () -> new CommandRequest(ENDPOINT, List.of(), SECOND, "")); } @Test void aTimeoutThatCannotElapseIsRejected() { - assertThrows(IllegalArgumentException.class, () -> new CommandRequest(ENDPOINT, List.of(), Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> CommandRequest.of(ENDPOINT, List.of("ls"), Duration.ZERO)); assertThrows( - IllegalArgumentException.class, () -> new CommandRequest(ENDPOINT, List.of(), Duration.ofSeconds(-1))); + IllegalArgumentException.class, + () -> CommandRequest.of(ENDPOINT, List.of("ls"), Duration.ofSeconds(-1))); } /** @@ -65,26 +91,26 @@ void aTimeoutThatCannotElapseIsRejected() { @Test @SuppressWarnings("NullAway") void nullsAreProgrammerErrorsNotTmuxFailures() { - assertThrows(NullPointerException.class, () -> new CommandRequest(ENDPOINT, List.of(), null)); - assertThrows(NullPointerException.class, () -> new CommandRequest(null, List.of(), SECOND)); - assertThrows(NullPointerException.class, () -> new CommandRequest(ENDPOINT, null, SECOND)); + assertThrows(NullPointerException.class, () -> CommandRequest.of(ENDPOINT, List.of("ls"), null)); + assertThrows(NullPointerException.class, () -> CommandRequest.of(null, List.of("ls"), SECOND)); + assertThrows(NullPointerException.class, () -> CommandRequest.of(ENDPOINT, null, SECOND)); } /** - * argv carries pane content and socket paths, and this value reaches logs and failed + * A command carries pane content and socket paths, and this value reaches logs and failed * assertions, so its rendering exposes counts only. */ @Test void toStringExposesNeitherSocketPathsNorPaneContent() { CommandRequest request = - new CommandRequest(ENDPOINT, List.of("send-keys", "-t", "%1", "export TOKEN=hunter2"), SECOND); + CommandRequest.of(ENDPOINT, List.of("send-keys", "-t", "%1", "export TOKEN=hunter2"), SECOND); String rendered = request.toString(); assertFalse(rendered.contains("hunter2"), "pane content must not reach a log line: " + rendered); assertFalse(rendered.contains("/run/user"), "a socket path must not reach a log line: " + rendered); assertEquals( - "CommandRequest[argumentCount=4, timeout=PT1S]", + "CommandRequest[commandCount=1, timeout=PT1S]", rendered, "counts and the timeout are the whole diagnostic"); } diff --git a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java index f67e5ae..8a80f68 100644 --- a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java +++ b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java @@ -48,11 +48,11 @@ final class ProcessTransportTest { private static final int FLOOD_BYTES = 262_144; private static CommandRequest shell(String script, Duration timeout) { - return new CommandRequest(List.of("/bin/sh"), List.of("-c", script), timeout); + return CommandRequest.of(List.of("/bin/sh"), List.of("-c", script), timeout); } private static CommandRequest bash(String script, Duration timeout) { - return new CommandRequest(List.of("/bin/bash"), List.of("-c", script), timeout); + return CommandRequest.of(List.of("/bin/bash"), List.of("-c", script), timeout); } // ------------------------------------------------------------------ channels and exit status @@ -109,7 +109,7 @@ void decodingHoldsThroughARealChildNotJustTheDecoder() { void aSemicolonInsideOneArgumentIsNeverASeparator() { try (ProcessTransport transport = new ProcessTransport()) { CommandResult result = - transport.execute(new CommandRequest(List.of("/bin/echo"), List.of("left;right"), GENEROUS)); + transport.execute(CommandRequest.of(List.of("/bin/echo"), List.of("left;right"), GENEROUS)); assertEquals(List.of("left;right"), result.stdout()); } @@ -140,7 +140,7 @@ void anExecutableThatDoesNotExistIsNotDispatched() { try (ProcessTransport transport = new ProcessTransport()) { TmuxTransportException failure = assertThrows( TmuxTransportException.class, - () -> transport.execute(new CommandRequest(List.of("/nonexistent/tmux"), List.of("ls"), GENEROUS))); + () -> transport.execute(CommandRequest.of(List.of("/nonexistent/tmux"), List.of("ls"), GENEROUS))); assertEquals( DispatchOutcome.NOT_DISPATCHED, failure.outcome(), "nothing ran, so the caller may retry freely"); @@ -244,7 +244,7 @@ void outputOverflowReclaimsDescendantsBeforeTheRootCanDisappear(@TempDir Path di + "while [ ! -f \"$1\" ]; do :; done; " + "while :; do printf 1234567890; done"; long descendant = -1; - CommandRequest request = new CommandRequest( + CommandRequest request = CommandRequest.of( List.of("/bin/bash"), List.of("-c", script, "probe", descendantPid.toString()), GENEROUS); try (ProcessTransport transport = new ProcessTransport(1, 1_024)) { @@ -268,7 +268,7 @@ void cleanupDoesNotAdoptADescendantSpawnedAfterItsOwnershipSnapshot(@TempDir Pat + "mv \"$1.tmp\" \"$1\"; exec sleep 30) /dev/null 2>&1 & " + "while :; do :; done' TERM; " + "while :; do sleep 30; done"; - CommandRequest request = new CommandRequest( + CommandRequest request = CommandRequest.of( List.of("/bin/bash"), List.of("-c", script, "probe", descendantPid.toString()), Duration.ofMillis(250)); long descendant = -1; @@ -448,7 +448,7 @@ void closeIsBoundedWhenADescendantInheritsTheChildPipes(@TempDir Path directory) String script = "trap 'exit 0' TERM; " + "(trap '' HUP TERM; echo \"$BASHPID\" > \"$1.tmp\"; " + "mv \"$1.tmp\" \"$1\"; exec sleep 30) & wait"; - CommandRequest request = new CommandRequest( + CommandRequest request = CommandRequest.of( List.of("/bin/bash"), List.of("-c", script, "probe", descendantPid.toString()), GENEROUS); ProcessTransport transport = new ProcessTransport(); @@ -514,7 +514,7 @@ void admissionTimeoutIsTypedAndKnownNotDispatched(@TempDir Path directory) throw ProcessTransport transport = new ProcessTransport(1); try { Path started = directory.resolve("started"); - CommandRequest occupying = new CommandRequest( + CommandRequest occupying = CommandRequest.of( List.of("/bin/sh"), List.of("-c", "touch \"$1\"; while :; do :; done", "probe", started.toString()), GENEROUS); From 7526684d0d5b0cef0ab83bbf7002c8441c8122fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:50:18 -0500 Subject: [PATCH 41/77] Snapshot(fix[framing]): Read a row spanning lines why: tmux frames a listing row with a newline and nothing else, so a value carrying one arrives as several lines. pane_current_path is such a value: a directory name may contain a newline and anything running in a pane may change into it. Server.rows parsed one line as one row, so the extra line failed the field count, hydration threw, and the lenient accessors returned an empty list. A server with sessions read as a server with none, silently. what: - RowFormat.rows reads a whole listing, closing a row when it carries every separator rather than at the line ending - Raise when a listing ends mid-row instead of dropping the partial row - Server.rows delegates to it - Cover a session started in a directory whose name holds a newline --- .../libtmux/it/RowFramingIntegrationTest.java | 20 ++++++++ .../main/java/io/github/libtmux/Server.java | 2 +- .../io/github/libtmux/format/RowFormat.java | 46 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/RowFramingIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/RowFramingIntegrationTest.java index 2b08ab0..6a10794 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/RowFramingIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/RowFramingIntegrationTest.java @@ -3,13 +3,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import io.github.libtmux.Pane; import io.github.libtmux.Server; +import io.github.libtmux.Session; import io.github.libtmux.format.RowFormat; import io.github.libtmux.format.TmuxFormatException; import io.github.libtmux.junit5.TmuxExtension; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; /** * The framing decision, measured against real tmux rather than against a string in a unit test. @@ -49,6 +54,21 @@ void theFixedSeparatorItReplacedIsBrokenByTheSameName(Server server) { assertEquals(4, row.split(separator, -1).length, "a fixed separator yields one field too many"); } + /** + * A working directory may contain a newline, and anything running in a pane may {@code cd} into + * one, so tmux splitting a listing into lines is not the same as splitting it into rows. + */ + @Test + void aValueCarryingANewlineIsOneRowRatherThanTwo(Server server, @TempDir Path directory) throws Exception { + Path awkward = Files.createDirectory(directory.resolve("dir\nwithnl")); + Session session = server.newSession(spec -> spec.named("awkward").in(awkward)); + + Pane pane = session.activePane().orElseThrow(); + + assertEquals(awkward, pane.currentPath()); + assertEquals(2, server.sessions().size(), "a listing must not come back empty because of it"); + } + @Test void aRowThatShiftedIsRejectedRatherThanParsed(Server server) { RowFormat wider = RowFormat.of("session_id", "window_id", "window_name", "window_index"); diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 30e1f0a..d1238b4 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -899,7 +899,7 @@ private List> rows(RowFormat format, String... command) { } throw new LibTmuxException("tmux " + command[0] + " failed: " + String.join("; ", result.stderr())); } - return result.stdout().stream().map(format::split).toList(); + return format.rows(result.stdout()); } /** A builder holding every configuration and ownership choice this server made. */ diff --git a/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java b/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java index 921bd0f..d3611e1 100644 --- a/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java +++ b/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java @@ -1,5 +1,6 @@ package io.github.libtmux.format; +import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; @@ -57,6 +58,51 @@ public int size() { return fields.size(); } + /** + * Reads a whole listing back into rows. + * + *

tmux ends a row with a newline and frames it no other way, so a listing's lines are not its + * rows: a value carrying a newline arrives as several. A working directory may contain one, and + * anything running in a pane may change into it, so this is caller-reachable rather than + * theoretical. A row is closed by carrying every separator, not by the line ending. + * + * @param lines the listing as tmux printed it + * @throws TmuxFormatException if the listing ends mid-row, or a row does not have exactly the + * expected number of fields + */ + public List> rows(List lines) { + List> rows = new ArrayList<>(); + StringBuilder pending = new StringBuilder(); + int separators = 0; + boolean open = false; + for (String line : lines) { + if (open) { + pending.append('\n'); + } + pending.append(line); + open = true; + separators += occurrences(line); + if (separators >= fields.size() - 1) { + rows.add(split(pending.toString())); + pending.setLength(0); + separators = 0; + open = false; + } + } + if (open) { + throw new TmuxFormatException("a tmux listing ended before its last row did"); + } + return List.copyOf(rows); + } + + private static int occurrences(String line) { + int count = 0; + for (int at = line.indexOf(SEPARATOR); at >= 0; at = line.indexOf(SEPARATOR, at + SEPARATOR.length())) { + count++; + } + return count; + } + /** * Reads one row back into its fields. * From bb457e363158c3b81065601c31be08494b4680cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:56:29 -0500 Subject: [PATCH 42/77] Options(fix[listing]): Report the stored value why: all() and effective() read the listing, which escapes a string option with vis(3) and wraps it in whichever quotes the release chose. The reader handled one wrapper and two escapes, so a backslash, a newline, a tab, a '$', the empty string, '~', a lone '"' and every control byte came back wrong, and disagreed with get(). Decoding it is not a fixed target either: a$b prints as "a\$b" on 3.2a and 3.7b but "a\\$b" on 3.4. what: - Take names from the listing and values from show-options -v, which returns the stored value exactly on every supported release - Read them in one further invocation, in groups under a budget, since tmux refuses a command over about 16300 bytes and a scope can exceed it - Add Batch.length() so a caller sizes a group rather than estimating, and Server.batch(snapshot) so a handle's reads stay fenced - get() joins a value that spans lines instead of taking the first - Raise when tmux answers for fewer options than were asked --- .../libtmux/it/OptionsIntegrationTest.java | 59 ++++++++++++++++-- .../main/java/io/github/libtmux/Options.java | 62 ++++++++++++++----- .../main/java/io/github/libtmux/Server.java | 5 ++ .../java/io/github/libtmux/batch/Batch.java | 13 ++++ 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OptionsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OptionsIntegrationTest.java index 944e190..92dc43c 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OptionsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OptionsIntegrationTest.java @@ -10,11 +10,15 @@ import io.github.libtmux.Session; import io.github.libtmux.Window; import io.github.libtmux.junit5.TmuxExtension; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; /** * Options and hooks, at each of the scopes tmux actually keeps them. @@ -137,12 +141,59 @@ void listingAScopeReturnsWhatTmuxPrinted(Server server) { "an array option keeps the subscript that addresses it"); } + /** + * tmux escapes a listed value with {@code vis(3)} and changes its mind about which characters + * that reaches across the supported range, so a listing reports the value itself rather than + * whatever spelling this release chose for it. + */ + @Test + void aListedValueIsTheValueRatherThanTmuxsSpellingOfIt(Server server) { + Map written = new LinkedHashMap<>(); + written.put("@spaces", "[#S] and a space"); + written.put("@backslash", "back\\slash"); + written.put("@newline", "first\nsecond"); + written.put("@tab", "a\tb"); + written.put("@dquote", "has \"quotes\""); + written.put("@empty", ""); + written.put("@tilde", "~"); + + written.forEach(server.globalOptions()::set); + + Map listed = server.globalOptions().all(); + written.forEach((name, value) -> { + assertEquals(Optional.of(value), server.globalOptions().get(name), name); + assertEquals(value, listed.get(name), name + " listed"); + }); + } + + /** + * tmux packs a command into 16384 bytes and refuses a longer one, so a scope with enough + * options cannot be read in one, and a listing that quietly stopped early would be worse than + * one that failed. + */ @Test - void aValueWithSpacesSurvivesTheRoundTrip(Server server) { - server.globalOptions().set("status-left", "[#S] and a space"); + void aScopeWithMoreOptionsThanOneCommandCanCarryIsStillListedWhole(Server server, @TempDir Path directory) + throws Exception { + // A handle's scope is the tighter case: its batch travels inside the staleness guard. + Session session = server.sessions().get(0); + StringBuilder script = new StringBuilder(); + for (int index = 0; index < 400; index++) { + script.append("set-option -t ") + .append(session.id().value()) + .append(" @filler") + .append(index) + .append(" value") + .append(index) + .append('\n'); + } + Path config = directory.resolve("options.conf"); + Files.writeString(config, script); + server.sourceFile(config); + + Map all = session.options().all(); - assertEquals(Optional.of("[#S] and a space"), server.globalOptions().get("status-left")); - assertEquals("[#S] and a space", server.globalOptions().all().get("status-left"), "tmux quotes it, we do not"); + assertEquals("value399", all.get("@filler399")); + assertEquals("value0", all.get("@filler0")); } @Test diff --git a/libtmux/src/main/java/io/github/libtmux/Options.java b/libtmux/src/main/java/io/github/libtmux/Options.java index c70dc14..0cd20f1 100644 --- a/libtmux/src/main/java/io/github/libtmux/Options.java +++ b/libtmux/src/main/java/io/github/libtmux/Options.java @@ -1,5 +1,8 @@ package io.github.libtmux; +import io.github.libtmux.batch.Batch; +import io.github.libtmux.batch.OperationOutcome; +import io.github.libtmux.batch.OperationResult; import io.github.libtmux.snapshot.ServerSnapshot; import io.github.libtmux.transport.CommandResult; import java.util.ArrayList; @@ -22,6 +25,9 @@ */ public final class Options { + /** Under the ceiling {@link Batch#length()} describes, with room for the guard around it. */ + private static final int GROUP_BUDGET = 15_000; + private final Server server; private final @Nullable ServerSnapshot snapshot; private final List scope; @@ -60,14 +66,15 @@ static Options pane(Server server, ServerSnapshot snapshot, PaneId pane) { * here. * * @return empty only when tmux does not know the option, which it reports as an error; an option - * genuinely set to the empty string comes back as an empty value, not as absent + * genuinely set to the empty string comes back as an empty value, not as absent. A value + * spanning several lines comes back whole */ public Optional get(String name) { var result = cmd(argv("show-options", List.of("-A", "-v", name))); if (!result.succeeded()) { return Optional.empty(); } - return Optional.of(result.stdout().isEmpty() ? "" : result.stdout().get(0)); + return Optional.of(String.join("\n", result.stdout())); } /** Every option set at this scope, in tmux's order. Inherited values are not listed. */ @@ -75,20 +82,49 @@ public Map all() { return read(List.of()); } + /** + * Names from the listing, values from {@code -v}, in one further invocation. + * + *

A listed value is escaped with {@code vis(3)} and wrapped in whichever quotes that release + * chose, and which characters it reaches changed inside the supported range — {@code a$b} prints + * as {@code "a\$b"} on 3.2a and {@code "a\\$b"} on 3.4. {@code -v} prints the value itself on + * every release, which is also what {@link #get} reads, so the two agree. + */ private Map read(List flags) { - Map options = new LinkedHashMap<>(); + List names = new ArrayList<>(); for (String line : run(argv("show-options", flags)).stdout()) { int split = line.indexOf(' '); - if (split < 0) { - // A flag option prints its name alone when set and nothing when unset. - options.put(inherited(line), ""); - } else { - options.put(inherited(line.substring(0, split)), unquote(line.substring(split + 1))); - } + names.add(inherited(split < 0 ? line : line.substring(0, split))); + } + if (names.isEmpty()) { + return Map.of(); + } + Map options = new LinkedHashMap<>(); + for (int from = 0; from < names.size(); ) { + int to = from; + // -q so an option unset between the two requests reads as empty rather than ending the batch. + Batch batch = snapshot == null ? server.batch() : server.batch(snapshot); + do { + batch.add(argv("show-options", List.of("-q", "-v", names.get(to++)))); + } while (to < names.size() && batch.length() < GROUP_BUDGET); + record(names.subList(from, to), batch, options); + from = to; } return Collections.unmodifiableMap(options); } + private static void record(List names, Batch batch, Map into) { + List read = batch.run().operations(); + for (int index = 0; index < names.size(); index++) { + OperationResult value = read.get(index); + if (value.outcome() != OperationOutcome.COMPLETE) { + throw new LibTmuxException( + "tmux could not read option " + names.get(index) + ": " + String.join("; ", value.stderr())); + } + into.put(names.get(index), String.join("\n", value.stdout())); + } + } + /** * Drops the marker tmux puts on an option a wide listing found on a parent scope. * @@ -167,12 +203,4 @@ private List argv(String command, List tail) { argv.addAll(tail); return argv; } - - /** tmux quotes a value that contains spaces or specials; a caller wants the value itself. */ - private static String unquote(String value) { - if (value.length() < 2 || value.charAt(0) != '"' || value.charAt(value.length() - 1) != '"') { - return value; - } - return value.substring(1, value.length() - 1).replace("\\\"", "\"").replace("\\\\", "\\"); - } } diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index d1238b4..a710bc5 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -796,6 +796,11 @@ CommandResult cmd(ServerSnapshot snapshot, List argv) { return guarded(snapshot, CommandStrings.stringify(argv)); } + /** A batch whose every command is refused once this handle's server has been replaced. */ + Batch batch(ServerSnapshot snapshot) { + return new Batch(commands -> guarded(snapshot, CommandStrings.group(commands))); + } + CommandResult run(ServerSnapshot snapshot, List argv) { CommandResult result = cmd(snapshot, argv); if (!result.succeeded()) { diff --git a/libtmux/src/main/java/io/github/libtmux/batch/Batch.java b/libtmux/src/main/java/io/github/libtmux/batch/Batch.java index 81f00cd..db381a7 100644 --- a/libtmux/src/main/java/io/github/libtmux/batch/Batch.java +++ b/libtmux/src/main/java/io/github/libtmux/batch/Batch.java @@ -1,6 +1,7 @@ package io.github.libtmux.batch; import io.github.libtmux.format.Tokens; +import io.github.libtmux.internal.CommandStrings; import io.github.libtmux.transport.CommandResult; import java.util.ArrayList; import java.util.List; @@ -52,6 +53,18 @@ public int size() { return operations.size(); } + /** + * How many bytes the collected operations come to as the one command tmux parses. + * + *

tmux packs a command into MAX_IMSGSIZE, 16384 bytes, and refuses a longer one with + * {@code command too long}; measured, it takes about 16300 of them. A batch taken from a handle + * travels as this one string plus the guard that fences it, so it costs this and a little more. + * One dispatched as separate arguments costs less, since nothing there is quoted. + */ + public int length() { + return CommandStrings.group(assemble()).length(); + } + /** * Runs every collected operation in one tmux invocation. * From 64b74d3762ecaa31deee615fbece2b62a6ae4ffb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:59:32 -0500 Subject: [PATCH 43/77] Format(fix[expand]): Return a multi-line expansion why: All four expand methods took the first line of what tmux printed and discarded the rest, so a format expanding to several lines reported a fragment as the whole answer. A loop over windows expands that way, and so does any option whose value carries a newline. what: - Server, Session, Window and Pane join the lines tmux printed - Say so on @return, since the type cannot - Cover it at server and session scope --- .../libtmux/it/ServerScriptingIntegrationTest.java | 12 ++++++++++++ libtmux/src/main/java/io/github/libtmux/Pane.java | 5 +++-- libtmux/src/main/java/io/github/libtmux/Server.java | 5 +++-- libtmux/src/main/java/io/github/libtmux/Session.java | 5 +++-- libtmux/src/main/java/io/github/libtmux/Window.java | 5 +++-- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ServerScriptingIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ServerScriptingIntegrationTest.java index a086013..b24c74a 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ServerScriptingIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ServerScriptingIntegrationTest.java @@ -36,6 +36,18 @@ private static boolean losesShellOutput(Server server) { // -------------------------------------------------------------------------------- expanding + /** + * A format may expand to several lines — a loop over windows does, and so does any option whose + * value carries a newline — so taking the first would report a fragment as the whole answer. + */ + @Test + void anExpansionSpanningLinesComesBackWhole(Server server) { + server.globalOptions().set("@multi", "first\nsecond"); + + assertEquals("first\nsecond", server.expand("#{@multi}")); + assertEquals("first\nsecond", server.sessions().get(0).expand("#{@multi}"), "every scope answers the same way"); + } + @Test void theServerExpandsFormatsThatBelongToNoSession(Server server) { assertEquals(server.version().toString(), server.expand("#{version}")); diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 6c7d2c4..c1b622b 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -340,7 +340,8 @@ private String currentCommandNow() { * } * * @param format a tmux format, usually of the shape {@code #{name}} - * @return the expansion, empty when the format expanded to nothing + * @return the expansion, whole when it spans lines and empty when the format expanded to + * nothing */ public String expand(String format) { Objects.requireNonNull(format, "format"); @@ -348,7 +349,7 @@ public String expand(String format) { snapshot, List.of("display-message", "-p", "-t", state.id().value(), format)) .stdout(); - return reported.isEmpty() ? "" : reported.get(0); + return String.join("\n", reported); } /** diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index a710bc5..2104f65 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -243,12 +243,13 @@ public CommandChain chain() { * {@code #{version}} that belong to no session in particular. * * @param format a tmux format, usually of the shape {@code #{name}} - * @return the expansion, empty when the format expanded to nothing + * @return the expansion, whole when it spans lines and empty when the format expanded to + * nothing */ public String expand(String format) { Objects.requireNonNull(format, "format"); List reported = run(List.of("display-message", "-p", format)).stdout(); - return reported.isEmpty() ? "" : reported.get(0); + return String.join("\n", reported); } /** diff --git a/libtmux/src/main/java/io/github/libtmux/Session.java b/libtmux/src/main/java/io/github/libtmux/Session.java index a401e41..95a7eea 100644 --- a/libtmux/src/main/java/io/github/libtmux/Session.java +++ b/libtmux/src/main/java/io/github/libtmux/Session.java @@ -192,7 +192,8 @@ public Window newWindow(WindowSpec spec) { *

The same escape hatch {@link Pane#expand} gives, resolved against this session. * * @param format a tmux format, usually of the shape {@code #{name}} - * @return the expansion, empty when the format expanded to nothing + * @return the expansion, whole when it spans lines and empty when the format expanded to + * nothing */ public String expand(String format) { Objects.requireNonNull(format, "format"); @@ -200,7 +201,7 @@ public String expand(String format) { snapshot, List.of("display-message", "-p", "-t", state.id().value(), format)) .stdout(); - return reported.isEmpty() ? "" : reported.get(0); + return String.join("\n", reported); } /** Renames this session and returns a handle on it as it is now. */ diff --git a/libtmux/src/main/java/io/github/libtmux/Window.java b/libtmux/src/main/java/io/github/libtmux/Window.java index 23d1ead..f646289 100644 --- a/libtmux/src/main/java/io/github/libtmux/Window.java +++ b/libtmux/src/main/java/io/github/libtmux/Window.java @@ -154,14 +154,15 @@ public Pane split(SplitSpec spec) { *

The same escape hatch {@link Pane#expand} gives, resolved against this window. * * @param format a tmux format, usually of the shape {@code #{name}} - * @return the expansion, empty when the format expanded to nothing + * @return the expansion, whole when it spans lines and empty when the format expanded to + * nothing */ public String expand(String format) { Objects.requireNonNull(format, "format"); List reported = server.run( snapshot, state.context(), List.of("display-message", "-p", "-t", linkTarget(), format)) .stdout(); - return reported.isEmpty() ? "" : reported.get(0); + return String.join("\n", reported); } /** From d51df2be43b1648c5bd8a785fae42243903c3786 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:00:45 -0500 Subject: [PATCH 44/77] MCP(refactor[socket]): Use the core expansion why: Both socket lookups rebuilt display-message by hand and then took the first line, which is the truncation Server.expand no longer has. A socket path is a filename and may carry a newline. what: - Listings and Caller call Server.expand --- libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java | 6 ++---- .../src/main/java/io/github/libtmux/mcp/Listings.java | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java index ec16f63..e4ffec2 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java @@ -5,7 +5,6 @@ import io.github.libtmux.TmuxEnvironment; import java.io.IOException; import java.nio.file.Path; -import java.util.List; import java.util.Map; import java.util.Optional; import org.jspecify.annotations.Nullable; @@ -67,9 +66,8 @@ boolean isSelf(PaneId target) { /** tmux is asked which socket it is on, rather than the endpoint being reassembled from flags. */ private static @Nullable Path socketOf(Server server) { try { - List reported = - server.cmd("display-message", "-p", "#{socket_path}").stdout(); - return reported.isEmpty() ? null : Path.of(reported.get(0)); + String reported = server.expand("#{socket_path}"); + return reported.isEmpty() ? null : Path.of(reported); } catch (RuntimeException e) { return null; } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java index 2381cde..f7a6720 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Listings.java @@ -314,9 +314,8 @@ private static Whoami absent(Server server, Safety ceiling) { private static @Nullable String socketOf(Server server) { try { - List reported = - server.cmd("display-message", "-p", "#{socket_path}").stdout(); - return reported.isEmpty() ? null : reported.get(0); + String reported = server.expand("#{socket_path}"); + return reported.isEmpty() ? null : reported; } catch (RuntimeException e) { return null; } From f6870f2b2362713677052a26d2425907edfdb9fd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:02:43 -0500 Subject: [PATCH 45/77] MCP(refactor[naming]): Name the pane reader Screen why: Watching read the part of a pane a caller had not seen, and sat beside Watches, which keeps MCP resource subscriptions, and WatchAttachment, which owns the control client one is watched through. Three names for two unrelated responsibilities, told apart only by a suffix. what: - Rename Watching to Screen, which is what it reads --- .../src/main/java/io/github/libtmux/mcp/Reading.java | 8 ++++---- .../src/main/java/io/github/libtmux/mcp/Resources.java | 2 +- .../main/java/io/github/libtmux/mcp/RunningCommands.java | 5 ++--- .../io/github/libtmux/mcp/{Watching.java => Screen.java} | 4 ++-- .../main/java/io/github/libtmux/mcp/WaitingForText.java | 4 ++-- 5 files changed, 11 insertions(+), 12 deletions(-) rename libtmux-mcp/src/main/java/io/github/libtmux/mcp/{Watching.java => Screen.java} (99%) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java index df4e0bc..6d003bd 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Reading.java @@ -49,7 +49,7 @@ record Found( /** What a pane shows now, newest last, with a cursor for watching it from here. */ static Captured capture(Call call) { Pane pane = Targets.pane(call.server(), call.string("pane_id")); - Watching.Fresh look = Watching.everything(pane, call.flag("history", false)); + Screen.Fresh look = Screen.everything(pane, call.flag("history", false)); Trim.Trimmed trimmed = Trim.tail(look.lines(), Trim.lineBudget(call)); return new Captured( pane.id().value(), @@ -73,7 +73,7 @@ static Captured capture(Call call) { static Since since(Call call) { Pane pane = Targets.pane(call.server(), call.string("pane_id")); Cursor from = call.maybe("cursor").map(Cursor::decode).orElse(null); - Watching.Fresh fresh = Watching.since(pane, from, Trim.lineBudget(call)); + Screen.Fresh fresh = Screen.since(pane, from, Trim.lineBudget(call)); Trim.Trimmed trimmed = Trim.tail(fresh.lines(), Trim.lineBudget(call)); return new Since( pane.id().value(), @@ -86,7 +86,7 @@ static Since since(Call call) { note(fresh, trimmed, from)); } - private static @Nullable String note(Watching.Fresh fresh, Trim.Trimmed trimmed, @Nullable Cursor from) { + private static @Nullable String note(Screen.Fresh fresh, Trim.Trimmed trimmed, @Nullable Cursor from) { if (!fresh.continuous()) { return "The lines already delivered are no longer where the cursor left them: the pane was " + "cleared, or its output has outrun the history tmux keeps. What is here is what the " @@ -119,7 +119,7 @@ static Found search(Call call) { List hits = new ArrayList<>(); for (Pane pane : panes) { int kept = 0; - for (String line : Watching.withoutTrailingBlanks(pane.capture())) { + for (String line : Screen.withoutTrailingBlanks(pane.capture())) { if (kept >= perPane) { break; } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java index 97edb3e..759c04a 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Resources.java @@ -102,7 +102,7 @@ static List templated(Conne TEXT_MIME, values -> { Pane pane = Targets.pane(connection.server(), values.get(0)); - return String.join("\n", Watching.withoutTrailingBlanks(pane.capture())); + return String.join("\n", Screen.withoutTrailingBlanks(pane.capture())); })); } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 64b6513..7310d48 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -75,7 +75,7 @@ static Ran run(Call call) { String endMark = nonce + "-e"; String channel = "ch_" + nonce; - Cursor before = Watching.from(pane).cursor(); + Cursor before = Screen.from(pane).cursor(); String typed = payload(server, command, nonce, startMark, endMark, channel, suppressHistory); // Never make the shell wait for Java cleanup: a transport can report UNKNOWN after tmux // accepted this line, and that failure must not strand the pane at private plumbing. @@ -85,8 +85,7 @@ static Ran run(Call call) { WakeReason wake = server.waitFor(channel, timeout); double seconds = (System.nanoTime() - started) / 1_000_000_000.0; - Watching.Fresh fresh = - wake == WakeReason.SERVER_GONE ? null : Watching.since(pane, before, Trim.lineBudget(call)); + Screen.Fresh fresh = wake == WakeReason.SERVER_GONE ? null : Screen.since(pane, before, Trim.lineBudget(call)); Framed framed = fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watching.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java similarity index 99% rename from libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watching.java rename to libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java index c5a561e..168ef47 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Watching.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java @@ -22,7 +22,7 @@ * of 60 attempts; batched into one invocation, none of 60 did, because tmux does not process pane * output between two commands of the same invocation. */ -final class Watching { +final class Screen { /** * How far back a look reaches beyond what the caller asked for. @@ -34,7 +34,7 @@ final class Watching { */ private static final int SLACK_LINES = 256; - private Watching() {} + private Screen() {} /** * @param lines what the caller has not seen diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java index d6d7aac..3a4ff2d 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/WaitingForText.java @@ -55,7 +55,7 @@ static Waited waitFor(Call call) { int budget = Trim.lineBudget(call); Cursor cursor = call.maybe("cursor") .map(Cursor::decode) - .orElseGet(() -> Watching.from(pane).cursor()); + .orElseGet(() -> Screen.from(pane).cursor()); Trim.Trimmed retained = new Trim.Trimmed(List.of(), 0); long started = System.nanoTime(); long deadline = started + timeout.toNanos(); @@ -65,7 +65,7 @@ static Waited waitFor(Call call) { String hitLine = null; while (true) { - Watching.Fresh fresh = Watching.since(pane, cursor, budget); + Screen.Fresh fresh = Screen.since(pane, cursor, budget); cursor = fresh.cursor(); retained = Trim.append(retained, fresh.lines(), budget); From f9d757fb1bf1329c90d126c6c191c9c757c362af Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:26:15 -0500 Subject: [PATCH 46/77] Snapshot(refactor[capture]): Extract the capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Server held the tmux command surface and the reading of a server into a snapshot, and the second is what changes when tmux adds a field. Its fields were addressed positionally — row.get(16) indexed an array declared in another method, which the two pane formats already made fragile. what: - SnapshotCapture takes the row formats, the identity probe, the four listings and the field parsing; Server.snapshot keeps the retry, which is its contract rather than the capture's - RowFormat.Row addresses a field by name and parses tmux's numbers and its 0/1 flags once, in the format layer - A malformed field raises TmuxFormatException: tmux printing something unexpected is not caller error - Say in the control package why it is not a TmuxTransport, since the reasons are a design decision rather than an omission No public signature changed and no test changed. --- .../main/java/io/github/libtmux/Server.java | 214 +---------------- .../io/github/libtmux/SnapshotCapture.java | 225 ++++++++++++++++++ .../github/libtmux/control/package-info.java | 8 + .../io/github/libtmux/format/RowFormat.java | 70 +++++- .../libtmux/format/TmuxFormatException.java | 4 + .../github/libtmux/format/package-info.java | 4 +- 6 files changed, 313 insertions(+), 212 deletions(-) create mode 100644 libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 2104f65..86bfc7f 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -1,14 +1,9 @@ package io.github.libtmux; import io.github.libtmux.batch.Batch; -import io.github.libtmux.format.RowFormat; import io.github.libtmux.internal.CommandStrings; -import io.github.libtmux.snapshot.ClientState; -import io.github.libtmux.snapshot.PaneState; import io.github.libtmux.snapshot.ServerSnapshot; -import io.github.libtmux.snapshot.SessionState; import io.github.libtmux.snapshot.WindowContext; -import io.github.libtmux.snapshot.WindowState; import io.github.libtmux.transport.CommandRequest; import io.github.libtmux.transport.CommandResult; import io.github.libtmux.transport.DispatchOutcome; @@ -18,10 +13,8 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -39,53 +32,6 @@ */ public final class Server implements AutoCloseable { - private static final RowFormat SESSIONS = - RowFormat.of("session_id", "session_name", "session_attached", "session_windows"); - private static final RowFormat WINDOWS = RowFormat.of( - "session_id", - "window_id", - "window_index", - "window_name", - "window_active", - "window_panes", - "window_linked", - "window_width", - "window_height", - "window_layout"); - private static final String[] PANE_FIELDS = { - "session_id", - "window_id", - "window_index", - "pane_id", - "pane_index", - "pane_active", - "pane_current_command", - "pane_width", - "pane_height", - "pane_title", - "pane_current_path", - "pane_pid", - "pane_at_top", - "pane_at_bottom", - "pane_at_left", - "pane_at_right" - }; - - private static final RowFormat PANES = RowFormat.of(PANE_FIELDS); - /** tmux gained pane_floating_flag in 3.7; before that the format expands to nothing. */ - private static final TmuxVersion FLOATING_SINCE = new TmuxVersion(3, 7, ""); - - private static final RowFormat PANES_WITH_FLOATING = RowFormat.of(withFloating()); - - private static String[] withFloating() { - String[] fields = Arrays.copyOf(PANE_FIELDS, PANE_FIELDS.length + 1); - fields[PANE_FIELDS.length] = "pane_floating_flag"; - return fields; - } - - private static final RowFormat CLIENTS = RowFormat.of("client_name", "session_id"); - private static final RowFormat PROCESS = RowFormat.of("pid", "version"); - /** Long enough for a pending signal to come straight back, short enough not to be a wait. */ private static final Duration DRAIN_TIMEOUT = Duration.ofMillis(250); @@ -95,12 +41,14 @@ private static String[] withFloating() { private final AtomicBoolean closed = new AtomicBoolean(); private final ServerIdentity identity; + private final SnapshotCapture capture; private Server(ServerConfig config, TmuxTransport transport, boolean owned) { this.config = config; this.transport = transport; this.owned = owned; this.identity = ServerIdentity.of(transport.realm(), config.endpoint()); + this.capture = new SnapshotCapture(this); } /** @@ -488,8 +436,8 @@ public Hooks hooks() { * started by a different build than the one this client is invoking. */ public TmuxVersion version() { - return process() - .map(ServerProcess::version) + return capture.process() + .map(SnapshotCapture.ServerProcess::version) .orElseThrow(() -> new LibTmuxException("no tmux server is answering on this endpoint")); } @@ -599,8 +547,8 @@ private void requireOpen() { public ServerSnapshot snapshot() { requireOpen(); try { - return hydrateSnapshot() - .or(this::hydrateSnapshot) + return capture.attempt() + .or(capture::attempt) .orElseThrow(() -> new LibTmuxException("tmux server changed during snapshot capture")); } catch (LibTmuxException e) { throw e; @@ -609,130 +557,6 @@ public ServerSnapshot snapshot() { } } - private Optional hydrateSnapshot() { - Optional observed = process(); - if (observed.isEmpty()) { - return Optional.of(ServerSnapshot.of(Instant.now(), List.of(), List.of(), List.of(), List.of())); - } - ServerProcess process = observed.orElseThrow(); - ServerSnapshot captured; - try { - captured = captureSnapshot(process); - } catch (RuntimeException failure) { - Optional current; - try { - current = process(); - } catch (RuntimeException probeFailure) { - probeFailure.addSuppressed(failure); - throw probeFailure; - } - if (Optional.of(process).equals(current)) { - throw failure; - } - return Optional.empty(); - } - if (!Optional.of(process).equals(process())) { - return Optional.empty(); - } - return Optional.of(captured); - } - - private ServerSnapshot captureSnapshot(ServerProcess process) { - List sessions = new ArrayList<>(); - for (List row : rows(SESSIONS, "list-sessions")) { - sessions.add(new SessionState( - new SessionId(row.get(0)), - row.get(1), - positiveCount(row.get(2), "session_attached"), - Integer.parseInt(row.get(3)))); - } - if (sessions.isEmpty()) { - return ServerSnapshot.of( - Instant.now(), process.pid(), process.version(), sessions, List.of(), List.of(), List.of()); - } - List windows = new ArrayList<>(); - for (List row : rows(WINDOWS, "list-windows", "-a")) { - windows.add(new WindowState( - context(row.get(0), row.get(2), row.get(1)), - row.get(3), - bit(row.get(4), "window_active"), - Integer.parseInt(row.get(5)), - bit(row.get(6), "window_linked"), - new Dimensions(Integer.parseInt(row.get(7)), Integer.parseInt(row.get(8))), - row.get(9))); - } - boolean floatingKnown = process.version().atLeast(FLOATING_SINCE); - RowFormat paneFormat = floatingKnown ? PANES_WITH_FLOATING : PANES; - List panes = new ArrayList<>(); - for (List row : rows(paneFormat, "list-panes", "-a")) { - panes.add(new PaneState( - context(row.get(0), row.get(2), row.get(1)), - new PaneId(row.get(3)), - Integer.parseInt(row.get(4)), - bit(row.get(5), "pane_active"), - row.get(6), - new Dimensions(Integer.parseInt(row.get(7)), Integer.parseInt(row.get(8))), - row.get(9), - Path.of(row.get(10)), - Long.parseLong(row.get(11)), - new PaneEdges( - bit(row.get(12), "pane_at_top"), - bit(row.get(13), "pane_at_bottom"), - bit(row.get(14), "pane_at_left"), - bit(row.get(15), "pane_at_right")), - floatingKnown ? Optional.of(bit(row.get(16), "pane_floating_flag")) : Optional.empty())); - } - List clients = new ArrayList<>(); - for (List row : rows(CLIENTS, "list-clients")) { - clients.add(new ClientState( - row.get(0), row.get(1).isEmpty() ? Optional.empty() : Optional.of(new SessionId(row.get(1))))); - } - return ServerSnapshot.of(Instant.now(), process.pid(), process.version(), sessions, windows, panes, clients); - } - - /** Reads process identity and version together so neither can come from a different server. */ - private Optional process() { - CommandResult result = cmd("display-message", "-p", PROCESS.template()); - if (!result.succeeded()) { - if (result.stderr().stream().anyMatch(Server::serverAbsent)) { - return Optional.empty(); - } - throw new LibTmuxException("tmux display-message failed: " + String.join("; ", result.stderr())); - } - if (result.stdout().size() != 1) { - throw new LibTmuxException("tmux did not report exactly one server identity row"); - } - List fields = PROCESS.split(result.stdout().get(0)); - String pid = fields.get(0); - if (pid.isEmpty() || !pid.chars().allMatch(character -> character >= '0' && character <= '9')) { - throw new LibTmuxException("tmux reported a malformed server pid: " + pid); - } - return Optional.of(new ServerProcess(Long.parseLong(pid), TmuxVersion.parse(fields.get(1)))); - } - - private static boolean serverAbsent(String message) { - return message.contains("no server running") - || message.contains("server exited unexpectedly") - || message.contains("(No such file or directory)"); - } - - private record ServerProcess(long pid, TmuxVersion version) {} - - private static boolean bit(String value, String field) { - return switch (value) { - case "0" -> false; - case "1" -> true; - default -> throw new IllegalArgumentException(field + " was neither 0 nor 1: " + value); - }; - } - - private static boolean positiveCount(String value, String field) { - if (value.isEmpty() || !value.chars().allMatch(character -> character >= '0' && character <= '9')) { - throw new IllegalArgumentException(field + " was not a non-negative count: " + value); - } - return Long.parseLong(value) > 0; - } - /** * Every session, captured now. * @@ -882,32 +706,6 @@ private ServerSnapshot lenient() { } } - private static WindowContext context(String session, String index, String window) { - return new WindowContext( - new SessionId(session), new WindowIndex(Integer.parseInt(index)), new WindowId(window)); - } - - /** - * Runs one listing and splits its rows. - * - *

An empty server is not a failure: {@code list-sessions} reports "no server running" as a - * nonzero exit, and a capture of nothing is still a capture. - */ - private List> rows(RowFormat format, String... command) { - List argv = new ArrayList<>(command.length + 2); - argv.addAll(List.of(command)); - argv.add("-F"); - argv.add(format.template()); - CommandResult result = cmd(argv); - if (!result.succeeded()) { - if (result.stderr().stream().anyMatch(line -> line.contains("no server running"))) { - return List.of(); - } - throw new LibTmuxException("tmux " + command[0] + " failed: " + String.join("; ", result.stderr())); - } - return format.rows(result.stdout()); - } - /** A builder holding every configuration and ownership choice this server made. */ public Builder toBuilder() { // An owned transport is not shared: this server will close it, so a derived server gets its own. diff --git a/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java b/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java new file mode 100644 index 0000000..9e8feed --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java @@ -0,0 +1,225 @@ +package io.github.libtmux; + +import io.github.libtmux.format.RowFormat; +import io.github.libtmux.snapshot.ClientState; +import io.github.libtmux.snapshot.PaneState; +import io.github.libtmux.snapshot.ServerSnapshot; +import io.github.libtmux.snapshot.SessionState; +import io.github.libtmux.snapshot.WindowContext; +import io.github.libtmux.snapshot.WindowState; +import io.github.libtmux.transport.CommandResult; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * Reads a whole tmux server into one snapshot. + * + *

One server-wide listing per kind of object, so ordering and membership stay tmux's decision + * rather than being re-derived from another listing's rows. + * + *

The server's process identity is read before and after, and a capture that spanned a + * replacement is discarded rather than returned: rows from two servers form a graph that never + * existed. Retrying is {@link Server#snapshot()}'s decision, not this one's. + */ +final class SnapshotCapture { + + private static final RowFormat SESSIONS = + RowFormat.of("session_id", "session_name", "session_attached", "session_windows"); + private static final RowFormat WINDOWS = RowFormat.of( + "session_id", + "window_id", + "window_index", + "window_name", + "window_active", + "window_panes", + "window_linked", + "window_width", + "window_height", + "window_layout"); + private static final String[] PANE_FIELDS = { + "session_id", + "window_id", + "window_index", + "pane_id", + "pane_index", + "pane_active", + "pane_current_command", + "pane_width", + "pane_height", + "pane_title", + "pane_current_path", + "pane_pid", + "pane_at_top", + "pane_at_bottom", + "pane_at_left", + "pane_at_right" + }; + + private static final RowFormat PANES = RowFormat.of(PANE_FIELDS); + + /** tmux gained pane_floating_flag in 3.7; before that the format expands to nothing. */ + private static final TmuxVersion FLOATING_SINCE = new TmuxVersion(3, 7, ""); + + private static final String FLOATING = "pane_floating_flag"; + + private static final RowFormat PANES_WITH_FLOATING = RowFormat.of(withFloating()); + + private static final RowFormat CLIENTS = RowFormat.of("client_name", "session_id"); + private static final RowFormat PROCESS = RowFormat.of("pid", "version"); + + private final Server server; + + SnapshotCapture(Server server) { + this.server = server; + } + + /** One attempt, empty when the server was replaced under it. */ + Optional attempt() { + Optional observed = process(); + if (observed.isEmpty()) { + return Optional.of(ServerSnapshot.of(Instant.now(), List.of(), List.of(), List.of(), List.of())); + } + ServerProcess process = observed.orElseThrow(); + ServerSnapshot captured; + try { + captured = capture(process); + } catch (RuntimeException failure) { + Optional current; + try { + current = process(); + } catch (RuntimeException probeFailure) { + probeFailure.addSuppressed(failure); + throw probeFailure; + } + if (Optional.of(process).equals(current)) { + throw failure; + } + return Optional.empty(); + } + if (!Optional.of(process).equals(process())) { + return Optional.empty(); + } + return Optional.of(captured); + } + + /** Reads process identity and version together so neither can come from a different server. */ + Optional process() { + CommandResult result = server.cmd("display-message", "-p", PROCESS.template()); + if (!result.succeeded()) { + if (result.stderr().stream().anyMatch(SnapshotCapture::serverAbsent)) { + return Optional.empty(); + } + throw new LibTmuxException("tmux display-message failed: " + String.join("; ", result.stderr())); + } + List reported = PROCESS.rows(result.stdout()); + if (reported.size() != 1) { + throw new LibTmuxException("tmux did not report exactly one server identity row"); + } + RowFormat.Row row = reported.get(0); + long pid = row.count("pid"); + if (pid <= 0) { + throw new LibTmuxException("tmux reported a malformed server pid: " + pid); + } + return Optional.of(new ServerProcess(pid, TmuxVersion.parse(row.text("version")))); + } + + private ServerSnapshot capture(ServerProcess process) { + List sessions = new ArrayList<>(); + for (RowFormat.Row row : rows(SESSIONS, "list-sessions")) { + sessions.add(new SessionState( + new SessionId(row.text("session_id")), + row.text("session_name"), + row.count("session_attached") > 0, + row.number("session_windows"))); + } + if (sessions.isEmpty()) { + return ServerSnapshot.of( + Instant.now(), process.pid(), process.version(), sessions, List.of(), List.of(), List.of()); + } + List windows = new ArrayList<>(); + for (RowFormat.Row row : rows(WINDOWS, "list-windows", "-a")) { + windows.add(new WindowState( + context(row), + row.text("window_name"), + row.flag("window_active"), + row.number("window_panes"), + row.flag("window_linked"), + new Dimensions(row.number("window_width"), row.number("window_height")), + row.text("window_layout"))); + } + boolean floatingKnown = process.version().atLeast(FLOATING_SINCE); + List panes = new ArrayList<>(); + for (RowFormat.Row row : rows(floatingKnown ? PANES_WITH_FLOATING : PANES, "list-panes", "-a")) { + panes.add(new PaneState( + context(row), + new PaneId(row.text("pane_id")), + row.number("pane_index"), + row.flag("pane_active"), + row.text("pane_current_command"), + new Dimensions(row.number("pane_width"), row.number("pane_height")), + row.text("pane_title"), + Path.of(row.text("pane_current_path")), + row.count("pane_pid"), + new PaneEdges( + row.flag("pane_at_top"), + row.flag("pane_at_bottom"), + row.flag("pane_at_left"), + row.flag("pane_at_right")), + floatingKnown ? Optional.of(row.flag(FLOATING)) : Optional.empty())); + } + List clients = new ArrayList<>(); + for (RowFormat.Row row : rows(CLIENTS, "list-clients")) { + String session = row.text("session_id"); + clients.add(new ClientState( + row.text("client_name"), + session.isEmpty() ? Optional.empty() : Optional.of(new SessionId(session)))); + } + return ServerSnapshot.of(Instant.now(), process.pid(), process.version(), sessions, windows, panes, clients); + } + + /** + * Runs one listing and reads its rows. + * + *

An empty server is not a failure: {@code list-sessions} reports "no server running" as a + * nonzero exit, and a capture of nothing is still a capture. + */ + private List rows(RowFormat format, String... command) { + List argv = new ArrayList<>(command.length + 2); + argv.addAll(List.of(command)); + argv.add("-F"); + argv.add(format.template()); + CommandResult result = server.cmd(argv); + if (!result.succeeded()) { + if (result.stderr().stream().anyMatch(line -> line.contains("no server running"))) { + return List.of(); + } + throw new LibTmuxException("tmux " + command[0] + " failed: " + String.join("; ", result.stderr())); + } + return format.rows(result.stdout()); + } + + private static WindowContext context(RowFormat.Row row) { + return new WindowContext( + new SessionId(row.text("session_id")), + new WindowIndex(row.number("window_index")), + new WindowId(row.text("window_id"))); + } + + private static boolean serverAbsent(String message) { + return message.contains("no server running") + || message.contains("server exited unexpectedly") + || message.contains("(No such file or directory)"); + } + + private static String[] withFloating() { + String[] fields = Arrays.copyOf(PANE_FIELDS, PANE_FIELDS.length + 1); + fields[PANE_FIELDS.length] = FLOATING; + return fields; + } + + record ServerProcess(long pid, TmuxVersion version) {} +} diff --git a/libtmux/src/main/java/io/github/libtmux/control/package-info.java b/libtmux/src/main/java/io/github/libtmux/control/package-info.java index d5be233..af14455 100644 --- a/libtmux/src/main/java/io/github/libtmux/control/package-info.java +++ b/libtmux/src/main/java/io/github/libtmux/control/package-info.java @@ -5,6 +5,14 @@ * does not discard the requests behind it, and each reply is framed with the request number that * produced it, so attribution is tmux's rather than something a client infers. * + *

Deliberately not a {@link io.github.libtmux.transport.TmuxTransport}, and so not something + * {@link io.github.libtmux.Server} can run over. That interface carries standard input, which + * control mode has no per-command channel for and {@code Pane.paste} depends on; it offers a + * request that stays blocked until another releases it, which would occupy the one request this + * carrier has in flight; and it answers with an exit status and a separate error channel, which a + * control reply does not have. An implementation would have to fail for those, which is worse than + * not offering one. Use this for what it is better at: staying attached, and being told. + * *

The package is null-marked: every type is non-null unless annotated otherwise. */ @NullMarked diff --git a/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java b/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java index d3611e1..56fdb3e 100644 --- a/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java +++ b/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java @@ -58,6 +58,70 @@ public int size() { return fields.size(); } + /** One row's values, addressed by the field name that asked for them. */ + public final class Row { + + private final List values; + + private Row(List values) { + this.values = values; + } + + /** The field's value as tmux printed it. */ + public String text(String field) { + return values.get(indexOf(field)); + } + + /** + * The field as a whole number. + * + * @throws TmuxFormatException if tmux did not print one + */ + public int number(String field) { + String value = text(field); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new TmuxFormatException(field + " was not a number", e); + } + } + + /** + * The field as a whole count. + * + * @throws TmuxFormatException if tmux did not print one + */ + public long count(String field) { + String value = text(field); + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + throw new TmuxFormatException(field + " was not a count", e); + } + } + + /** + * The field as one of tmux's flags, which it prints as 0 or 1. + * + * @throws TmuxFormatException if tmux printed anything else + */ + public boolean flag(String field) { + return switch (text(field)) { + case "0" -> false; + case "1" -> true; + default -> throw new TmuxFormatException(field + " was neither 0 nor 1"); + }; + } + + private int indexOf(String field) { + int index = fields.indexOf(field); + if (index < 0) { + throw new IllegalArgumentException(field + " is not a field of this format"); + } + return index; + } + } + /** * Reads a whole listing back into rows. * @@ -70,8 +134,8 @@ public int size() { * @throws TmuxFormatException if the listing ends mid-row, or a row does not have exactly the * expected number of fields */ - public List> rows(List lines) { - List> rows = new ArrayList<>(); + public List rows(List lines) { + List rows = new ArrayList<>(); StringBuilder pending = new StringBuilder(); int separators = 0; boolean open = false; @@ -83,7 +147,7 @@ public List> rows(List lines) { open = true; separators += occurrences(line); if (separators >= fields.size() - 1) { - rows.add(split(pending.toString())); + rows.add(new Row(split(pending.toString()))); pending.setLength(0); separators = 0; open = false; diff --git a/libtmux/src/main/java/io/github/libtmux/format/TmuxFormatException.java b/libtmux/src/main/java/io/github/libtmux/format/TmuxFormatException.java index 30143b6..626c457 100644 --- a/libtmux/src/main/java/io/github/libtmux/format/TmuxFormatException.java +++ b/libtmux/src/main/java/io/github/libtmux/format/TmuxFormatException.java @@ -10,4 +10,8 @@ public final class TmuxFormatException extends LibTmuxException { public TmuxFormatException(String message) { super(message); } + + public TmuxFormatException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/libtmux/src/main/java/io/github/libtmux/format/package-info.java b/libtmux/src/main/java/io/github/libtmux/format/package-info.java index 43eff7f..0b605e4 100644 --- a/libtmux/src/main/java/io/github/libtmux/format/package-info.java +++ b/libtmux/src/main/java/io/github/libtmux/format/package-info.java @@ -3,7 +3,9 @@ * *

tmux answers a listing as lines of text, so the only thing separating one field from the next * is a string the client chose. Anything a user can put in a window name can appear in that text, - * which makes the choice of separator a correctness question rather than a formatting one. + * which makes the choice of separator a correctness question rather than a formatting one. For the + * same reason a line is not a row: a value carrying a newline spans several, and a row is closed by + * carrying every separator instead. * *

The package is null-marked: every type is non-null unless annotated otherwise. */ From 257b5682c448c7ac95447d063dc89638944e1814 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:27:23 -0500 Subject: [PATCH 47/77] Test(fix[chain]): Define before using the name why: The chain defined a shell function and typed its name in the same invocation, so under load the name was typed before the shell had read the definition and the test reported that Enter had been pressed. It failed that way on the 3.5 lane of a full matrix run and passed alone. what: - Define the function and wait for the shell to acknowledge it, then run the literal-line chain the case is actually about --- .../it/CommandChainIntegrationTest.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java index f93a303..66b3be5 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java @@ -49,9 +49,25 @@ void eachStepActsOnWhatTheLastOneMade(Server server) throws Exception { @Test void aLineThatIsAKeyNameStaysLiteralInsideAChain(Server server) throws Exception { - BatchResult result = server.chain() + assertTrue(server.chain() .newWindow("literal-line") .sendLine("Enter() { printf 'literal-chain-%s\\n' enter; }") + .sendLine("echo defined-the-function") + .run() + .succeeded()); + Pane pane = server.windows().stream() + .filter(window -> window.name().equals("literal-line")) + .findFirst() + .orElseThrow() + .panes() + .get(0); + // The shell has to have read the definition before the name is used. A chain is one + // invocation, so without this the name is typed before anything is reading for it. + assertTrue( + await(() -> pane.capture().stream().anyMatch(line -> line.contains("defined-the-function"))), + "the shell never read the definition"); + + BatchResult result = server.chain() .sendLine("clear") .sendLine("Enter") .sendLine("-R") @@ -59,12 +75,6 @@ void aLineThatIsAKeyNameStaysLiteralInsideAChain(Server server) throws Exception .run(); assertTrue(result.succeeded(), result.toString()); - Pane pane = server.windows().stream() - .filter(window -> window.name().equals("literal-line")) - .findFirst() - .orElseThrow() - .panes() - .get(0); assertTrue( await(() -> pane.capture().stream().anyMatch(line -> line.contains("literal-chain-enter"))), "Enter was pressed instead of typed"); From 6a8832394f72ce5ae36ad63b22cd891316ba8b54 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:45:07 -0500 Subject: [PATCH 48/77] Build(feat[benchmarks]): Price an operation why: The suite carries costs nobody measures: a handle fences every command behind if-shell, a strict capture samples identity either side of four listings, and an option listing reads names then values. The module that would have priced them was removed with the execution modes it benchmarked, so a performance claim here has had no number behind it. what: - Restore the module, rewritten for what exists: collapsing round trips, capturing the hierarchy, fencing a handle's command, listing options - Regenerate docs/benchmarks/operations.md from a run, stamped with the tmux that answered - Count dispatches through the transport rather than inferring them --- benchmarks/README.md | 30 ++ benchmarks/build.gradle.kts | 27 ++ .../libtmux/benchmark/OperationBenchmark.java | 326 ++++++++++++++++++ docs/benchmarks/operations.md | 47 +++ settings.gradle.kts | 1 + 5 files changed, 431 insertions(+) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/build.gradle.kts create mode 100644 benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java create mode 100644 docs/benchmarks/operations.md diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..eb79143 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,30 @@ +# benchmarks + +**Measures what an operation costs. Not published.** + +Every command this library sends starts a tmux process, so what a caller pays is +decided by how many commands an operation takes rather than by how fast any one +of them runs. This measures that, and regenerates +[`docs/benchmarks/operations.md`](../docs/benchmarks/operations.md) from a real +run. + +```console +$ ./gradlew operationBenchmark -PlibtmuxTmux=/path/to/tmux +``` + +The table is **never hand-edited**, and it stamps which tmux answered, because a +table without its conditions is a claim rather than a measurement. Read the +dispatch counts; the milliseconds are one machine at one moment. + +Four things are measured, and each exists because the library made a choice that +costs something: collapsing round trips with `batch()` and `chain()`, capturing +the hierarchy, fencing a handle's command against a replaced server, and reading +a scope's options. + +Its own module, and excluded from `check`: a benchmark starts a tmux server per +case and takes seconds. Keeping it inside a published artifact's tests made that +a matter of remembering a tag rather than a matter of where the code lives. + +## Next + +- [Batching and chaining](../docs/guide/batching-and-chaining.md) · [the measured table](../docs/benchmarks/operations.md) diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts new file mode 100644 index 0000000..e7f4baa --- /dev/null +++ b/benchmarks/build.gradle.kts @@ -0,0 +1,27 @@ +// Measures what an operation costs, and rewrites docs/benchmarks/operations.md from a real run. +// +// Its own module, and never published. A benchmark takes seconds per case and starts a server per +// case, so it must not run in any ordinary suite; keeping it in a published artifact's tests made +// that a matter of remembering a tag rather than a matter of where the code lives. +plugins { id("libtmux.java-library") } + +dependencies { testImplementation(project(":libtmux")) } + +// Nothing here belongs to `check`: it writes a file and takes seconds. Run it when the table needs +// regenerating. +tasks.named("test") { enabled = false } + +tasks.register("operationBenchmark") { + group = "verification" + description = "Measures what each operation costs and rewrites docs/benchmarks/operations.md." + val tests = sourceSets.test.get() + testClassesDirs = tests.output.classesDirs + classpath = tests.runtimeClasspath + useJUnitPlatform { includeTags("benchmark") } + systemProperty("libtmux.tmux", providers.gradleProperty("libtmuxTmux").getOrElse("tmux")) + systemProperty( + "libtmux.benchmark.out", + rootProject.layout.projectDirectory.file("docs/benchmarks/operations.md").asFile.path, + ) + outputs.upToDateWhen { false } +} diff --git a/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java b/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java new file mode 100644 index 0000000..d5c1875 --- /dev/null +++ b/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java @@ -0,0 +1,326 @@ +package io.github.libtmux.benchmark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.libtmux.CommandChain; +import io.github.libtmux.Server; +import io.github.libtmux.ServerConfig; +import io.github.libtmux.ServerEndpoint; +import io.github.libtmux.Session; +import io.github.libtmux.Window; +import io.github.libtmux.Window_; +import io.github.libtmux.batch.Batch; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Measures what an operation costs, and writes the table the docs show. + * + *

Tagged {@code benchmark} and excluded from the ordinary suite and the release matrix: it takes + * seconds rather than milliseconds and it writes a file. Run it with + * {@code ./gradlew operationBenchmark}. + * + *

Numbers are never written by hand. This regenerates {@code docs/benchmarks/operations.md} from + * a run on the tmux it is given, and stamps which tmux that was, because a table without its + * conditions is a claim rather than a measurement. + */ +@Tag("benchmark") +final class OperationBenchmark { + + private static final int ROUNDS = 20; + + /** The tmux the measurements ran against, asked of the running server rather than inferred. */ + private String tmux = ""; + + /** Counts what the transport really did, which is the only honest way to report process cost. */ + private static final class Counting implements TmuxTransport { + + private final TmuxTransport delegate; + private final AtomicInteger dispatches = new AtomicInteger(); + + Counting(TmuxTransport delegate) { + this.delegate = delegate; + } + + @Override + public CommandResult execute(CommandRequest request) { + dispatches.incrementAndGet(); + return delegate.execute(request); + } + + @Override + public CommandResult executeWaiting(CommandRequest request) { + dispatches.incrementAndGet(); + return delegate.executeWaiting(request); + } + + @Override + public String realm() { + return delegate.realm(); + } + + @Override + public void close() { + delegate.close(); + } + } + + private record Measured(String label, long millis, int dispatches, String output) {} + + @Test + void writeTheOperationTable(@TempDir Path directory) throws Exception { + List grouping = List.of( + measure(directory, "one-at-a-time", server -> {}, OperationBenchmark::create), + measure(directory, "batch", server -> {}, OperationBenchmark::createBatched), + measure(directory, "chain", server -> {}, OperationBenchmark::createChained)); + + List reading = List.of( + measure(directory, "traversal", OperationBenchmark::plantWindows, OperationBenchmark::traverse), + measure(directory, "snapshot", OperationBenchmark::plantWindows, OperationBenchmark::snapshot)); + + List guarding = List.of( + measure(directory, "unguarded", OperationBenchmark::plantWindows, OperationBenchmark::unguarded), + measure(directory, "guarded", OperationBenchmark::plantWindows, OperationBenchmark::guarded)); + + List options = List.of( + measure(directory, "one option", server -> {}, OperationBenchmark::oneOption), + measure(directory, "all()", server -> {}, OperationBenchmark::allOptions), + measure(directory, "effective()", server -> {}, OperationBenchmark::effectiveOptions)); + + assertEquals( + 1, + grouping.stream().map(Measured::output).distinct().count(), + "grouping the same commands built something different: " + labelled(grouping)); + + // Told where to write rather than guessing from a working directory, which for a Gradle + // Test task is the module and not the root. + Path report = Path.of(System.getProperty("libtmux.benchmark.out", "build/operations.md")); + Files.createDirectories(report.getParent()); + Files.writeString(report, render(grouping, reading, guarding, options)); + + assertTrue(Files.exists(report), "the benchmark wrote no table"); + } + + // ------------------------------------------------------------------------------- scenarios + + /** Gives the readers something to find, before the clock starts. */ + private static void plantWindows(Server server) { + Session session = server.sessions().get(0); + for (int index = 0; index < 3; index++) { + String name = "bench-" + index; + session.newWindow(window -> window.named(name).detached()); + } + } + + /** Builds a workspace one call at a time: what a program setting tmux up does naively. */ + private static String create(Server server) { + Session session = server.sessions().get(0); + for (int round = 0; round < ROUNDS; round++) { + String name = "bench-" + round; + session.newWindow(window -> window.named(name).detached()); + } + return Integer.toString(session.refresh().windows().size()); + } + + /** The same workspace, asked for in one request. */ + private static String createBatched(Server server) { + Session session = server.sessions().get(0); + Batch batch = server.batch(); + for (int round = 0; round < ROUNDS; round++) { + batch.add("new-window", "-d", "-n", "bench-" + round); + } + batch.run(); + return Integer.toString(session.refresh().windows().size()); + } + + /** The same workspace again, as steps that each act on what the last one made. */ + private static String createChained(Server server) { + Session session = server.sessions().get(0); + CommandChain chain = server.chain(); + for (int round = 0; round < ROUNDS; round++) { + chain.newWindow("bench-" + round); + } + chain.run(); + return Integer.toString(session.refresh().windows().size()); + } + + /** Reads the hierarchy repeatedly through handles: what a program watching tmux does. */ + private static String traverse(Server server) { + String seen = ""; + for (int round = 0; round < ROUNDS; round++) { + seen = server.windows().stream() + .filter(Window_.name().startsWith("bench")) + .map(Window::name) + .sorted() + .toList() + .toString(); + } + return seen; + } + + /** The same reads, taken as one strict capture each time. */ + private static String snapshot(Server server) { + int seen = 0; + for (int round = 0; round < ROUNDS; round++) { + seen = server.snapshot().windows().size(); + } + return Integer.toString(seen); + } + + /** A command that reaches tmux directly, with nothing fencing it. */ + private static String unguarded(Server server) { + // Taken and discarded, so both rows pay for the same handle and the difference is the guard. + server.sessions().get(0); + String seen = ""; + for (int round = 0; round < ROUNDS; round++) { + seen = server.expand("#{session_name}"); + } + return seen; + } + + /** The same command through a handle, which wraps it in the staleness guard. */ + private static String guarded(Server server) { + Session session = server.sessions().get(0); + String seen = ""; + for (int round = 0; round < ROUNDS; round++) { + seen = session.expand("#{session_name}"); + } + return seen; + } + + /** One option read by name, which is one request. */ + private static String oneOption(Server server) { + String seen = ""; + for (int round = 0; round < ROUNDS; round++) { + seen = server.globalOptions().get("status-left").orElse(""); + } + return seen; + } + + /** Every option this scope sets: names from the listing, then values. */ + private static String allOptions(Server server) { + int seen = 0; + for (int round = 0; round < ROUNDS; round++) { + seen = server.globalOptions().all().size(); + } + return Integer.toString(seen); + } + + /** Every option in effect, which is the widest listing a scope has. */ + private static String effectiveOptions(Server server) { + int seen = 0; + for (int round = 0; round < ROUNDS; round++) { + seen = server.globalOptions().effective().size(); + } + return Integer.toString(seen); + } + + // ------------------------------------------------------------------------------- measuring + + private Measured measure(Path root, String scenario, Consumer setUp, Function work) + throws IOException { + Path home = root.resolve(scenario.replace("()", "").replace(' ', '-')); + Files.createDirectories(home); + Path config = home.resolve("empty.conf"); + Files.writeString(config, ""); + ServerConfig built = ServerConfig.builder() + .binary(System.getProperty("libtmux.tmux", "tmux")) + .endpoint(ServerEndpoint.socketPath(home.resolve("s"))) + .configFile(config) + .defaultTimeout(Duration.ofSeconds(30)) + .build(); + + Counting counting = new Counting(new ProcessTransport()); + try (Server server = Server.using(built, counting)) { + server.newSession("bench"); + tmux = server.version().toString(); + setUp.accept(server); + // Warm: the first command pays for starting a server, which is not what is being + // compared. Whatever the scenario needed is already in place, so none of it is timed. + server.windows(); + int before = counting.dispatches.get(); + long started = System.nanoTime(); + String output = work.apply(server); + long millis = (System.nanoTime() - started) / 1_000_000; + int dispatches = counting.dispatches.get() - before; + server.killServer(); + return new Measured(scenario, millis, dispatches, output); + } finally { + counting.close(); + } + } + + // --------------------------------------------------------------------------------- the table + + private String render( + List grouping, List reading, List guarding, List options) { + StringBuilder out = new StringBuilder(); + out.append("# What an operation costs, measured\n\n") + .append("Regenerated by `./gradlew operationBenchmark`. Never edit by hand.\n\n") + .append("Measured against tmux `") + .append(tmux) + .append("`, ") + .append(ROUNDS) + .append(" rounds per scenario, on one machine at one moment. ") + .append("Every command starts a tmux process, so the dispatch count is the cost and ") + .append("the milliseconds are one machine's rendering of it. Read the shape.\n\n"); + + out.append("## Collapsing round trips\n\n") + .append("The same ") + .append(ROUNDS) + .append(" windows, asked for three ways. This is the whole of the answer to ") + .append("per-command process cost, so it leads.\n\n"); + table(out, "strategy", grouping); + + out.append("\n## Reading the hierarchy\n\n") + .append("`windows()` is lenient and `snapshot()` is strict; both capture the whole ") + .append("server, and the strict one samples process identity either side of it.\n\n"); + table(out, "read", reading); + + out.append("\n## What the staleness guard costs\n\n") + .append("A handle fences every command it sends behind `if-shell -F`, so that a ") + .append("handle cannot act on a tmux that replaced the one it was taken from. Both ") + .append("rows take a handle first, so the difference is the guard alone.\n\n"); + table(out, "command", guarding); + out.append("\nThe guard rides inside the one command it fences, so it costs no further ") + .append("process. What it adds is bytes, against the 16384 a tmux command may carry.\n"); + + out.append("\n## What an option listing costs\n\n") + .append("A listed value is escaped for display, and how it is escaped changes ") + .append("between releases, so names come from the listing and values from ") + .append("`show-options -v`, batched.\n\n"); + table(out, "read", options); + out.append("\nThat is the cost: one option is one command, and a listing is two whatever ") + .append("its size, until it outgrows what one command may carry.\n"); + return out.toString(); + } + + private static void table(StringBuilder out, String heading, List rows) { + out.append("| %s | wall clock | commands dispatched |%n".formatted(heading)) + .append("| --- | --- | --- |\n"); + for (Measured row : rows) { + out.append("| `%s` | %d ms | %d |%n".formatted(row.label(), row.millis(), row.dispatches())); + } + } + + private static List labelled(List rows) { + List described = new ArrayList<>(rows.size()); + rows.forEach(row -> described.add(row.label() + "=" + row.output())); + return described; + } +} diff --git a/docs/benchmarks/operations.md b/docs/benchmarks/operations.md new file mode 100644 index 0000000..8c449db --- /dev/null +++ b/docs/benchmarks/operations.md @@ -0,0 +1,47 @@ +# What an operation costs, measured + +Regenerated by `./gradlew operationBenchmark`. Never edit by hand. + +Measured against tmux `3.7d`, 20 rounds per scenario, on one machine at one moment. Every command starts a tmux process, so the dispatch count is the cost and the milliseconds are one machine's rendering of it. Read the shape. + +## Collapsing round trips + +The same 20 windows, asked for three ways. This is the whole of the answer to per-command process cost, so it leads. + +| strategy | wall clock | commands dispatched | +| --- | --- | --- | +| `one-at-a-time` | 2830 ms | 152 | +| `batch` | 361 ms | 13 | +| `chain` | 331 ms | 13 | + +## Reading the hierarchy + +`windows()` is lenient and `snapshot()` is strict; both capture the whole server, and the strict one samples process identity either side of it. + +| read | wall clock | commands dispatched | +| --- | --- | --- | +| `traversal` | 1111 ms | 120 | +| `snapshot` | 795 ms | 120 | + +## What the staleness guard costs + +A handle fences every command it sends behind `if-shell -F`, so that a handle cannot act on a tmux that replaced the one it was taken from. Both rows take a handle first, so the difference is the guard alone. + +| command | wall clock | commands dispatched | +| --- | --- | --- | +| `unguarded` | 412 ms | 26 | +| `guarded` | 416 ms | 26 | + +The guard rides inside the one command it fences, so it costs no further process. What it adds is bytes, against the 16384 a tmux command may carry. + +## What an option listing costs + +A listed value is escaped for display, and how it is escaped changes between releases, so names come from the listing and values from `show-options -v`, batched. + +| read | wall clock | commands dispatched | +| --- | --- | --- | +| `one option` | 180 ms | 20 | +| `all()` | 579 ms | 40 | +| `effective()` | 321 ms | 40 | + +That is the cost: one option is one command, and a listing is two whatever its size, until it outgrows what one command may carry. diff --git a/settings.gradle.kts b/settings.gradle.kts index a6490d2..89da86c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,7 @@ include("libtmux-workspace") include("libtmux-mcp") // Internal: exercised by the build, never released. +include("benchmarks") include("docs-tests") include("examples") include("integration-tests") From 841d38fd37220270c463c5a6974116f731ed6394 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:46:58 -0500 Subject: [PATCH 49/77] Build(feat[matrix]): Run the 3.7c lane why: The newest lane was 3.7b while 3.7c had been released, so a plain check ran against a tmux no lane covered. The range the README states is meant to be executed rather than claimed, and it was drifting. what: - Add 3.7c to the lane list and the workflow matrix - Move the stated range to 3.7c in README, CONTRIBUTING and WRITING --- .github/CONTRIBUTING.md | 2 +- .github/WRITING.md | 4 ++-- .github/workflows/tmux-matrix.yml | 4 ++-- README.md | 2 +- build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts | 2 +- libtmux/README.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c4f3d58..4425b2f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -201,7 +201,7 @@ toolchain and `options.release` in matrix in the CI workflow, which builds on 21 and 25, and the claim `README.md` makes under `Requirements`. -**tmux 3.2a through 3.7b is the supported range**, and it is not a claim: the +**tmux 3.2a through 3.7c is the supported range**, and it is not a claim: the whole real-tmux suite runs against every one of those releases, and each lane checks it really ran the tmux it is named after. Moving the range means moving `workflows/tmux-matrix.yml` and the README together. diff --git a/.github/WRITING.md b/.github/WRITING.md index 8434a67..980f2d0 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -28,7 +28,7 @@ Four things near the top are compatibility claims, and they move together: exported identifiers without a deprecation period, pin an exact version, not recommended for production. `CHANGELOG.md` and the `Status` section say the same thing in the same words. -- **The requirements are JDK 21 or newer, and tmux 3.2a through 3.7b.** The tmux +- **The requirements are JDK 21 or newer, and tmux 3.2a through 3.7c.** The tmux range is not a claim — the matrix runs every lane of it — so it may not drift from `workflows/tmux-matrix.yml`. - **Coordinates are group `io.github.libtmux`, imported through @@ -104,7 +104,7 @@ Order it capability, then consequence, then compatibility: > 0.0.1-alpha.7 adds streaming capture, and rejects `windowId` at pane scope > rather than ignoring it. Pass `scope: window` to read at window scope. JDK 21 -> and tmux 3.2a through 3.7b are unchanged. +> and tmux 3.2a through 3.7c are unchanged. The title is plain — the version, optionally preceded by `libtmux for Java`. Never "we are excited to announce"; state what shipped. diff --git a/.github/workflows/tmux-matrix.yml b/.github/workflows/tmux-matrix.yml index 1a1a596..8d3b7e1 100644 --- a/.github/workflows/tmux-matrix.yml +++ b/.github/workflows/tmux-matrix.yml @@ -1,6 +1,6 @@ name: tmux matrix -# The README claims tmux 3.2a through 3.7b, and says that range is not a claim because the suite runs +# The README claims tmux 3.2a through 3.7c, and says that range is not a claim because the suite runs # against every one of them. This is where that stops depending on someone running it locally. on: @@ -32,7 +32,7 @@ jobs: matrix: # Must match the lanes in build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts, which # is the source of truth. The agree step below fails if they drift apart. - tmux: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7', '3.7a', '3.7b'] + tmux: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7', '3.7a', '3.7b', '3.7c'] steps: - uses: actions/checkout@v7 diff --git a/README.md b/README.md index d93a744..1ae3eeb 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ bug. See [the Scala guide](docs/guide/scala.md). JDK 21 or newer. -tmux 3.2a through 3.7b. That range is not a claim: the whole real-tmux suite runs +tmux 3.2a through 3.7c. That range is not a claim: the whole real-tmux suite runs against every one of those releases, and each lane checks it really ran the tmux it is named after. diff --git a/build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts b/build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts index 1cf1841..7ae9a5c 100644 --- a/build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts +++ b/build-logic/src/main/kotlin/libtmux.tmux-matrix.gradle.kts @@ -8,7 +8,7 @@ // Declared so the source-set accessors resolve; the module already has it via the library plugin. plugins { java } -val lanes = listOf("3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7", "3.7a", "3.7b") +val lanes = listOf("3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7", "3.7a", "3.7b", "3.7c") val matrix = providers.gradleProperty("libtmuxMatrix") diff --git a/libtmux/README.md b/libtmux/README.md index 43e2fbe..09ce2e6 100644 --- a/libtmux/README.md +++ b/libtmux/README.md @@ -42,7 +42,7 @@ dependencies { ``` -Needs JDK 21 and a tmux between 3.2a and 3.7b. +Needs JDK 21 and a tmux between 3.2a and 3.7c. ## Thirty seconds From 70806a04a5221a8775c278ea0c49d377a388e772 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:00:45 -0500 Subject: [PATCH 50/77] Snapshot(perf[capture]): Capture in one group why: A capture cost six tmux processes: an identity probe, four listings, and a second probe to notice a server replaced under them. Every command is a process, so that was the price of the operation a program watching tmux performs most. Measured on 3.7d, twenty traversals fell from 1138 ms and 120 commands to 174 ms and 40. what: - Read who the server is, then run the four listings as one group fenced against that answer - Drop the second probe: tmux runs a group in the server, so rows cannot come from two of them, and the fence refuses a replacement before a listing runs rather than after - Keep the empty-server rule: tmux refuses the rest of the group with no current target, which costs no further request - GroupedTmux runs a group and honours the fence, so a double answers what the library now sends --- .../libtmux/benchmark/OperationBenchmark.java | 5 +- docs/benchmarks/operations.md | 22 +++--- .../libtmux/it/SnapshotIntegrationTest.java | 32 +++++++++ .../libtmux/mcp/ToolsAgainstTmuxTest.java | 2 +- .../main/java/io/github/libtmux/Server.java | 13 +++- .../io/github/libtmux/SnapshotCapture.java | 70 +++++++++++-------- .../java/io/github/libtmux/GroupedTmux.java | 67 ++++++++++++++++++ .../java/io/github/libtmux/HandleTest.java | 33 ++++++++- .../java/io/github/libtmux/ServerTest.java | 53 +++++++++----- 9 files changed, 234 insertions(+), 63 deletions(-) create mode 100644 libtmux/src/test/java/io/github/libtmux/GroupedTmux.java diff --git a/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java b/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java index d5c1875..c36f757 100644 --- a/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java +++ b/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java @@ -288,8 +288,9 @@ private String render( table(out, "strategy", grouping); out.append("\n## Reading the hierarchy\n\n") - .append("`windows()` is lenient and `snapshot()` is strict; both capture the whole ") - .append("server, and the strict one samples process identity either side of it.\n\n"); + .append("`windows()` is lenient and `snapshot()` is strict; both read who the ") + .append("server is, then run the four listings as one group fenced against that ") + .append("answer. Two commands, whatever the hierarchy holds.\n\n"); table(out, "read", reading); out.append("\n## What the staleness guard costs\n\n") diff --git a/docs/benchmarks/operations.md b/docs/benchmarks/operations.md index 8c449db..32eee48 100644 --- a/docs/benchmarks/operations.md +++ b/docs/benchmarks/operations.md @@ -10,18 +10,18 @@ The same 20 windows, asked for three ways. This is the whole of the answer to pe | strategy | wall clock | commands dispatched | | --- | --- | --- | -| `one-at-a-time` | 2830 ms | 152 | -| `batch` | 361 ms | 13 | -| `chain` | 331 ms | 13 | +| `one-at-a-time` | 818 ms | 64 | +| `batch` | 94 ms | 5 | +| `chain` | 92 ms | 5 | ## Reading the hierarchy -`windows()` is lenient and `snapshot()` is strict; both capture the whole server, and the strict one samples process identity either side of it. +`windows()` is lenient and `snapshot()` is strict; both read who the server is, then run the four listings as one group fenced against that answer. Two commands, whatever the hierarchy holds. | read | wall clock | commands dispatched | | --- | --- | --- | -| `traversal` | 1111 ms | 120 | -| `snapshot` | 795 ms | 120 | +| `traversal` | 232 ms | 40 | +| `snapshot` | 192 ms | 40 | ## What the staleness guard costs @@ -29,8 +29,8 @@ A handle fences every command it sends behind `if-shell -F`, so that a handle ca | command | wall clock | commands dispatched | | --- | --- | --- | -| `unguarded` | 412 ms | 26 | -| `guarded` | 416 ms | 26 | +| `unguarded` | 90 ms | 22 | +| `guarded` | 92 ms | 22 | The guard rides inside the one command it fences, so it costs no further process. What it adds is bytes, against the 16384 a tmux command may carry. @@ -40,8 +40,8 @@ A listed value is escaped for display, and how it is escaped changes between rel | read | wall clock | commands dispatched | | --- | --- | --- | -| `one option` | 180 ms | 20 | -| `all()` | 579 ms | 40 | -| `effective()` | 321 ms | 40 | +| `one option` | 59 ms | 20 | +| `all()` | 213 ms | 40 | +| `effective()` | 189 ms | 40 | That is the cost: one option is one command, and a listing is two whatever its size, until it outgrows what one command may carry. diff --git a/integration-tests/src/test/java/io/github/libtmux/it/SnapshotIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/SnapshotIntegrationTest.java index 41f0337..ab9df59 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/SnapshotIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/SnapshotIntegrationTest.java @@ -14,8 +14,13 @@ import io.github.libtmux.snapshot.SessionState; import io.github.libtmux.snapshot.WindowContext; import io.github.libtmux.snapshot.WindowState; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,6 +28,33 @@ @ExtendWith(TmuxExtension.class) final class SnapshotIntegrationTest { + /** + * Every command is a tmux process, so what a capture costs is how many it takes. One asks who + * the server is; one runs the four listings as a group, fenced against that answer. + */ + @Test + void aCaptureCostsTwoCommands(Server server) { + AtomicInteger commands = new AtomicInteger(); + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport counting = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + commands.incrementAndGet(); + return processes.execute(request); + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), counting)) { + ServerSnapshot captured = measured.snapshot(); + + assertEquals(1, captured.sessions().size()); + assertEquals(2, commands.get(), "one identity read, then the listings as one group"); + } + } + } + @Test void aCaptureSeesWhatTmuxReports(Server server) { server.cmd("new-window", "-t", "libtmux:", "-n", "second"); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index f0e3d50..d223f54 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -187,7 +187,7 @@ public void close() {} Listings.Whoami whoami = Listings.whoami(measured, Caller.nowhere(), Safety.MUTATING); assertEquals(1, whoami.sessions()); - assertEquals(7, commands.get(), "one identity-fenced snapshot and one socket-path read"); + assertEquals(3, commands.get(), "one identity read, the listings as one group, one socket path"); } } } diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 86bfc7f..8ad97b6 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -623,7 +623,14 @@ CommandResult cmd(ServerSnapshot snapshot, List argv) { /** A batch whose every command is refused once this handle's server has been replaced. */ Batch batch(ServerSnapshot snapshot) { - return new Batch(commands -> guarded(snapshot, CommandStrings.group(commands))); + long pid = snapshot.serverPid() + .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); + return batch(pid); + } + + /** As {@link #batch(ServerSnapshot)}, fenced against a server identity read separately. */ + Batch batch(long pid) { + return new Batch(commands -> guarded(pid, CommandStrings.group(commands), "")); } CommandResult run(ServerSnapshot snapshot, List argv) { @@ -663,6 +670,10 @@ private CommandResult guarded(ServerSnapshot snapshot, String command) { private CommandResult guarded(ServerSnapshot snapshot, String command, String input) { long pid = snapshot.serverPid() .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); + return guarded(pid, command, input); + } + + private CommandResult guarded(long pid, String command, String input) { String stale = "libtmux-stale-handle-" + pid; CommandResult result = cmd( List.of("if-shell", "-F", "#{==:#{pid}," + pid + "}", command, stale), config.defaultTimeout(), input); diff --git a/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java b/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java index 9e8feed..a7cc9a9 100644 --- a/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java +++ b/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java @@ -1,5 +1,8 @@ package io.github.libtmux; +import io.github.libtmux.batch.Batch; +import io.github.libtmux.batch.OperationOutcome; +import io.github.libtmux.batch.OperationResult; import io.github.libtmux.format.RowFormat; import io.github.libtmux.snapshot.ClientState; import io.github.libtmux.snapshot.PaneState; @@ -21,9 +24,10 @@ *

One server-wide listing per kind of object, so ordering and membership stay tmux's decision * rather than being re-derived from another listing's rows. * - *

The server's process identity is read before and after, and a capture that spanned a - * replacement is discarded rather than returned: rows from two servers form a graph that never - * existed. Retrying is {@link Server#snapshot()}'s decision, not this one's. + *

Two commands: who the server is, then the listings as one group fenced against that answer. + * tmux runs a group in the server, so rows cannot come from two of them, and a replacement is + * refused by the fence before a listing runs rather than detected afterwards. Retrying is + * {@link Server#snapshot()}'s decision, not this one's. */ final class SnapshotCapture { @@ -84,9 +88,11 @@ Optional attempt() { return Optional.of(ServerSnapshot.of(Instant.now(), List.of(), List.of(), List.of(), List.of())); } ServerProcess process = observed.orElseThrow(); - ServerSnapshot captured; try { - captured = capture(process); + return Optional.of(capture(process)); + } catch (ObjectDoesNotExist replaced) { + // The fence answered: this is no longer the server the identity came from. + return Optional.empty(); } catch (RuntimeException failure) { Optional current; try { @@ -100,10 +106,6 @@ Optional attempt() { } return Optional.empty(); } - if (!Optional.of(process).equals(process())) { - return Optional.empty(); - } - return Optional.of(captured); } /** Reads process identity and version together so neither can come from a different server. */ @@ -127,9 +129,25 @@ Optional process() { return Optional.of(new ServerProcess(pid, TmuxVersion.parse(row.text("version")))); } + /** + * The whole hierarchy in one invocation, fenced against the identity just read. + * + *

tmux runs a group in the server, so the four listings cannot come from two servers and + * there is nothing to sample afterwards: either the fence matched and every row is that + * server's, or it did not and there is no capture. + */ private ServerSnapshot capture(ServerProcess process) { + boolean floatingKnown = process.version().atLeast(FLOATING_SINCE); + RowFormat paneFormat = floatingKnown ? PANES_WITH_FLOATING : PANES; + Batch listings = server.batch(process.pid()); + listings.add(listing(SESSIONS, "list-sessions")); + listings.add(listing(WINDOWS, "list-windows", "-a")); + listings.add(listing(paneFormat, "list-panes", "-a")); + listings.add(listing(CLIENTS, "list-clients")); + List answered = listings.run().operations(); + List sessions = new ArrayList<>(); - for (RowFormat.Row row : rows(SESSIONS, "list-sessions")) { + for (RowFormat.Row row : rows(SESSIONS, answered.get(0), "list-sessions")) { sessions.add(new SessionState( new SessionId(row.text("session_id")), row.text("session_name"), @@ -137,11 +155,13 @@ private ServerSnapshot capture(ServerProcess process) { row.number("session_windows"))); } if (sessions.isEmpty()) { + // A server with no sessions has no current target, so tmux refuses the rest of the + // group. An empty sessions listing is the whole hierarchy, so there is nothing to read. return ServerSnapshot.of( Instant.now(), process.pid(), process.version(), sessions, List.of(), List.of(), List.of()); } List windows = new ArrayList<>(); - for (RowFormat.Row row : rows(WINDOWS, "list-windows", "-a")) { + for (RowFormat.Row row : rows(WINDOWS, answered.get(1), "list-windows")) { windows.add(new WindowState( context(row), row.text("window_name"), @@ -151,9 +171,8 @@ private ServerSnapshot capture(ServerProcess process) { new Dimensions(row.number("window_width"), row.number("window_height")), row.text("window_layout"))); } - boolean floatingKnown = process.version().atLeast(FLOATING_SINCE); List panes = new ArrayList<>(); - for (RowFormat.Row row : rows(floatingKnown ? PANES_WITH_FLOATING : PANES, "list-panes", "-a")) { + for (RowFormat.Row row : rows(paneFormat, answered.get(2), "list-panes")) { panes.add(new PaneState( context(row), new PaneId(row.text("pane_id")), @@ -172,7 +191,7 @@ private ServerSnapshot capture(ServerProcess process) { floatingKnown ? Optional.of(row.flag(FLOATING)) : Optional.empty())); } List clients = new ArrayList<>(); - for (RowFormat.Row row : rows(CLIENTS, "list-clients")) { + for (RowFormat.Row row : rows(CLIENTS, answered.get(3), "list-clients")) { String session = row.text("session_id"); clients.add(new ClientState( row.text("client_name"), @@ -181,25 +200,20 @@ private ServerSnapshot capture(ServerProcess process) { return ServerSnapshot.of(Instant.now(), process.pid(), process.version(), sessions, windows, panes, clients); } - /** - * Runs one listing and reads its rows. - * - *

An empty server is not a failure: {@code list-sessions} reports "no server running" as a - * nonzero exit, and a capture of nothing is still a capture. - */ - private List rows(RowFormat format, String... command) { + private static List listing(RowFormat format, String... command) { List argv = new ArrayList<>(command.length + 2); argv.addAll(List.of(command)); argv.add("-F"); argv.add(format.template()); - CommandResult result = server.cmd(argv); - if (!result.succeeded()) { - if (result.stderr().stream().anyMatch(line -> line.contains("no server running"))) { - return List.of(); - } - throw new LibTmuxException("tmux " + command[0] + " failed: " + String.join("; ", result.stderr())); + return argv; + } + + /** Reads one listing's rows, insisting tmux actually ran it. */ + private static List rows(RowFormat format, OperationResult operation, String command) { + if (operation.outcome() != OperationOutcome.COMPLETE) { + throw new LibTmuxException("tmux " + command + " failed: " + String.join("; ", operation.stderr())); } - return format.rows(result.stdout()); + return format.rows(operation.stdout()); } private static WindowContext context(RowFormat.Row row) { diff --git a/libtmux/src/test/java/io/github/libtmux/GroupedTmux.java b/libtmux/src/test/java/io/github/libtmux/GroupedTmux.java new file mode 100644 index 0000000..96da294 --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/GroupedTmux.java @@ -0,0 +1,67 @@ +package io.github.libtmux; + +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The part of tmux a snapshot double has to be: a process-identity fence and a command group. + * + *

A capture arrives as one fenced group, so a double answering one listing at a time would never + * see the request the library actually sends. This runs the group the way tmux does — in order, + * stopping at the first failure — and refuses it when the fence names a different server, so a test + * exercises the fence rather than assuming it. + */ +final class GroupedTmux { + + private static final Pattern QUOTED = Pattern.compile("'([^']*)'"); + private static final Pattern FENCED_PID = Pattern.compile("#\\{==:#\\{pid},(\\d+)}"); + + private GroupedTmux() {} + + /** + * Answers one request, running any group it carries. + * + * @param livePid the server this double is pretending to be + * @param command answers one command, as tmux would + */ + static CommandResult execute(CommandRequest request, long livePid, Function, CommandResult> command) { + List argv = request.commands().get(0); + if (!argv.get(0).equals("if-shell")) { + return command.apply(argv); + } + Matcher fence = FENCED_PID.matcher(argv.get(2)); + String stale = argv.get(argv.size() - 1); + if (fence.find() && Long.parseLong(fence.group(1)) != livePid) { + return new CommandResult(1, List.of(), List.of("unknown command: " + stale)); + } + return group(argv.get(argv.size() - 2), command); + } + + /** Runs each command in turn, and discards the rest once one fails, which is what tmux does. */ + private static CommandResult group(String commands, Function, CommandResult> command) { + List stdout = new ArrayList<>(); + for (String one : commands.split(" ; ", -1)) { + List words = new ArrayList<>(); + Matcher word = QUOTED.matcher(one); + while (word.find()) { + words.add(word.group(1)); + } + if (words.isEmpty()) { + continue; + } + CommandResult answered = words.get(0).equals("display-message") && words.size() == 3 + ? new CommandResult(0, List.of(words.get(2)), List.of()) + : command.apply(words); + stdout.addAll(answered.stdout()); + if (!answered.succeeded()) { + return new CommandResult(answered.exitCode(), stdout, answered.stderr()); + } + } + return new CommandResult(0, stdout, List.of()); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/HandleTest.java b/libtmux/src/test/java/io/github/libtmux/HandleTest.java index 925c3db..d29c83d 100644 --- a/libtmux/src/test/java/io/github/libtmux/HandleTest.java +++ b/libtmux/src/test/java/io/github/libtmux/HandleTest.java @@ -16,6 +16,8 @@ import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.junit.jupiter.api.Test; /** @@ -260,6 +262,8 @@ private static String last(CountingTransport transport) { */ private static final class CountingTransport implements TmuxTransport { + private static final Pattern QUOTED = Pattern.compile("'([^']*)'"); + private final AtomicInteger calls = new AtomicInteger(); private final List requests = new ArrayList<>(); private final String firstSessionName; @@ -272,8 +276,33 @@ private static final class CountingTransport implements TmuxTransport { public CommandResult execute(CommandRequest request) { calls.incrementAndGet(); requests.add(request); - String command = request.commands().get(0).get(0); - return new CommandResult(0, rows(command), List.of()); + List argv = request.commands().get(0); + if (argv.get(0).equals("if-shell")) { + // A capture arrives as one fenced group, so answer it the way tmux runs one. + return new CommandResult(0, group(argv.get(argv.size() - 2)), List.of()); + } + return new CommandResult(0, rows(argv.get(0)), List.of()); + } + + /** Runs a quoted command group: a listing answers with rows, a marker with itself. */ + private List group(String commands) { + List answered = new ArrayList<>(); + for (String one : commands.split(" ; ", -1)) { + List words = new ArrayList<>(); + Matcher word = QUOTED.matcher(one); + while (word.find()) { + words.add(word.group(1)); + } + if (words.isEmpty()) { + continue; + } + if (words.get(0).equals("display-message")) { + answered.add(words.get(words.size() - 1)); + } else { + answered.addAll(rows(words.get(0))); + } + } + return answered; } private List rows(String command) { diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index d98e845..495944b 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -271,20 +271,19 @@ void snapshotDistinguishesAnAbsentServerFromAnIdentityProbeFailure(@TempDir Path @Test void snapshotKeepsTheIdentityOfALiveServerWithNoSessions(@TempDir Path directory) throws IOException { - AtomicInteger impossibleListings = new AtomicInteger(); + AtomicInteger requests = new AtomicInteger(); TmuxTransport transport = new TmuxTransport() { @Override public CommandResult execute(CommandRequest request) { - return switch (request.commands().get(0).getFirst()) { + requests.incrementAndGet(); + return GroupedTmux.execute(request, 4242L, argv -> switch (argv.getFirst()) { case "display-message" -> new CommandResult( 0, List.of(String.join(RowFormat.of("field").separator(), "4242", "3.2a")), List.of()); case "list-sessions" -> new CommandResult(0, List.of(), List.of()); - default -> { - impossibleListings.incrementAndGet(); - yield new CommandResult(1, List.of(), List.of("no current target")); - } - }; + // tmux has no current target to list children against, and says so. + default -> new CommandResult(1, List.of(), List.of("no current target")); + }); } @Override @@ -300,10 +299,11 @@ public void close() {} assertTrue(snapshot.windows().isEmpty()); assertTrue(snapshot.panes().isEmpty()); assertTrue(snapshot.clients().isEmpty()); - assertEquals(0, impossibleListings.get(), "tmux cannot list children without a current target"); + assertEquals(2, requests.get(), "tmux refused the rest of the group, which cost no further request"); } } + /** The fence refuses a replaced server before a listing runs, so there is no first capture. */ @Test void snapshotRetriesAChangedIncarnationAndKeepsOnlyTheSecondCapture(@TempDir Path directory) throws IOException { String separator = RowFormat.of("field").separator(); @@ -311,9 +311,7 @@ void snapshotRetriesAChangedIncarnationAndKeepsOnlyTheSecondCapture(@TempDir Pat config(directory), new SnapshotRaceTransport( List.of("4242", "4343", "4343", "4343"), - List.of( - String.join(separator, "$0", "old", "0", "0"), - String.join(separator, "$1", "new", "0", "0"))))) { + List.of(String.join(separator, "$1", "new", "0", "0"))))) { var snapshot = server.snapshot(); assertEquals(4343L, snapshot.serverPid().orElseThrow()); @@ -495,13 +493,13 @@ private record SnapshotTransport(String sessionRow) implements TmuxTransport { @Override public CommandResult execute(CommandRequest request) { - return switch (request.commands().get(0).get(0)) { + return GroupedTmux.execute(request, 4242L, argv -> switch (argv.get(0)) { case "list-sessions" -> new CommandResult(0, List.of(sessionRow), List.of()); case "display-message" -> new CommandResult( 0, List.of(String.join(RowFormat.of("field").separator(), "4242", "3.6")), List.of()); default -> new CommandResult(0, List.of(), List.of()); - }; + }); } @Override @@ -520,14 +518,29 @@ private static final class SnapshotRaceTransport implements TmuxTransport { this.sessionRows = sessionRows; } + /** + * Identities come in pairs: what a capture's probe is told, then what the server has become + * by the time its listings run. A capture is two requests, so the pair is the whole race. + */ @Override public CommandResult execute(CommandRequest request) { - return switch (request.commands().get(0).get(0)) { - case "display-message" -> identity(identities.get(identityReads.getAndIncrement())); + if (request.commands().get(0).get(0).equals("display-message")) { + return identity(at(2 * identityReads.getAndIncrement())); + } + String live = at(2 * (identityReads.get() - 1) + 1); + if (live.isEmpty()) { + return new CommandResult(1, List.of(), List.of("no server running on /tmp/s")); + } + return GroupedTmux.execute(request, Long.parseLong(live), argv -> switch (argv.get(0)) { case "list-sessions" -> new CommandResult(0, List.of(sessionRows.get(sessionReads.getAndIncrement())), List.of()); default -> new CommandResult(0, List.of(), List.of()); - }; + }); + } + + /** Clamped, so a server that has gone stays gone however often it is asked about. */ + private String at(int index) { + return identities.get(Math.min(index, identities.size() - 1)); } private static CommandResult identity(String pid) { @@ -556,10 +569,14 @@ private static final class ReplacementDuringCaptureTransport implements TmuxTran this.failure = failure; } + /** + * The server is still the one the probe named while its listings run, so the fence passes + * and the capture fails for the reason under test rather than for the replacement. + */ @Override public CommandResult execute(CommandRequest request) { boolean firstCapture = identityReads.get() == 1; - return switch (request.commands().get(0).get(0)) { + return GroupedTmux.execute(request, firstCapture ? 4242L : 4343L, argv -> switch (argv.get(0)) { case "display-message" -> identityReads.getAndIncrement() == 0 ? identity("4242", failure == CaptureFailure.PANE_SHAPE ? "3.7" : "3.6") @@ -590,7 +607,7 @@ yield new CommandResult( : List.of(), List.of()); default -> new CommandResult(0, List.of(), List.of()); - }; + }); } private static CommandResult identity(String pid, String version) { From d6338d6d2c6ce67f3d435f20fd8035dababce7ed Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:02:33 -0500 Subject: [PATCH 51/77] Test(fix[replacement]): Wait for the socket why: The case starts a replacement server on the socket the one it killed was using, and tmux answers a client reaching a server that is still exiting with "server exited unexpectedly" rather than "no server running", from 3.3a onwards. Under full-matrix load the replacement's new-session reached the dying server and the case failed for the teardown rather than for what it is about. It failed on the 3.3a lane and passed alone on both this commit and the one before it. what: - Wait for tmux to unlink the socket before opening the replacement --- .../libtmux/it/OperationsIntegrationTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java index f892538..a491b42 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java @@ -10,9 +10,11 @@ import io.github.libtmux.ObjectDoesNotExist; import io.github.libtmux.Pane; import io.github.libtmux.Server; +import io.github.libtmux.ServerEndpoint; import io.github.libtmux.Session; import io.github.libtmux.Window; import io.github.libtmux.junit5.TmuxExtension; +import java.nio.file.Files; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -151,6 +153,7 @@ void anOperationOnSomethingAlreadyGoneRaises(Server server) { void aHandleCannotMutateAReplacementServerThatReusedItsId(Server server) { Session stale = session(server); server.killServer(); + awaitSocketReleased(server); try (Server replacement = Server.open(server.config())) { try { @@ -168,6 +171,28 @@ void aHandleCannotMutateAReplacementServerThatReusedItsId(Server server) { } } + /** + * Waits for the killed server to let go of its socket. + * + *

tmux unlinks the socket as it exits, and a client reaching one whose server is still + * exiting is answered {@code server exited unexpectedly} rather than {@code no server running} + * — from 3.3a onwards. A replacement on the same path has to be started after that, or the + * test measures the teardown rather than the thing it is about. + */ + private static void awaitSocketReleased(Server server) { + if (!(server.config().endpoint() instanceof ServerEndpoint.SocketPath socket)) { + return; + } + for (int attempt = 0; attempt < 200 && Files.exists(socket.path()); attempt++) { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + private static boolean awaitOutput(Pane pane, String expected) { for (int attempt = 0; attempt < 100; attempt++) { if (pane.capture().stream().anyMatch(line -> line.contains(expected))) { From 1c94058a4300ec04eb7ade6b22736d54ac9583f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:02:50 -0500 Subject: [PATCH 52/77] Test(fix[literal]): Reach the clear first why: The case makes the shell acknowledge the function definition, but not the clear between that and the marker it asserts on, so under full-matrix load the assertion could run against a screen the shell had not caught up with. It failed that way on the 3.2a lane and passed alone, which is the second time this case has been load-only. what: - Acknowledge the clear the same way the definition is acknowledged --- .../java/io/github/libtmux/it/OperationsIntegrationTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java index a491b42..7027f56 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java @@ -82,7 +82,10 @@ void aLineThatIsAKeyNameIsTypedLiterally(Server server) { // failure reports that Enter was pressed when the line was typed before anything was reading. pane.sendLine("echo defined-the-function"); assertTrue(awaitOutput(pane, "defined-the-function"), "the shell never read the definition"); + // Clearing is what makes the marker below unambiguous, so it too has to have happened. pane.sendLine("clear"); + pane.sendLine("echo cleared-the-screen"); + assertTrue(awaitOutput(pane, "cleared-the-screen"), "the shell never reached the clear"); pane.sendLine("Enter"); assertDoesNotThrow(() -> pane.sendLine("-R"), "a line is not a send-keys option"); From 2bb6d20c9faabcc6f7ce1975bbe32ac657d5b5d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:18:55 -0500 Subject: [PATCH 53/77] Test(refactor[await]): Wait in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Thirteen files each carried the same poll loop with the same five-second bound, and a lane of the matrix runs the whole suite on a machine the other lanes are also using. That bound is only ever spent when something is already wrong, so a tight one reports the load rather than the library — which is how the literal-line case failed on two different lanes on two consecutive runs. what: - Await.until and Await.output replace thirteen copies, 147 lines fewer - Raise the bound to fifteen seconds, in the one place it now lives - Keep the subscription waiter in ExamplesTest: a stream is drained rather than polled --- .../test/java/io/github/libtmux/it/Await.java | 41 ++++++++++++++ .../it/BuffersAndClientIntegrationTest.java | 27 +++------- .../libtmux/it/CaptureIntegrationTest.java | 15 +----- .../it/ClientOperationsIntegrationTest.java | 27 +++------- .../it/CommandChainIntegrationTest.java | 20 ++----- .../libtmux/it/CreationIntegrationTest.java | 19 ++----- .../it/EnvironmentIntegrationTest.java | 13 +---- .../io/github/libtmux/it/ExamplesTest.java | 53 +++++++------------ .../libtmux/it/NavigationIntegrationTest.java | 15 +----- .../libtmux/it/OperationsIntegrationTest.java | 23 ++------ .../it/PaneProcessIntegrationTest.java | 22 +++----- .../it/ServerControlIntegrationTest.java | 19 ++----- .../it/ServerScriptingIntegrationTest.java | 13 +---- .../libtmux/it/SplitIntegrationTest.java | 17 ++---- 14 files changed, 109 insertions(+), 215 deletions(-) create mode 100644 integration-tests/src/test/java/io/github/libtmux/it/Await.java diff --git a/integration-tests/src/test/java/io/github/libtmux/it/Await.java b/integration-tests/src/test/java/io/github/libtmux/it/Await.java new file mode 100644 index 0000000..b4ba55b --- /dev/null +++ b/integration-tests/src/test/java/io/github/libtmux/it/Await.java @@ -0,0 +1,41 @@ +package io.github.libtmux.it; + +import io.github.libtmux.Pane; +import java.util.function.BooleanSupplier; + +/** + * Waits for tmux, and for the shells running inside it, to catch up. + * + *

A pane's output arrives when its shell is scheduled, which this suite does not control and a + * matrix lane makes slower: every release runs the whole suite on the same machine. The budget is + * therefore generous rather than tight — it is only ever spent when something is already wrong, and + * a bound too small for a loaded machine reports the load rather than the library. + */ +final class Await { + + private static final int ATTEMPTS = 300; + private static final long INTERVAL_MILLIS = 50; + + private Await() {} + + /** Whether the condition held within the budget. */ + static boolean until(BooleanSupplier condition) { + for (int attempt = 0; attempt < ATTEMPTS; attempt++) { + if (condition.getAsBoolean()) { + return true; + } + try { + Thread.sleep(INTERVAL_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return false; + } + + /** Whether the pane showed the text within the budget. */ + static boolean output(Pane pane, String expected) { + return until(() -> pane.capture().stream().anyMatch(line -> line.contains(expected))); + } +} diff --git a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java index f7afaf7..60e4d40 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java @@ -21,7 +21,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.function.BooleanSupplier; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -129,7 +128,7 @@ void pastingPutsABufferIntoAPane(Server server) throws Exception { pane.pasteBuffer("typed"); assertTrue( - await(() -> pane.capture().stream().anyMatch(line -> line.contains("pasted-this"))), + Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains("pasted-this"))), "the buffer never reached the pane"); } @@ -150,7 +149,7 @@ void pastingTextLeavesNothingInTheBufferStack(Server server) throws Exception { pane.paste("echo pasted-text\n"); assertTrue( - await(() -> pane.capture().stream().anyMatch(line -> line.contains("pasted-text"))), + Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains("pasted-text"))), "the text never reached the pane"); assertEquals( List.of("belongs-to-the-user"), @@ -170,7 +169,7 @@ void pastedTextReachesThePaneExactly(Server server) throws Exception { pane.paste("printf 'a;b \"c\" d\\n'\n"); assertTrue( - await(() -> pane.capture().stream().anyMatch(line -> line.contains("a;b \"c\" d"))), + Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains("a;b \"c\" d"))), "the text did not arrive as written"); assertThrows(IllegalArgumentException.class, () -> pane.paste("has\0nul"), "NUL is not typeable"); } @@ -186,7 +185,7 @@ void pastedTextIsNotBoundedByTheSizeOfACommand(Server server) throws Exception { pane.paste("y".repeat(20_000) + "END-OF-A-LARGE-PASTE"); assertTrue( - await(() -> pane.capture().stream().anyMatch(line -> line.contains("END-OF-A-LARGE-PASTE"))), + Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains("END-OF-A-LARGE-PASTE"))), "text larger than a tmux command never arrived"); } @@ -229,7 +228,7 @@ void sourcingAFileRunsTheCommandsInIt(Server server, @TempDir Path directory) th void anAttachedClientReportsWhatItIsLookingAt(Server server) throws Exception { Session session = server.sessions().get(0); try (ControlClient attached = ControlClient.attach(server.config(), session.id())) { - assertTrue(await(() -> !server.clients().isEmpty()), "the control client never appeared as a client"); + assertTrue(Await.until(() -> !server.clients().isEmpty()), "the control client never appeared as a client"); Client client = server.clients().get(0); ClientAttachment looking = client.attachment().orElseThrow(); @@ -249,7 +248,7 @@ void fetchingAnAttachmentTakesAFreshLook(Server server) throws Exception { Session session = server.sessions().get(0); try (ControlClient attached = ControlClient.attach(server.config(), session.id())) { assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> !server.clients().isEmpty())); + assertTrue(Await.until(() -> !server.clients().isEmpty())); Client client = server.clients().get(0); String before = client.attachment().orElseThrow().activeWindow().name(); @@ -274,11 +273,11 @@ void aClientThatHasGoneRefreshesToNothing(Server server) throws Exception { Client client; try (ControlClient attached = ControlClient.attach(server.config(), session.id())) { assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> appeared(server, before).isPresent()), "no client ever attached"); + assertTrue(Await.until(() -> appeared(server, before).isPresent()), "no client ever attached"); client = appeared(server, before).orElseThrow(); } - assertTrue(await(() -> client.refresh().isEmpty()), "the client outlived the connection that made it"); + assertTrue(Await.until(() -> client.refresh().isEmpty()), "the client outlived the connection that made it"); assertEquals(Optional.empty(), client.fetchAttachment()); } @@ -288,14 +287,4 @@ private static Optional appeared(Server server, Set before) { .filter(client -> !before.contains(client.name())) .findFirst(); } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CaptureIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CaptureIntegrationTest.java index 87e8668..4b93410 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/CaptureIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/CaptureIntegrationTest.java @@ -12,7 +12,6 @@ import io.github.libtmux.UnsupportedTmuxVersion; import io.github.libtmux.junit5.TmuxExtension; import java.util.List; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,7 +40,7 @@ private static Pane generated(Server server) throws InterruptedException { .split(s -> s.running("sh", "-c", "for i in 1 2 3 4 5 6 7 8 9 10 11 12; do echo line-$i; done; sleep 60")); assertTrue( - await(() -> + Await.until(() -> pane.capture(c -> c.fromStartOfHistory()).stream().anyMatch(line -> line.contains("line-12"))), "the pane never printed what it was told to"); return pane; @@ -110,7 +109,7 @@ void preservingTrailingSpaceKeepsWhatAPlainReadDrops(Server server) throws Excep Session session = server.sessions().get(0); Pane pane = session.windows().get(0).split(s -> s.running("sh", "-c", "printf 'padded \\n'; sleep 60")); assertTrue( - await(() -> pane.capture().stream().anyMatch(line -> line.contains("padded"))), + Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains("padded"))), "the pane never printed the padded line"); String plain = lineWith(pane.capture(), "padded"); @@ -179,14 +178,4 @@ void aRefusalReadsNothingAndLeavesThePaneAlone(Server server) throws Exception { assertEquals(before, pane.capture().size(), "the pane changed under a refused read"); } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ClientOperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ClientOperationsIntegrationTest.java index 2ce7a96..12f75bb 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ClientOperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ClientOperationsIntegrationTest.java @@ -15,7 +15,6 @@ import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.function.BooleanSupplier; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,12 +40,12 @@ void aClientCanBeDetachedAndTheSessionSurvives(Server server) throws Exception { try (ControlClient attached = ControlClient.attach(server.config(), session.id())) { assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> appeared(server, before).isPresent()), "no client ever attached"); + assertTrue(Await.until(() -> appeared(server, before).isPresent()), "no client ever attached"); Client client = appeared(server, before).orElseThrow(); client.detach(); - assertTrue(await(() -> appeared(server, before).isEmpty()), "the client is still attached"); + assertTrue(Await.until(() -> appeared(server, before).isEmpty()), "the client is still attached"); assertTrue(server.isAlive(), "detaching is not killing"); assertTrue( server.sessions().stream().anyMatch(seen -> seen.id().equals(session.id())), @@ -61,13 +60,13 @@ void aClientCanBeMovedToAnotherSession(Server server) throws Exception { try (ControlClient attached = ControlClient.attach(server.config(), first.id())) { assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> !server.clients().isEmpty())); + assertTrue(Await.until(() -> !server.clients().isEmpty())); Client client = server.clients().get(0); client.switchTo(second); assertTrue( - await(() -> server.clients().stream() + Await.until(() -> server.clients().stream() .findFirst() .flatMap(Client::fetchAttachment) .map(seen -> seen.session().id().equals(second.id())) @@ -82,7 +81,7 @@ void redrawingIsNotTheSameAsRecapturing(Server server) throws Exception { try (ControlClient attached = ControlClient.attach(server.config(), session.id())) { assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> !server.clients().isEmpty())); + assertTrue(Await.until(() -> !server.clients().isEmpty())); Client client = server.clients().get(0); client.redraw(); @@ -99,12 +98,12 @@ void detachingEveryOtherClientLeavesThisOneAttached(Server server) throws Except ControlClient two = ControlClient.attach(server.config(), session.id())) { assertTrue(one.send("display-message", "-p", "ready").succeeded()); assertTrue(two.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> server.clients().size() >= 2), "two clients never attached"); + assertTrue(Await.until(() -> server.clients().size() >= 2), "two clients never attached"); Client survivor = server.clients().get(0); survivor.detachOthers(); - assertTrue(await(() -> server.clients().size() == 1), "the others are still attached"); + assertTrue(Await.until(() -> server.clients().size() == 1), "the others are still attached"); assertEquals(survivor.name(), server.clients().get(0).name(), "and the survivor is the one that asked"); } } @@ -122,7 +121,7 @@ void onlyTheSessionsSomebodyIsAttachedToAreListed(Server server) throws Exceptio try (ControlClient attached = ControlClient.attach(server.config(), watched.id())) { assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> !server.attachedSessions().isEmpty()), "no session ever became attached"); + assertTrue(Await.until(() -> !server.attachedSessions().isEmpty()), "no session ever became attached"); List listed = server.attachedSessions().stream().map(Session::id).toList(); @@ -167,14 +166,4 @@ private static Optional appeared(Server server, Set before) { .filter(client -> !before.contains(client.name())) .findFirst(); } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java index 66b3be5..4f31743 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/CommandChainIntegrationTest.java @@ -12,7 +12,6 @@ import io.github.libtmux.batch.OperationResult; import io.github.libtmux.junit5.TmuxExtension; import java.util.List; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -43,7 +42,8 @@ void eachStepActsOnWhatTheLastOneMade(Server server) throws Exception { List panes = built.panes(); assertTrue( - await(() -> panes.get(1).capture().stream().anyMatch(line -> line.contains("chained-landed-here"))), + Await.until( + () -> panes.get(1).capture().stream().anyMatch(line -> line.contains("chained-landed-here"))), "the keys went to the pane the split produced, not to the one the chain started from"); } @@ -64,7 +64,7 @@ void aLineThatIsAKeyNameStaysLiteralInsideAChain(Server server) throws Exception // The shell has to have read the definition before the name is used. A chain is one // invocation, so without this the name is typed before anything is reading for it. assertTrue( - await(() -> pane.capture().stream().anyMatch(line -> line.contains("defined-the-function"))), + Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains("defined-the-function"))), "the shell never read the definition"); BatchResult result = server.chain() @@ -76,10 +76,10 @@ void aLineThatIsAKeyNameStaysLiteralInsideAChain(Server server) throws Exception assertTrue(result.succeeded(), result.toString()); assertTrue( - await(() -> pane.capture().stream().anyMatch(line -> line.contains("literal-chain-enter"))), + Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains("literal-chain-enter"))), "Enter was pressed instead of typed"); assertTrue( - await(() -> pane.capture().stream().anyMatch(line -> line.contains("literal-chain-semicolon"))), + Await.until(() -> pane.capture().stream().anyMatch(line -> line.contains("literal-chain-semicolon"))), "a trailing semicolon became a command-group separator"); } @@ -139,14 +139,4 @@ void aRecognisedLayoutIsApplied(Server server) { assertTrue(result.succeeded(), result.toString()); } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/CreationIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/CreationIntegrationTest.java index 4e9290e..6c6a9fd 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/CreationIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/CreationIntegrationTest.java @@ -19,7 +19,6 @@ import io.github.libtmux.junit5.TmuxExtension; import java.nio.file.Files; import java.nio.file.Path; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -46,7 +45,7 @@ void aWindowCanBeNamedAndGivenACommand(Server server) throws InterruptedExceptio assertEquals("logs", logs.name()); Pane pane = logs.activePane().orElseThrow(); assertTrue( - await(() -> "sleep".equals(pane.refresh().currentCommand())), + Await.until(() -> "sleep".equals(pane.refresh().currentCommand())), "the window's first pane never reported the command"); } @@ -138,7 +137,7 @@ void aStartDirectoryIsHonouredOrRefusedDependingOnTheRelease(Server server, @Tem Pane pane = window.activePane().orElseThrow(); assertTrue( - await(() -> real.equals(pane.refresh().currentPath())), + Await.until(() -> real.equals(pane.refresh().currentPath())), "the window did not start where it was told"); } else { assertThrows( @@ -159,7 +158,7 @@ void aWindowInheritsTheEnvironmentItWasGiven(Server server, @TempDir Path direct .env("LIBTMUX_W", "carried") .running("sh", "-c", "printf '%s' \"$LIBTMUX_W\" > " + written + "; sleep 30")); - assertTrue(await(() -> Files.exists(written)), "the command never ran"); + assertTrue(Await.until(() -> Files.exists(written)), "the command never ran"); assertEquals("carried", Files.readString(written)); } @@ -173,7 +172,7 @@ void aSessionCanNameItsFirstWindowAndRunSomethingInIt(Server server) throws Inte assertEquals("built", built.name()); assertEquals("editor", built.windows().get(0).name()); Pane pane = built.activePane().orElseThrow(); - assertTrue(await(() -> "sleep".equals(pane.refresh().currentCommand()))); + assertTrue(Await.until(() -> "sleep".equals(pane.refresh().currentCommand()))); } /** 3.2a accepts {@code -x}/{@code -y} for a detached session and gives it the default size. */ @@ -238,14 +237,4 @@ void aSessionSpecIsADescriptionThatCanBeAppliedTwice(Server server) { assertEquals("main", first.windows().get(0).name()); assertEquals("main", second.windows().get(0).name()); } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/EnvironmentIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/EnvironmentIntegrationTest.java index 3cab696..e689ad3 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/EnvironmentIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/EnvironmentIntegrationTest.java @@ -17,7 +17,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -89,7 +88,7 @@ private static Reported reportedFrom(Window window, Path report) throws Interrup Pane pane = window.split( s -> s.running("sh", "-c", "printf '%s\\n%s\\n' \"$TMUX\" \"$TMUX_PANE\" > " + report + "; sleep 30")); - assertTrue(await(() -> lines(report).size() >= 2), "the pane never reported its environment"); + assertTrue(Await.until(() -> lines(report).size() >= 2), "the pane never reported its environment"); Map exported = new HashMap<>(); exported.put("TMUX", lines(report).get(0)); @@ -107,14 +106,4 @@ private static List lines(Path file) { return List.of(); } } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ExamplesTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ExamplesTest.java index cc0e76f..022a257 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ExamplesTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ExamplesTest.java @@ -27,7 +27,6 @@ import java.time.Duration; import java.util.List; import java.util.Optional; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -58,7 +57,7 @@ void quickstart(@TempDir Path directory) throws Exception { pane.sendLine("echo hello from libtmux"); - assertTrue(awaitOutput(pane, "hello from libtmux")); + assertTrue(Await.output(pane, "hello from libtmux")); assertEquals("demo", session.name()); server.killServer(); } @@ -73,7 +72,7 @@ void describingWhatYouCreate(Server server) throws Exception { assertEquals("editor", build.windows().get(0).name()); assertEquals("logs", logs.name()); assertTrue( - await(() -> + Await.until(() -> "sleep".equals(logs.activePane().orElseThrow().refresh().currentCommand())), "the window ran what it was given"); } @@ -88,7 +87,7 @@ void describingASplit(Server server, @TempDir Path directory) throws Exception { Pane app = pane.split(s -> s.running("sleep", "30").in(directory)); assertTrue(side.edges().right()); - assertTrue(await(() -> "sleep".equals(app.refresh().currentCommand()))); + assertTrue(Await.until(() -> "sleep".equals(app.refresh().currentCommand()))); Session session = server.sessions().get(0); SplitSpec sidebar = SplitSpec.builder().toRight().percent(25).build(); @@ -248,10 +247,25 @@ void streaming(Server server) throws Exception { client.send("send-keys", "-t", session.name(), "echo streamed", "Enter"); - assertTrue(awaitOutput(output, "streamed")); + assertTrue(streamed(output, "streamed")); } } + /** A subscription is drained rather than polled, so it waits differently from a screen. */ + private static boolean streamed(EventSubscription output, String expected) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); + while (System.nanoTime() < deadline) { + var next = output.next(Duration.ofNanos(Math.max(0L, deadline - System.nanoTime()))); + if (next.isEmpty()) { + return false; + } + if (next.orElseThrow().data().contains(expected)) { + return true; + } + } + return false; + } + /** Guide: options are read at the scope tmux will act on. */ @Test void options(Server server) { @@ -279,33 +293,4 @@ void pinningAConfigFile(@TempDir Path directory) throws Exception { server.killServer(); } } - - private static boolean awaitOutput(Pane pane, String expected) throws InterruptedException { - return await(() -> pane.capture().stream().anyMatch(line -> line.contains(expected))); - } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } - - private static boolean awaitOutput(EventSubscription output, String expected) - throws InterruptedException { - long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); - while (System.nanoTime() < deadline) { - var next = output.next(Duration.ofNanos(Math.max(0L, deadline - System.nanoTime()))); - if (next.isEmpty()) { - return false; - } - if (next.orElseThrow().data().contains(expected)) { - return true; - } - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/NavigationIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/NavigationIntegrationTest.java index 41913ea..d914877 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/NavigationIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/NavigationIntegrationTest.java @@ -14,7 +14,6 @@ import io.github.libtmux.WindowId; import io.github.libtmux.control.ControlClient; import io.github.libtmux.junit5.TmuxExtension; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -89,11 +88,11 @@ void detachingLeavesTheSessionRunning(Server server) throws Exception { Session session = server.sessions().get(0); try (ControlClient attached = ControlClient.attach(server.config(), session.id())) { assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> !server.clients().isEmpty())); + assertTrue(Await.until(() -> !server.clients().isEmpty())); session.detachClients(); - assertTrue(await(() -> server.clients().isEmpty()), "the client went"); + assertTrue(Await.until(() -> server.clients().isEmpty()), "the client went"); assertTrue(server.hasSession(session.name()), "and the session stayed"); } } @@ -131,14 +130,4 @@ void aResizeOfNothingIsRejected(Server server) { assertThrows(IllegalArgumentException.class, () -> pane.resize(Direction.UP, 0)); } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java index 7027f56..71a8692 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java @@ -71,7 +71,7 @@ void aPaneRunsWhatItIsSent(Server server) { pane.sendLine("echo libtmux-was-here"); - assertTrue(awaitOutput(pane, "libtmux-was-here"), "the pane never showed the command's output"); + assertTrue(Await.output(pane, "libtmux-was-here"), "the pane never showed the command's output"); } @Test @@ -81,16 +81,16 @@ void aLineThatIsAKeyNameIsTypedLiterally(Server server) { // The shell has to have read the definition before the name is used. Without this the same // failure reports that Enter was pressed when the line was typed before anything was reading. pane.sendLine("echo defined-the-function"); - assertTrue(awaitOutput(pane, "defined-the-function"), "the shell never read the definition"); + assertTrue(Await.output(pane, "defined-the-function"), "the shell never read the definition"); // Clearing is what makes the marker below unambiguous, so it too has to have happened. pane.sendLine("clear"); pane.sendLine("echo cleared-the-screen"); - assertTrue(awaitOutput(pane, "cleared-the-screen"), "the shell never reached the clear"); + assertTrue(Await.output(pane, "cleared-the-screen"), "the shell never reached the clear"); pane.sendLine("Enter"); assertDoesNotThrow(() -> pane.sendLine("-R"), "a line is not a send-keys option"); - assertTrue(awaitOutput(pane, "literal-enter-command"), "Enter was pressed instead of typed"); + assertTrue(Await.output(pane, "literal-enter-command"), "Enter was pressed instead of typed"); } @Test @@ -195,19 +195,4 @@ private static void awaitSocketReleased(Server server) { } } } - - private static boolean awaitOutput(Pane pane, String expected) { - for (int attempt = 0; attempt < 100; attempt++) { - if (pane.capture().stream().anyMatch(line -> line.contains(expected))) { - return true; - } - try { - Thread.sleep(50); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return false; - } - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PaneProcessIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PaneProcessIntegrationTest.java index 6a92d90..69c864c 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PaneProcessIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PaneProcessIntegrationTest.java @@ -11,7 +11,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -65,7 +64,8 @@ void respawningReplacesTheProcessInThePane(Server server) throws InterruptedExce pane.respawn(); assertTrue( - await(() -> pane.refresh().pid() != before), "the pane kept process " + before + " through a respawn"); + Await.until(() -> pane.refresh().pid() != before), + "the pane kept process " + before + " through a respawn"); assertEquals(pane.id(), pane.refresh().id(), "and it is still the same pane"); } @@ -76,7 +76,7 @@ void respawningWithACommandRunsThatCommand(Server server) throws InterruptedExce pane.respawn("sleep", "30"); assertTrue( - await(() -> "sleep".equals(pane.refresh().currentCommand())), + Await.until(() -> "sleep".equals(pane.refresh().currentCommand())), "the pane never reported the command it was respawned with"); } @@ -104,7 +104,7 @@ void aPipedPaneSendsWhatItPrintsToTheCommand(Server server, @TempDir Path direct pane.pipeTo("cat > " + captured); pane.sendLine("echo piped-marker"); - assertTrue(await(() -> contains(captured, "piped-marker")), "nothing reached the pipe"); + assertTrue(Await.until(() -> contains(captured, "piped-marker")), "nothing reached the pipe"); } @Test @@ -114,7 +114,7 @@ void stoppingThePipeStopsTheOutput(Server server, @TempDir Path directory) throw pane.pipeTo("cat > " + captured); pane.sendLine("echo before-stop"); - assertTrue(await(() -> contains(captured, "before-stop")), "the pipe never started"); + assertTrue(Await.until(() -> contains(captured, "before-stop")), "the pipe never started"); pane.stopPiping(); pane.sendLine("echo after-stop"); @@ -140,7 +140,7 @@ void aSecondPipeReplacesTheFirstRatherThanAddingToIt(Server server, @TempDir Pat pane.pipeTo("cat > " + second); pane.sendLine("echo only-once"); - assertTrue(await(() -> contains(second, "only-once")), "the second pipe never received anything"); + assertTrue(Await.until(() -> contains(second, "only-once")), "the second pipe never received anything"); assertTrue(!contains(first, "only-once"), "tmux keeps one pipe per pane, not a list"); assertNotEquals(first, second); } @@ -158,14 +158,4 @@ private static boolean contains(Path file, String text) { return false; } } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ServerControlIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ServerControlIntegrationTest.java index a6fc55f..a5fde90 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ServerControlIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ServerControlIntegrationTest.java @@ -11,7 +11,6 @@ import io.github.libtmux.UnsupportedTmuxVersion; import io.github.libtmux.control.ControlClient; import io.github.libtmux.junit5.TmuxExtension; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -41,7 +40,8 @@ void aTrueConditionRunsTheCommandItGuards(Server server) throws Exception { server.ifShell("true", "rename-window then-ran"); assertTrue( - await(() -> "then-ran".equals(session.refresh().windows().get(0).name())), + Await.until(() -> + "then-ran".equals(session.refresh().windows().get(0).name())), "the guarded command never ran"); } @@ -52,7 +52,8 @@ void aFalseConditionRunsTheOtherOne(Server server) throws Exception { server.ifShell("false", "rename-window then-ran", "rename-window else-ran"); assertTrue( - await(() -> "else-ran".equals(session.refresh().windows().get(0).name())), + Await.until(() -> + "else-ran".equals(session.refresh().windows().get(0).name())), "the other command never ran"); } @@ -101,7 +102,7 @@ void anOlderReleaseAnswersOnceAClientIsAttached(Server server) throws Exception try (ControlClient attached = ControlClient.attach(server.config(), session.id())) { assertTrue(attached.send("display-message", "-p", "ready").succeeded()); - assertTrue(await(() -> !server.clients().isEmpty()), "no client ever attached"); + assertTrue(Await.until(() -> !server.clients().isEmpty()), "no client ever attached"); assertTrue(!server.messages().isEmpty(), "with a client attached the log is readable after all"); } @@ -139,14 +140,4 @@ void exactlyTheOldestReleaseRefuses(Server server) { !server.version().atLeast(PROMPT_HISTORY_SINCE), "lane " + lane + " disagrees with the version rule"); } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ServerScriptingIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ServerScriptingIntegrationTest.java index b24c74a..24b14ff 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ServerScriptingIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ServerScriptingIntegrationTest.java @@ -11,7 +11,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -68,7 +67,7 @@ void aShellCommandRunsForItsEffectOnEveryRelease(Server server, @TempDir Path di server.runShell("touch " + touched); - assertTrue(await(() -> Files.exists(touched)), "the command never ran"); + assertTrue(Await.until(() -> Files.exists(touched)), "the command never ran"); } @Test @@ -114,14 +113,4 @@ void theServerListsTheCommandsItKnows(Server server) { assertTrue( commands.stream().anyMatch(line -> line.startsWith("split-window")), "split-window is not among them"); } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } diff --git a/integration-tests/src/test/java/io/github/libtmux/it/SplitIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/SplitIntegrationTest.java index b5be811..a97b6f2 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/SplitIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/SplitIntegrationTest.java @@ -16,7 +16,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import java.util.function.BooleanSupplier; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; @@ -104,7 +103,7 @@ void aCommandAndAnEnvironmentAndADirectoryAllReachTheNewPane(Server server, @Tem .env("LIBTMUX_PROBE", "carried") .running("sh", "-c", "printf '%s' \"$LIBTMUX_PROBE\" > seen; sleep 30")); - assertTrue(await(() -> Files.exists(written)), "the command never ran in the directory it was given"); + assertTrue(Await.until(() -> Files.exists(written)), "the command never ran in the directory it was given"); assertEquals("carried", read(written), "the pane did not inherit the variable"); } @@ -113,7 +112,7 @@ void aPaneRunningACommandReportsThatCommand(Server server) throws InterruptedExc Pane created = onlyPane(server).split(s -> s.running("sleep", "30")); assertTrue( - await(() -> "sleep".equals(created.refresh().currentCommand())), + Await.until(() -> "sleep".equals(created.refresh().currentCommand())), "the pane never reported the command it was started with"); } @@ -195,7 +194,7 @@ void keepingAPaneAfterItsCommandExitsIsCreatedOrRefused(Server server) throws In Pane created = original.split(s -> s.keepOnExit().running("true")); assertTrue( - await(() -> created.window().panes().size() == 2), + Await.until(() -> created.window().panes().size() == 2), "the pane closed even though it was asked to stay"); } else { assertThrows(UnsupportedTmuxVersion.class, () -> original.split(s -> s.keepOnExit())); @@ -258,14 +257,4 @@ private static String read(Path file) { throw new AssertionError("could not read " + file, e); } } - - private static boolean await(BooleanSupplier condition) throws InterruptedException { - for (int attempt = 0; attempt < 100; attempt++) { - if (condition.getAsBoolean()) { - return true; - } - Thread.sleep(50); - } - return false; - } } From 256c8e2fcc518500ef7b3e87a8381641366060d4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:21:25 -0500 Subject: [PATCH 54/77] Docs(docs[spikes]): Mark what no longer exists why: A reader could not tell a current spike from one describing a design that has since been removed, and four of them describe types that are gone: the execution modes, their seam, the metamodel base class, and the six-command capture. what: - Head each with what survived and what did not - State in the snapshots guide what a capture now costs, and where the number comes from --- docs/guide/snapshots-and-handles.md | 6 ++++++ docs/spikes/03-hydration.md | 7 +++++++ docs/spikes/04-query-metamodel.md | 6 ++++++ docs/spikes/19-execution-mode-seam.md | 8 ++++++++ docs/spikes/20-mode-taxonomy.md | 7 +++++++ 5 files changed, 34 insertions(+) diff --git a/docs/guide/snapshots-and-handles.md b/docs/guide/snapshots-and-handles.md index 80d52f2..c18b599 100644 --- a/docs/guide/snapshots-and-handles.md +++ b/docs/guide/snapshots-and-handles.md @@ -21,6 +21,12 @@ That is deliberate. tmux offers no transaction across separate listings, so a traversal that re-queried could observe a hierarchy that never existed — a window in one listing and its panes from after it closed. +A capture costs two tmux commands: one asks which server this is, and one runs the +four listings as a group fenced against that answer. Because tmux runs a group +inside the server, the rows cannot come from two of them, and a server replaced +under the capture is refused rather than half-read. What that costs is measured in +[`docs/benchmarks/operations.md`](../benchmarks/operations.md). + `refresh()` is how to look again. `server.snapshot()` is the strict form: it raises when a listing failed, where the list accessors answer with an empty list. Use `isAlive()` or `raiseIfDead()` to tell an empty server from an absent one. diff --git a/docs/spikes/03-hydration.md b/docs/spikes/03-hydration.md index f087fbf..946ca44 100644 --- a/docs/spikes/03-hydration.md +++ b/docs/spikes/03-hydration.md @@ -1,5 +1,12 @@ # Immutable hierarchy hydration +> **Superseded in part.** The shape below still holds: one server-wide listing per +> entity kind, and membership left to tmux. What changed is the cost. The four +> listings now travel as one command group fenced against a process identity read +> first, so a capture is two commands rather than six, and a server replaced under +> one is refused before a listing runs rather than detected by a second probe +> afterwards. + ## Verdict Capture one server-wide listing per entity kind — sessions, windows, panes, diff --git a/docs/spikes/04-query-metamodel.md b/docs/spikes/04-query-metamodel.md index ea4bb37..5c2f571 100644 --- a/docs/spikes/04-query-metamodel.md +++ b/docs/spikes/04-query-metamodel.md @@ -1,5 +1,11 @@ # Query expressions and the typed metamodel +> **Superseded in part.** `EntityMetamodel` and `FieldProvenance` no longer exist: +> handles are opaque values minted through `Fields`, with no base class and no +> global provenance bit. The decision this note is actually about — no generator, +> because the metamodel is small explicit domain code guarded by a reflective +> conformance test — still stands, and `MetamodelConformanceTest` is that guard. + ## Status Historical evidence. The expression semantics and pushdown measurements remain diff --git a/docs/spikes/19-execution-mode-seam.md b/docs/spikes/19-execution-mode-seam.md index bbf3280..2957420 100644 --- a/docs/spikes/19-execution-mode-seam.md +++ b/docs/spikes/19-execution-mode-seam.md @@ -1,5 +1,13 @@ # Where an execution mode would plug in +> **Superseded.** There is no execution mode to plug in. The carriers this note +> designed a seam for were removed: `VIRTUAL` did not free the carrier it claimed +> to, and `CONTROL` routed by command name, which a user's `command-alias` can +> defeat. Measured later, `if-shell` answers with its own reply block plus one for +> whichever branch ran, so a control client cannot even know how many replies a +> request will produce. What survives is the observation that the entity layer is +> carrier-agnostic — which is why `Server.using` still takes a transport. + ## Verdict The seam is one method, and the entity layer is already mode-agnostic. Making diff --git a/docs/spikes/20-mode-taxonomy.md b/docs/spikes/20-mode-taxonomy.md index f66d6fb..b3aa54b 100644 --- a/docs/spikes/20-mode-taxonomy.md +++ b/docs/spikes/20-mode-taxonomy.md @@ -1,5 +1,12 @@ # Two modes, not five, and no per-call override +> **Superseded.** `ExecutionMode` is gone, and with it the taxonomy below. One +> carrier remains, and the round trips this note wanted modes to collapse are +> collapsed by `batch()` and `chain()` instead — measured in +> [`docs/benchmarks/operations.md`](../benchmarks/operations.md). The reasoning +> about which commands a control client cannot carry still reads true; it was the +> switch, not the analysis, that did not survive. + ## Verdict `ExecutionMode` has `DIRECT` and `CONTROL`. Batching, chaining and virtual From c413696199c42adb3e37a8e7df8211b2e6a2f062 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:22:57 -0500 Subject: [PATCH 55/77] Test(refactor[double]): Share the group emulator why: HandleTest grew its own parser for the fenced group a capture now sends, and GroupedTmux was extracted for exactly that a few commits later. Two readings of tmux's group semantics can disagree, which is the failure the shared one exists to prevent. what: - HandleTest answers through GroupedTmux like the other doubles --- .../java/io/github/libtmux/HandleTest.java | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/libtmux/src/test/java/io/github/libtmux/HandleTest.java b/libtmux/src/test/java/io/github/libtmux/HandleTest.java index d29c83d..9e7c58e 100644 --- a/libtmux/src/test/java/io/github/libtmux/HandleTest.java +++ b/libtmux/src/test/java/io/github/libtmux/HandleTest.java @@ -16,8 +16,6 @@ import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.junit.jupiter.api.Test; /** @@ -262,8 +260,6 @@ private static String last(CountingTransport transport) { */ private static final class CountingTransport implements TmuxTransport { - private static final Pattern QUOTED = Pattern.compile("'([^']*)'"); - private final AtomicInteger calls = new AtomicInteger(); private final List requests = new ArrayList<>(); private final String firstSessionName; @@ -276,33 +272,7 @@ private static final class CountingTransport implements TmuxTransport { public CommandResult execute(CommandRequest request) { calls.incrementAndGet(); requests.add(request); - List argv = request.commands().get(0); - if (argv.get(0).equals("if-shell")) { - // A capture arrives as one fenced group, so answer it the way tmux runs one. - return new CommandResult(0, group(argv.get(argv.size() - 2)), List.of()); - } - return new CommandResult(0, rows(argv.get(0)), List.of()); - } - - /** Runs a quoted command group: a listing answers with rows, a marker with itself. */ - private List group(String commands) { - List answered = new ArrayList<>(); - for (String one : commands.split(" ; ", -1)) { - List words = new ArrayList<>(); - Matcher word = QUOTED.matcher(one); - while (word.find()) { - words.add(word.group(1)); - } - if (words.isEmpty()) { - continue; - } - if (words.get(0).equals("display-message")) { - answered.add(words.get(words.size() - 1)); - } else { - answered.addAll(rows(words.get(0))); - } - } - return answered; + return GroupedTmux.execute(request, 4242L, argv -> new CommandResult(0, rows(argv.get(0)), List.of())); } private List rows(String command) { From 71ddc25c88e9b71b2f03e5396602ccde07ae43ea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:28:59 -0500 Subject: [PATCH 56/77] Snapshot(fix[fence]): Check the whole identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The second identity probe this replaced compared a pid and a version; the fence that replaced it compared only the pid. A pid is reusable, so a different tmux landing on the one just probed would have answered as the server the rows were being read from — which the form it replaced would have caught, and which its own contract claimed. what: - Fence a capture on pid and version together - Refuse a double answering as 3.7 on a pid probed as 3.6 - Fold the socket wait into Await, which the last commit missed - Say in the grouping table that every row pays the same setup, so the ratio between them understates what grouping saves --- .../libtmux/benchmark/OperationBenchmark.java | 3 ++ docs/benchmarks/operations.md | 22 +++++++------- .../libtmux/it/OperationsIntegrationTest.java | 12 ++------ .../main/java/io/github/libtmux/Server.java | 21 ++++++++++---- .../io/github/libtmux/SnapshotCapture.java | 18 ++++-------- .../java/io/github/libtmux/GroupedTmux.java | 23 ++++++++++++--- .../java/io/github/libtmux/ServerTest.java | 29 ++++++++++++++++++- 7 files changed, 85 insertions(+), 43 deletions(-) diff --git a/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java b/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java index c36f757..eed092c 100644 --- a/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java +++ b/benchmarks/src/test/java/io/github/libtmux/benchmark/OperationBenchmark.java @@ -286,6 +286,9 @@ private String render( .append(" windows, asked for three ways. This is the whole of the answer to ") .append("per-command process cost, so it leads.\n\n"); table(out, "strategy", grouping); + out.append("\nEvery row pays the same four commands for the handle it starts from and the ") + .append("count it ends with, so the ratio between them understates what grouping ") + .append("saves: the work itself is 60 commands against one.\n"); out.append("\n## Reading the hierarchy\n\n") .append("`windows()` is lenient and `snapshot()` is strict; both read who the ") diff --git a/docs/benchmarks/operations.md b/docs/benchmarks/operations.md index 32eee48..f89ab30 100644 --- a/docs/benchmarks/operations.md +++ b/docs/benchmarks/operations.md @@ -10,9 +10,11 @@ The same 20 windows, asked for three ways. This is the whole of the answer to pe | strategy | wall clock | commands dispatched | | --- | --- | --- | -| `one-at-a-time` | 818 ms | 64 | -| `batch` | 94 ms | 5 | -| `chain` | 92 ms | 5 | +| `one-at-a-time` | 1115 ms | 64 | +| `batch` | 148 ms | 5 | +| `chain` | 96 ms | 5 | + +Every row pays the same four commands for the handle it starts from and the count it ends with, so the ratio between them understates what grouping saves: the work itself is 60 commands against one. ## Reading the hierarchy @@ -20,8 +22,8 @@ The same 20 windows, asked for three ways. This is the whole of the answer to pe | read | wall clock | commands dispatched | | --- | --- | --- | -| `traversal` | 232 ms | 40 | -| `snapshot` | 192 ms | 40 | +| `traversal` | 294 ms | 40 | +| `snapshot` | 290 ms | 40 | ## What the staleness guard costs @@ -29,8 +31,8 @@ A handle fences every command it sends behind `if-shell -F`, so that a handle ca | command | wall clock | commands dispatched | | --- | --- | --- | -| `unguarded` | 90 ms | 22 | -| `guarded` | 92 ms | 22 | +| `unguarded` | 167 ms | 22 | +| `guarded` | 114 ms | 22 | The guard rides inside the one command it fences, so it costs no further process. What it adds is bytes, against the 16384 a tmux command may carry. @@ -40,8 +42,8 @@ A listed value is escaped for display, and how it is escaped changes between rel | read | wall clock | commands dispatched | | --- | --- | --- | -| `one option` | 59 ms | 20 | -| `all()` | 213 ms | 40 | -| `effective()` | 189 ms | 40 | +| `one option` | 107 ms | 20 | +| `all()` | 317 ms | 40 | +| `effective()` | 281 ms | 40 | That is the cost: one option is one command, and a listing is two whatever its size, until it outgrows what one command may carry. diff --git a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java index 71a8692..b8cca95 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/OperationsIntegrationTest.java @@ -183,16 +183,8 @@ void aHandleCannotMutateAReplacementServerThatReusedItsId(Server server) { * test measures the teardown rather than the thing it is about. */ private static void awaitSocketReleased(Server server) { - if (!(server.config().endpoint() instanceof ServerEndpoint.SocketPath socket)) { - return; - } - for (int attempt = 0; attempt < 200 && Files.exists(socket.path()); attempt++) { - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } + if (server.config().endpoint() instanceof ServerEndpoint.SocketPath socket) { + Await.until(() -> !Files.exists(socket.path())); } } } diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 8ad97b6..8867213 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -625,12 +625,18 @@ CommandResult cmd(ServerSnapshot snapshot, List argv) { Batch batch(ServerSnapshot snapshot) { long pid = snapshot.serverPid() .orElseThrow(() -> new IllegalStateException("a live handle has no server process identity")); - return batch(pid); + return new Batch(commands -> guarded(pid, CommandStrings.group(commands), "")); } - /** As {@link #batch(ServerSnapshot)}, fenced against a server identity read separately. */ - Batch batch(long pid) { - return new Batch(commands -> guarded(pid, CommandStrings.group(commands), "")); + /** + * As {@link #batch(ServerSnapshot)}, fenced against a whole identity read separately. + * + *

Both halves, because a pid alone is reusable: a different tmux landing on the one just + * probed would answer as if it were the server the rows are being read from. + */ + Batch batch(long pid, TmuxVersion version) { + String fence = "#{&&:#{==:#{pid}," + pid + "},#{==:#{version}," + version + "}}"; + return new Batch(commands -> guarded(pid, fence, CommandStrings.group(commands), "")); } CommandResult run(ServerSnapshot snapshot, List argv) { @@ -674,9 +680,12 @@ private CommandResult guarded(ServerSnapshot snapshot, String command, String in } private CommandResult guarded(long pid, String command, String input) { + return guarded(pid, "#{==:#{pid}," + pid + "}", command, input); + } + + private CommandResult guarded(long pid, String fence, String command, String input) { String stale = "libtmux-stale-handle-" + pid; - CommandResult result = cmd( - List.of("if-shell", "-F", "#{==:#{pid}," + pid + "}", command, stale), config.defaultTimeout(), input); + CommandResult result = cmd(List.of("if-shell", "-F", fence, command, stale), config.defaultTimeout(), input); if (!result.succeeded() && result.stderr().stream().anyMatch(line -> line.contains(stale))) { throw new ObjectDoesNotExist("the tmux server this handle belonged to has ended"); } diff --git a/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java b/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java index a7cc9a9..a270cb2 100644 --- a/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java +++ b/libtmux/src/main/java/io/github/libtmux/SnapshotCapture.java @@ -24,10 +24,10 @@ *

One server-wide listing per kind of object, so ordering and membership stay tmux's decision * rather than being re-derived from another listing's rows. * - *

Two commands: who the server is, then the listings as one group fenced against that answer. - * tmux runs a group in the server, so rows cannot come from two of them, and a replacement is - * refused by the fence before a listing runs rather than detected afterwards. Retrying is - * {@link Server#snapshot()}'s decision, not this one's. + *

Two commands: who the server is, then the listings as one group fenced against both halves of + * that answer, its pid and its version. tmux runs a group in the server, so rows cannot come from + * two of them, and a server that is not the one probed is refused before a listing runs rather than + * detected afterwards. Retrying is {@link Server#snapshot()}'s decision, not this one's. */ final class SnapshotCapture { @@ -129,17 +129,11 @@ Optional process() { return Optional.of(new ServerProcess(pid, TmuxVersion.parse(row.text("version")))); } - /** - * The whole hierarchy in one invocation, fenced against the identity just read. - * - *

tmux runs a group in the server, so the four listings cannot come from two servers and - * there is nothing to sample afterwards: either the fence matched and every row is that - * server's, or it did not and there is no capture. - */ + /** The whole hierarchy in one invocation, fenced against the identity just read. */ private ServerSnapshot capture(ServerProcess process) { boolean floatingKnown = process.version().atLeast(FLOATING_SINCE); RowFormat paneFormat = floatingKnown ? PANES_WITH_FLOATING : PANES; - Batch listings = server.batch(process.pid()); + Batch listings = server.batch(process.pid(), process.version()); listings.add(listing(SESSIONS, "list-sessions")); listings.add(listing(WINDOWS, "list-windows", "-a")); listings.add(listing(paneFormat, "list-panes", "-a")); diff --git a/libtmux/src/test/java/io/github/libtmux/GroupedTmux.java b/libtmux/src/test/java/io/github/libtmux/GroupedTmux.java index 96da294..bb26853 100644 --- a/libtmux/src/test/java/io/github/libtmux/GroupedTmux.java +++ b/libtmux/src/test/java/io/github/libtmux/GroupedTmux.java @@ -19,7 +19,7 @@ final class GroupedTmux { private static final Pattern QUOTED = Pattern.compile("'([^']*)'"); - private static final Pattern FENCED_PID = Pattern.compile("#\\{==:#\\{pid},(\\d+)}"); + private static final Pattern FENCED = Pattern.compile("#\\{==:#\\{(pid|version)},([^}]+)}"); private GroupedTmux() {} @@ -30,14 +30,29 @@ private GroupedTmux() {} * @param command answers one command, as tmux would */ static CommandResult execute(CommandRequest request, long livePid, Function, CommandResult> command) { + return execute(request, livePid, "3.6", command); + } + + /** + * Answers one request, running any group it carries. + * + * @param livePid the server this double is pretending to be + * @param liveVersion the tmux that server is pretending to be + * @param command answers one command, as tmux would + */ + static CommandResult execute( + CommandRequest request, long livePid, String liveVersion, Function, CommandResult> command) { List argv = request.commands().get(0); if (!argv.get(0).equals("if-shell")) { return command.apply(argv); } - Matcher fence = FENCED_PID.matcher(argv.get(2)); String stale = argv.get(argv.size() - 1); - if (fence.find() && Long.parseLong(fence.group(1)) != livePid) { - return new CommandResult(1, List.of(), List.of("unknown command: " + stale)); + Matcher fence = FENCED.matcher(argv.get(2)); + while (fence.find()) { + String live = fence.group(1).equals("pid") ? Long.toString(livePid) : liveVersion; + if (!fence.group(2).equals(live)) { + return new CommandResult(1, List.of(), List.of("unknown command: " + stale)); + } } return group(argv.get(argv.size() - 2), command); } diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 495944b..68937fa 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -276,7 +276,7 @@ void snapshotKeepsTheIdentityOfALiveServerWithNoSessions(@TempDir Path directory @Override public CommandResult execute(CommandRequest request) { requests.incrementAndGet(); - return GroupedTmux.execute(request, 4242L, argv -> switch (argv.getFirst()) { + return GroupedTmux.execute(request, 4242L, "3.2a", argv -> switch (argv.getFirst()) { case "display-message" -> new CommandResult( 0, List.of(String.join(RowFormat.of("field").separator(), "4242", "3.2a")), List.of()); @@ -303,6 +303,33 @@ public void close() {} } } + /** + * A pid is reusable, so the fence carries the version too: a different tmux that landed on the + * pid just probed would otherwise answer as the server the rows are read from. + */ + @Test + void snapshotRefusesAServerThatReusedThePidUnderADifferentTmux(@TempDir Path directory) throws IOException { + String separator = RowFormat.of("field").separator(); + TmuxTransport transport = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + return GroupedTmux.execute(request, 4242L, "3.7", argv -> switch (argv.get(0)) { + // Probed as 3.6; the server answering the listings is a 3.7 on that pid. + case "display-message" -> + new CommandResult(0, List.of(String.join(separator, "4242", "3.6")), List.of()); + default -> new CommandResult(0, List.of(), List.of()); + }); + } + + @Override + public void close() {} + }; + + try (Server server = Server.using(config(directory), transport)) { + assertThrows(LibTmuxException.class, server::snapshot); + } + } + /** The fence refuses a replaced server before a listing runs, so there is no first capture. */ @Test void snapshotRetriesAChangedIncarnationAndKeepsOnlyTheSecondCapture(@TempDir Path directory) throws IOException { From 099fc4b35ec75c35464e359abf755f3085df99c9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:29:47 -0500 Subject: [PATCH 57/77] Test(fix[waiting]): Give a pane's shell longer why: The case waits ten seconds for a pane's shell to print, and a matrix lane shares its machine with the other eight. It failed that way on the 3.6 lane, which is the same budget problem the integration suite had one commit earlier. what: - Wait thirty seconds, since these cases wait on a shell and on marker scanning rather than on tmux alone --- .../java/io/github/libtmux/mcp/RunningCommandsTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 604ec3b..70068c2 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -323,8 +323,12 @@ private static void await(CountDownLatch latch) { } } + /** + * Generous, because these cases wait on a pane's shell and a matrix lane shares its machine + * with every other lane. The budget is only ever spent when something is already wrong. + */ private static boolean await(BooleanSupplier condition) throws InterruptedException { - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); while (System.nanoTime() < deadline) { if (condition.getAsBoolean()) { return true; From 3e81f81a3369d1222aa7181dc56618c10c7f88ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:33:47 -0500 Subject: [PATCH 58/77] Pane(feat[mode]): Report the mode as a value why: mode() handed back tmux's own string in a library whose case is that tmux state is typed, so a caller compared against "tree-mode" by hand and a typo failed at runtime rather than at compile time. what: - PaneMode names the seven modes tmux has, each present from 3.2a - mode() answers with one, and raises on a name outside the range - Kotlin's modeOrNull follows --- .../libtmux/it/PaneModeIntegrationTest.java | 23 +++---- .../io/github/libtmux/kotlin/Optionals.kt | 5 +- .../src/main/java/io/github/libtmux/Pane.java | 7 ++- .../main/java/io/github/libtmux/PaneMode.java | 61 +++++++++++++++++++ 4 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 libtmux/src/main/java/io/github/libtmux/PaneMode.java diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PaneModeIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PaneModeIntegrationTest.java index 64931e5..2b46ab2 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PaneModeIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PaneModeIntegrationTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import io.github.libtmux.Pane; +import io.github.libtmux.PaneMode; import io.github.libtmux.Server; import io.github.libtmux.Session; import io.github.libtmux.junit5.TmuxExtension; @@ -32,10 +33,10 @@ void aFreshPaneIsInNoModeAtAll(Server server) { @Test void eachModeReportsItselfByTmuxsOwnName(Server server) { - assertEquals(Optional.of("copy-mode"), enter(server, Pane::copyMode)); - assertEquals(Optional.of("clock-mode"), enter(server, Pane::clockMode)); - assertEquals(Optional.of("tree-mode"), enter(server, Pane::chooseTree)); - assertEquals(Optional.of("options-mode"), enter(server, Pane::customizeMode)); + assertEquals(Optional.of(PaneMode.COPY), enter(server, Pane::copyMode)); + assertEquals(Optional.of(PaneMode.CLOCK), enter(server, Pane::clockMode)); + assertEquals(Optional.of(PaneMode.TREE), enter(server, Pane::chooseTree)); + assertEquals(Optional.of(PaneMode.OPTIONS), enter(server, Pane::customizeMode)); } /** @@ -79,7 +80,7 @@ void theBufferChooserOpensOnlyOnceThereIsABufferToChoose(Server server) { server.buffers().set("chooser-fodder", "something"); pane.chooseBuffer(); - assertEquals(Optional.of("buffer-mode"), pane.mode()); + assertEquals(Optional.of(PaneMode.BUFFER), pane.mode()); } @Test @@ -115,7 +116,7 @@ void findingAWindowOpensTheBrowserRatherThanGoingThere(Server server) { pane.findWindowByName("editor"); - assertEquals(Optional.of("tree-mode"), pane.mode(), "the pane is in the browser"); + assertEquals(Optional.of(PaneMode.TREE), pane.mode(), "the pane is in the browser"); assertEquals( activeBefore, session.refresh().activeWindow().orElseThrow().id(), @@ -129,7 +130,7 @@ void aMatchThatFoundNothingIsNotReported(Server server) { pane.findWindowByName("no-window-carries-this"); - assertEquals(Optional.of("tree-mode"), pane.mode(), "tmux opens the browser either way"); + assertEquals(Optional.of(PaneMode.TREE), pane.mode(), "tmux opens the browser either way"); } @Test @@ -137,11 +138,11 @@ void aWindowCanBeSoughtByNameOrByContentOrByBoth(Server server) { Pane pane = onlyPane(server); pane.findWindow("anything"); - assertEquals(Optional.of("tree-mode"), pane.mode()); + assertEquals(Optional.of(PaneMode.TREE), pane.mode()); pane.exitMode(); pane.findWindowByContent("anything"); - assertEquals(Optional.of("tree-mode"), pane.mode()); + assertEquals(Optional.of(PaneMode.TREE), pane.mode()); } // -------------------------------------------------------------------------------- expanding @@ -159,11 +160,11 @@ void aWindowAndASessionExpandFormatsInTheirOwnContext(Server server) { "a window resolves its own index, not the session's active one"); } - private static Optional enter(Server server, Consumer mode) { + private static Optional enter(Server server, Consumer mode) { Pane pane = onlyPane(server); pane.exitMode(); mode.accept(pane); - Optional reported = pane.mode(); + Optional reported = pane.mode(); pane.exitMode(); return reported; } diff --git a/libtmux-kotlin/src/main/kotlin/io/github/libtmux/kotlin/Optionals.kt b/libtmux-kotlin/src/main/kotlin/io/github/libtmux/kotlin/Optionals.kt index 00b3770..538a3eb 100644 --- a/libtmux-kotlin/src/main/kotlin/io/github/libtmux/kotlin/Optionals.kt +++ b/libtmux-kotlin/src/main/kotlin/io/github/libtmux/kotlin/Optionals.kt @@ -4,6 +4,7 @@ import io.github.libtmux.Client import io.github.libtmux.ClientAttachment import io.github.libtmux.Options import io.github.libtmux.Pane +import io.github.libtmux.PaneMode import io.github.libtmux.Session import io.github.libtmux.Window @@ -27,8 +28,8 @@ public fun Session.activePaneOrNull(): Pane? = activePane().orElse(null) /** Whether the pane floats, or null on a tmux older than 3.7, which does not report it. */ public fun Pane.floatingOrNull(): Boolean? = floating().orElse(null) -/** The pane's copy or view mode, or null when it is in none. */ -public fun Pane.modeOrNull(): String? = mode().orElse(null) +/** The mode the pane is in, or null when it is showing its program. */ +public fun Pane.modeOrNull(): PaneMode? = mode().orElse(null) /** The session this client is attached to, or null when it is attached to none. */ public fun Client.sessionOrNull(): Session? = session().orElse(null) diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index c1b622b..bc1f289 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -121,11 +121,12 @@ public void copyMode() { *

Read live rather than from the capture, because entering a mode is something this library * does and a caller wants to see the result of. * - * @return tmux's own name for the mode, such as {@code copy-mode} or {@code tree-mode} + * @return the mode, or empty when the pane is showing its program + * @throws LibTmuxException if this tmux named a mode outside the supported range */ - public Optional mode() { + public Optional mode() { String reported = expand("#{pane_mode}"); - return reported.isEmpty() ? Optional.empty() : Optional.of(reported); + return reported.isEmpty() ? Optional.empty() : Optional.of(PaneMode.of(reported)); } /** Shows a clock in this pane. */ diff --git a/libtmux/src/main/java/io/github/libtmux/PaneMode.java b/libtmux/src/main/java/io/github/libtmux/PaneMode.java new file mode 100644 index 0000000..0661a3f --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/PaneMode.java @@ -0,0 +1,61 @@ +package io.github.libtmux; + +/** + * A mode a pane can be in, as tmux names them. + * + *

A pane in a mode shows something other than its program: its own scrollback, a clock, or one of + * the choosers. Every mode below exists on each supported release. + */ +public enum PaneMode { + + /** Scrollback, navigable and selectable. Entered by {@link Pane#copyMode()}. */ + COPY("copy-mode"), + + /** + * Scrollback without the selection commands. + * + *

tmux enters this itself, for the output of a command it was asked to display; no command + * here puts a pane into it. + */ + VIEW("view-mode"), + + /** A clock. Entered by {@link Pane#clockMode()}. */ + CLOCK("clock-mode"), + + /** The session and window browser, which the window finder also opens. */ + TREE("tree-mode"), + + /** The attached-client browser. */ + CLIENT("client-mode"), + + /** The paste-buffer browser. */ + BUFFER("buffer-mode"), + + /** The option browser. Entered by {@link Pane#customizeMode()}. */ + OPTIONS("options-mode"); + + private final String reported; + + PaneMode(String reported) { + this.reported = reported; + } + + /** What tmux calls it, which is what {@code #{pane_mode}} expands to. */ + public String reported() { + return reported; + } + + /** + * Reads what tmux reported. + * + * @throws LibTmuxException if this tmux named a mode this release range does not have + */ + static PaneMode of(String reported) { + for (PaneMode mode : values()) { + if (mode.reported.equals(reported)) { + return mode; + } + } + throw new LibTmuxException("tmux reported a mode this library does not know: " + reported); + } +} From f880b51dc23f05a042179110b333b4231cebfafd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:42:41 -0500 Subject: [PATCH 59/77] Server(feat[channel]): Name a wait channel once why: waitFor, waitForWithSignalCapacity, signal and drain each took the channel name again, so a caller repeated it at every call site and a typo waited on a channel nothing would ever signal. The two traps tmux's wait-for carries were documented four times over. what: - Channel binds the name once and carries signal, drain and the two waits, the way options(), hooks() and buffers() already do - Server keeps channel(name) in place of the four --- .../libtmux/it/WaitForIntegrationTest.java | 22 +++--- .../java/io/github/libtmux/mcp/Channels.java | 8 +- .../github/libtmux/mcp/RunningCommands.java | 2 +- .../main/java/io/github/libtmux/Channel.java | 78 +++++++++++++++++++ .../main/java/io/github/libtmux/Server.java | 56 +------------ .../java/io/github/libtmux/ServerTest.java | 7 +- 6 files changed, 103 insertions(+), 70 deletions(-) create mode 100644 libtmux/src/main/java/io/github/libtmux/Channel.java diff --git a/integration-tests/src/test/java/io/github/libtmux/it/WaitForIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/WaitForIntegrationTest.java index 036f794..e8e6847 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/WaitForIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/WaitForIntegrationTest.java @@ -30,9 +30,10 @@ final class WaitForIntegrationTest { void aSignalWakesAWaiter(Server server) throws Exception { ExecutorService signaller = Executors.newSingleThreadExecutor(); try { - Future waiting = signaller.submit(() -> server.waitFor("woken", Duration.ofSeconds(20))); + Future waiting = + signaller.submit(() -> server.channel("woken").await(Duration.ofSeconds(20))); Thread.sleep(300); - server.signal("woken"); + server.channel("woken").signal(); assertEquals(WakeReason.SIGNALLED, waiting.get(30, TimeUnit.SECONDS)); } finally { @@ -42,7 +43,7 @@ void aSignalWakesAWaiter(Server server) throws Exception { @Test void nothingSignallingIsATimeoutRatherThanAWake(Server server) { - assertEquals(WakeReason.TIMED_OUT, server.waitFor("never-signalled", SHORT)); + assertEquals(WakeReason.TIMED_OUT, server.channel("never-signalled").await(SHORT)); } /** @@ -51,23 +52,23 @@ void nothingSignallingIsATimeoutRatherThanAWake(Server server) { */ @Test void aStaleSignalIsConsumedByDrainingRatherThanSatisfyingTheNextWait(Server server) { - server.signal("stale"); + server.channel("stale").signal(); - assertTrue(server.drain("stale"), "the buffered signal was there"); - assertFalse(server.drain("stale"), "and only one of it"); + assertTrue(server.channel("stale").drain(), "the buffered signal was there"); + assertFalse(server.channel("stale").drain(), "and only one of it"); assertEquals( WakeReason.TIMED_OUT, - server.waitFor("stale", SHORT), + server.channel("stale").await(SHORT), "after draining, a wait waits rather than returning on somebody else's signal"); } @Test void anUndrainedStaleSignalWouldHaveSatisfiedTheWait(Server server) { - server.signal("undrained"); + server.channel("undrained").signal(); assertEquals( WakeReason.SIGNALLED, - server.waitFor("undrained", SHORT), + server.channel("undrained").await(SHORT), "this is the trap: nothing signalled during the wait, and it woke anyway"); } @@ -79,7 +80,8 @@ void anUndrainedStaleSignalWouldHaveSatisfiedTheWait(Server server) { void aServerDyingUnderTheWaiterIsNotAWake(Server server) throws Exception { ExecutorService killer = Executors.newSingleThreadExecutor(); try { - Future waiting = killer.submit(() -> server.waitFor("doomed", Duration.ofSeconds(20))); + Future waiting = + killer.submit(() -> server.channel("doomed").await(Duration.ofSeconds(20))); Thread.sleep(500); server.killServer(); diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Channels.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Channels.java index 5ba82ea..cc48cfa 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Channels.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Channels.java @@ -36,10 +36,10 @@ static Woke waitFor(Call call) { Duration timeout = Waits.requested(call); boolean drained = call.flag("drain_first", false); if (drained) { - call.server().drain(channel); + call.server().channel(channel).drain(); } long started = System.nanoTime(); - WakeReason wake = call.server().waitForWithSignalCapacity(channel, timeout); + WakeReason wake = call.server().channel(channel).awaitReservingCapacity(timeout); double seconds = (System.nanoTime() - started) / 1_000_000_000.0; return new Woke( channel, wake.name(), Math.round(seconds * 100) / 100.0, Waits.asSeconds(timeout), note(wake, drained)); @@ -67,7 +67,7 @@ static Woke waitFor(Call call) { static Signalled signal(Call call) { String channel = call.string("channel"); - call.server().signal(channel); + call.server().channel(channel).signal(); return new Signalled( channel, "Signalled. If nothing was waiting, tmux remembers it and the next wait on this channel " @@ -76,7 +76,7 @@ static Signalled signal(Call call) { static Drained drain(Call call) { String channel = call.string("channel"); - boolean had = call.server().drain(channel); + boolean had = call.server().channel(channel).drain(); return new Drained( channel, had, diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 7310d48..613072e 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -82,7 +82,7 @@ static Ran run(Call call) { pane.sendLine(typed); long started = System.nanoTime(); - WakeReason wake = server.waitFor(channel, timeout); + WakeReason wake = server.channel(channel).await(timeout); double seconds = (System.nanoTime() - started) / 1_000_000_000.0; Screen.Fresh fresh = wake == WakeReason.SERVER_GONE ? null : Screen.since(pane, before, Trim.lineBudget(call)); diff --git a/libtmux/src/main/java/io/github/libtmux/Channel.java b/libtmux/src/main/java/io/github/libtmux/Channel.java new file mode 100644 index 0000000..6417ebb --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/Channel.java @@ -0,0 +1,78 @@ +package io.github.libtmux; + +import java.time.Duration; +import java.util.Objects; + +/** + * One of tmux's wait-for channels, named once. + * + *

A channel is server-wide and shared by everything on it, including programs running in panes. + * Nothing creates or destroys one: a name either has a signal remembered against it or does not. + * + *

tmux's own {@code wait-for} has two traps, and this exists to close both. It exits successfully + * when the server dies under the waiter, which is indistinguishable from a real signal, so the + * server is checked afterwards rather than believed. And a signal sent when nobody is waiting is + * remembered, satisfying the next wait whenever that happens — possibly in a later run of a + * different program — so {@link #drain()} is how a caller starts from a known state on a channel + * whose history is not its own. + */ +public final class Channel { + + /** Long enough for a pending signal to come straight back, short enough not to be a wait. */ + private static final Duration DRAIN_TIMEOUT = Duration.ofMillis(250); + + private final Server server; + private final String name; + + Channel(Server server, String name) { + this.server = server; + this.name = Objects.requireNonNull(name, "name"); + } + + /** The name this channel is known by on its server. */ + public String name() { + return name; + } + + /** Signals the channel, waking one waiter, or being remembered until something waits. */ + public void signal() { + server.run(java.util.List.of("wait-for", "-S", name)); + } + + /** + * Waits for something to signal the channel. + * + * @param timeout how long to wait + * @return why the wait ended, which is never simply "successfully" + */ + public WakeReason await(Duration timeout) { + return server.awaitChannel(name, timeout, false); + } + + /** + * Waits while preserving process capacity for a call through this server that signals it. + * + *

Use this when the waiter and its release share a bounded transport. A wait released outside + * that transport should use {@link #await}; reserving capacity for it only rejects useful + * concurrency. + * + * @throws io.github.libtmux.transport.TmuxTransportException if the wait could not be dispatched + */ + public WakeReason awaitReservingCapacity(Duration timeout) { + return server.awaitChannel(name, timeout, true); + } + + /** + * Consumes a signal already waiting, so a stale one cannot satisfy a later wait. + * + * @return whether a signal was there to consume + */ + public boolean drain() { + return await(DRAIN_TIMEOUT) == WakeReason.SIGNALLED; + } + + @Override + public String toString() { + return "Channel[" + name + "]"; + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/Server.java b/libtmux/src/main/java/io/github/libtmux/Server.java index 8867213..9c03d7d 100644 --- a/libtmux/src/main/java/io/github/libtmux/Server.java +++ b/libtmux/src/main/java/io/github/libtmux/Server.java @@ -32,9 +32,6 @@ */ public final class Server implements AutoCloseable { - /** Long enough for a pending signal to come straight back, short enough not to be a wait. */ - private static final Duration DRAIN_TIMEOUT = Duration.ofMillis(250); - private final ServerConfig config; private final TmuxTransport transport; private final boolean owned; @@ -332,43 +329,12 @@ public List listKeys() { return result.succeeded() ? result.stdout() : List.of(); } - /** - * Waits for something to signal a channel. - * - *

tmux's own {@code wait-for} has two traps, and this exists to close both. - * - *

It exits successfully when the server dies under the waiter, which is indistinguishable - * from a real signal, so the server is checked afterwards rather than believed. - * - *

A signal sent when nobody is waiting is remembered, and satisfies the next wait whenever - * that happens — possibly in a later run of a different program. A channel carrying a stale - * signal therefore wakes a waiter that nothing actually signalled. Use {@link #drain} first when - * the channel's history is not yours. - * - * @param channel the channel name, which is shared by everything on this server - * @param timeout how long to wait - * @return why the wait ended, which is never simply "successfully" - */ - public WakeReason waitFor(String channel, Duration timeout) { - return waitFor(channel, timeout, false); - } - - /** - * Waits while preserving process capacity for a call through this server that signals the - * channel. - * - *

Use this when the waiter and its release share a bounded transport. A wait released outside - * that transport should use {@link #waitFor}; reserving capacity for it only rejects useful - * concurrency. - * - * @throws io.github.libtmux.transport.TmuxTransportException if the wait could not be - * dispatched - */ - public WakeReason waitForWithSignalCapacity(String channel, Duration timeout) { - return waitFor(channel, timeout, true); + /** One of this server's wait-for channels, which is where a signal is sent and waited for. */ + public Channel channel(String name) { + return new Channel(this, name); } - private WakeReason waitFor(String channel, Duration timeout, boolean reserveSignalCapacity) { + WakeReason awaitChannel(String channel, Duration timeout, boolean reserveSignalCapacity) { try { CommandRequest request = request(List.of("wait-for", channel), timeout); if (reserveSignalCapacity) { @@ -386,20 +352,6 @@ private WakeReason waitFor(String channel, Duration timeout, boolean reserveSign return isAlive() ? WakeReason.SIGNALLED : WakeReason.SERVER_GONE; } - /** Signals a channel, waking one waiter, or being remembered until something waits. */ - public void signal(String channel) { - run(List.of("wait-for", "-S", channel)); - } - - /** - * Consumes a signal already waiting on a channel, so a stale one cannot satisfy a later wait. - * - * @return whether a signal was there to consume - */ - public boolean drain(String channel) { - return waitFor(channel, DRAIN_TIMEOUT) == WakeReason.SIGNALLED; - } - /** The server's paste buffers, which every session shares. */ public Buffers buffers() { return new Buffers(this); diff --git a/libtmux/src/test/java/io/github/libtmux/ServerTest.java b/libtmux/src/test/java/io/github/libtmux/ServerTest.java index 68937fa..05e87aa 100644 --- a/libtmux/src/test/java/io/github/libtmux/ServerTest.java +++ b/libtmux/src/test/java/io/github/libtmux/ServerTest.java @@ -186,7 +186,7 @@ public void close() {} failure, assertThrows( TmuxTransportException.class, - () -> server.waitFor("channel", java.time.Duration.ofSeconds(1)))); + () -> server.channel("channel").await(java.time.Duration.ofSeconds(1)))); } } @@ -215,13 +215,14 @@ public void close() {} }; try (Server server = Server.using(config(directory), transport)) { - assertEquals(WakeReason.SERVER_GONE, server.waitFor("self-signalled", java.time.Duration.ofSeconds(1))); + assertEquals( + WakeReason.SERVER_GONE, server.channel("self-signalled").await(java.time.Duration.ofSeconds(1))); assertFalse(waiting.get(), "an ordinary wait consumed reserved signal capacity"); assertSame( failure, assertThrows( TmuxTimeoutException.class, - () -> server.waitForWithSignalCapacity("channel", java.time.Duration.ofSeconds(1)))); + () -> server.channel("channel").awaitReservingCapacity(java.time.Duration.ofSeconds(1)))); assertTrue(waiting.get(), "wait-for used ordinary transport admission"); } } From f6d740270dbd67906e7c9abeb638409aa855616c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:53:48 -0500 Subject: [PATCH 60/77] Pane(feat[find]): Search the fields tmux offers why: Three methods covered name, content and both, and tmux also takes a title, a case-insensitive match and a regular expression. Naming every combination would have meant a method each; what was there was an incomplete surface rather than a crowded one. what: - FindSpec collects what to match and where, the shape CaptureSpec uses - findWindow takes a spec, a builder, or the text alone - findWindowByName and findWindowByContent go: name it on the spec - Every flag exists from 3.2a, so nothing here is version-gated --- .../libtmux/it/PaneModeIntegrationTest.java | 14 +- .../main/java/io/github/libtmux/FindSpec.java | 156 ++++++++++++++++++ .../src/main/java/io/github/libtmux/Pane.java | 29 ++-- .../io/github/libtmux/CreationSpecTest.java | 39 +++++ 4 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 libtmux/src/main/java/io/github/libtmux/FindSpec.java diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PaneModeIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PaneModeIntegrationTest.java index 2b46ab2..afe3a64 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PaneModeIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PaneModeIntegrationTest.java @@ -114,7 +114,7 @@ void findingAWindowOpensTheBrowserRatherThanGoingThere(Server server) { var activeBefore = session.refresh().activeWindow().orElseThrow().id(); Pane pane = onlyPane(server); - pane.findWindowByName("editor"); + pane.findWindow(f -> f.matching("editor").inName()); assertEquals(Optional.of(PaneMode.TREE), pane.mode(), "the pane is in the browser"); assertEquals( @@ -128,20 +128,26 @@ void findingAWindowOpensTheBrowserRatherThanGoingThere(Server server) { void aMatchThatFoundNothingIsNotReported(Server server) { Pane pane = onlyPane(server); - pane.findWindowByName("no-window-carries-this"); + pane.findWindow(f -> f.matching("no-window-carries-this").inName()); assertEquals(Optional.of(PaneMode.TREE), pane.mode(), "tmux opens the browser either way"); } @Test - void aWindowCanBeSoughtByNameOrByContentOrByBoth(Server server) { + void aWindowCanBeSoughtInAnyFieldAndAnyWay(Server server) { Pane pane = onlyPane(server); pane.findWindow("anything"); assertEquals(Optional.of(PaneMode.TREE), pane.mode()); pane.exitMode(); - pane.findWindowByContent("anything"); + pane.findWindow(f -> f.matching("anything").inContent()); + assertEquals(Optional.of(PaneMode.TREE), pane.mode()); + pane.exitMode(); + + // Title, case-insensitivity and regex are what the three named methods could not reach. + pane.findWindow( + f -> f.matching("^ANY").inTitle().inName().ignoringCase().asRegex()); assertEquals(Optional.of(PaneMode.TREE), pane.mode()); } diff --git a/libtmux/src/main/java/io/github/libtmux/FindSpec.java b/libtmux/src/main/java/io/github/libtmux/FindSpec.java new file mode 100644 index 0000000..ae0a167 --- /dev/null +++ b/libtmux/src/main/java/io/github/libtmux/FindSpec.java @@ -0,0 +1,156 @@ +package io.github.libtmux; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * What to look for when opening the window browser narrowed to a match. + * + *

The shape {@link CaptureSpec} uses. tmux looks in a window's name, its title and its visible + * content unless told otherwise; naming any of them narrows it to those. + * + *

{@code
+ * pane.findWindow(f -> f.matching("build").inName());
+ * pane.findWindow(f -> f.matching("^err").asRegex().ignoringCase());
+ * }
+ * + *

Every flag below exists on each supported release, so nothing here is version-gated. + */ +public final class FindSpec { + + private final String match; + private final boolean name; + private final boolean title; + private final boolean content; + private final boolean ignoreCase; + private final boolean regex; + private final boolean zoom; + + private FindSpec(Builder builder) { + this.match = builder.match; + this.name = builder.name; + this.title = builder.title; + this.content = builder.content; + this.ignoreCase = builder.ignoreCase; + this.regex = builder.regex; + this.zoom = builder.zoom; + } + + /** A builder that looks everywhere tmux looks by default. */ + public static Builder builder() { + return new Builder(); + } + + /** What is being looked for. */ + public String match() { + return match; + } + + List argv(String target) { + List argv = new ArrayList<>(10); + argv.add("find-window"); + // Naming every field is what tmux does when told nothing, so it is left unsaid. + if (!(name && title && content)) { + flag(argv, name, "-N"); + flag(argv, title, "-T"); + flag(argv, content, "-C"); + } + flag(argv, ignoreCase, "-i"); + flag(argv, regex, "-r"); + + flag(argv, zoom, "-Z"); + argv.add("-t"); + argv.add(target); + argv.add(match); + return argv; + } + + private static void flag(List argv, boolean wanted, String flag) { + if (wanted) { + argv.add(flag); + } + } + + /** Collects what to look for, and where. */ + public static final class Builder { + + private String match = ""; + private boolean name = true; + private boolean title = true; + private boolean content = true; + private boolean ignoreCase; + private boolean regex; + private boolean zoom; + private boolean narrowed; + + private Builder() {} + + /** The text to look for. Required. */ + public Builder matching(String match) { + this.match = Objects.requireNonNull(match, "match"); + return this; + } + + /** Looks in the window's name. Naming any field stops the others being searched. */ + public Builder inName() { + narrow(); + name = true; + return this; + } + + /** Looks in the window's title. */ + public Builder inTitle() { + narrow(); + title = true; + return this; + } + + /** Looks in what the window is showing. */ + public Builder inContent() { + narrow(); + content = true; + return this; + } + + /** Matches without regard to case. */ + public Builder ignoringCase() { + this.ignoreCase = true; + return this; + } + + /** Reads the match as an extended regular expression rather than as text. */ + public Builder asRegex() { + this.regex = true; + return this; + } + + /** Zooms the pane the browser opens in. */ + public Builder zooming() { + this.zoom = true; + return this; + } + + /** The first field named replaces tmux's default of all three; later ones add to it. */ + private void narrow() { + if (!narrowed) { + name = false; + title = false; + content = false; + narrowed = true; + } + } + + /** + * Builds the spec. + * + * @throws IllegalArgumentException if nothing was given to match + */ + public FindSpec build() { + if (match.isEmpty()) { + throw new IllegalArgumentException("a find has nothing to match"); + } + return new FindSpec(this); + } + } +} diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index bc1f289..b5877c9 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -174,24 +174,27 @@ public void chooseClient() { * told nothing more specific. */ public void findWindow(String match) { - Objects.requireNonNull(match, "match"); - server.run(snapshot, List.of("find-window", "-t", state.id().value(), match)); - } - - /** Narrows the window browser by name alone. See {@link #findWindow} for what it does not do. */ - public void findWindowByName(String match) { - Objects.requireNonNull(match, "match"); - server.run(snapshot, List.of("find-window", "-N", "-t", state.id().value(), match)); + findWindow(FindSpec.builder().matching(match).build()); } /** - * Narrows the window browser by what the windows are showing. + * Puts this pane into the window browser, narrowed as described. + * + *

{@code
+     * pane.findWindow(f -> f.matching("build").inName());
+     * }
* - *

See {@link #findWindow} for what it does not do. + * @param configure receives a builder that looks everywhere tmux looks by default */ - public void findWindowByContent(String match) { - Objects.requireNonNull(match, "match"); - server.run(snapshot, List.of("find-window", "-C", "-t", state.id().value(), match)); + public void findWindow(Consumer configure) { + FindSpec.Builder builder = FindSpec.builder(); + configure.accept(builder); + findWindow(builder.build()); + } + + /** Puts this pane into the window browser, narrowed by a spec that may be reused. */ + public void findWindow(FindSpec spec) { + server.run(snapshot, spec.argv(state.id().value())); } /** diff --git a/libtmux/src/test/java/io/github/libtmux/CreationSpecTest.java b/libtmux/src/test/java/io/github/libtmux/CreationSpecTest.java index cb4d93c..029589d 100644 --- a/libtmux/src/test/java/io/github/libtmux/CreationSpecTest.java +++ b/libtmux/src/test/java/io/github/libtmux/CreationSpecTest.java @@ -219,4 +219,43 @@ void bothSpecsAreDescriptionsThatCanBeLoweredTwice() { window.argv("$2", FORMAT, V37B) .get(window.argv("$2", FORMAT, V37B).indexOf("-t") + 1)); } + + // ------------------------------------------------------------------------------ finding + + /** tmux looks in every field when told nothing, so saying so would narrow rather than widen. */ + @Test + void aFindNamesNoFieldWhenItWantsThemAll() { + assertEquals( + List.of("find-window", "-t", "%1", "build"), + FindSpec.builder().matching("build").build().argv("%1")); + } + + @Test + void namingAFieldNarrowsToTheOnesNamed() { + assertEquals( + List.of("find-window", "-N", "-t", "%1", "build"), + FindSpec.builder().matching("build").inName().build().argv("%1")); + assertEquals( + List.of("find-window", "-N", "-T", "-t", "%1", "build"), + FindSpec.builder().matching("build").inName().inTitle().build().argv("%1")); + } + + @Test + void howAMatchIsReadIsSeparateFromWhereItIsLookedFor() { + assertEquals( + List.of("find-window", "-C", "-i", "-r", "-Z", "-t", "%1", "^err"), + FindSpec.builder() + .matching("^err") + .inContent() + .ignoringCase() + .asRegex() + .zooming() + .build() + .argv("%1")); + } + + @Test + void aFindWithNothingToMatchIsRejected() { + assertThrows(IllegalArgumentException.class, () -> FindSpec.builder().build()); + } } From b2646979105cd25f89ddb0039437170a1a9ffc66 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 22:54:41 -0500 Subject: [PATCH 61/77] Control(test[attach]): Wait for attachment why: The close test subscribed before tmux had acknowledged the control client, leaving the waiting thread exposed to an attach race. what: - Send one checked command before subscribing --- .../java/io/github/libtmux/it/ControlModeIntegrationTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java index 6d21a43..e93204a 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java @@ -218,6 +218,7 @@ void closeIsIdempotent(Server server) { @Test void closingTheClientWakesAWaitingSubscriber(Server server) throws Exception { ControlClient client = attach(server); + assertTrue(client.send("display-message", "-p", "attached").succeeded()); EventSubscription output = client.subscribeOutput(1); CountDownLatch entered = new CountDownLatch(1); FutureTask> waiting = new FutureTask<>(() -> { From 9f897f5fda139bb51872d75c32dc39e0b591648e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 22:54:41 -0500 Subject: [PATCH 62/77] Buffer(test[paste]): Wait for the reader why: A large paste could arrive while the inherited shell was still starting, so the test observed startup timing instead of paste capacity. what: - Start a fixed noncanonical reader pane - Wait for its readiness marker before pasting --- .../libtmux/it/BuffersAndClientIntegrationTest.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java index 60e4d40..dbc36e2 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/BuffersAndClientIntegrationTest.java @@ -177,10 +177,17 @@ void pastedTextReachesThePaneExactly(Server server) throws Exception { /** tmux refuses a command whose packed argv exceeds MAX_IMSGSIZE, which is 16384 bytes. */ @Test void pastedTextIsNotBoundedByTheSizeOfACommand(Server server) throws Exception { - Pane pane = server.sessions().get(0).windows().get(0).panes().get(0); if (!server.version().atLeast(EXACT_NAMED_DELETE)) { return; } + Pane pane = server.sessions() + .get(0) + .windows() + .get(0) + .panes() + .get(0) + .split(s -> s.running("sh", "-c", "stty -icanon -echo; printf 'reader-ready\\n'; cat")); + assertTrue(Await.output(pane, "reader-ready"), "the paste reader never started"); pane.paste("y".repeat(20_000) + "END-OF-A-LARGE-PASTE"); From 1b82b75e4808bf5f4ca7cd392b232dba0d6c8029 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 23:27:00 -0500 Subject: [PATCH 63/77] Control(test[close]): Remove pane output race Why: The live tmux test assumed an attached shell would remain silent long enough to prove its subscriber was blocked. JDK 25 exposed legitimate pane output winning that race. What: Exercise the same client-close wakeup contract against a controlled fake control process that emits no notifications. --- .../it/ControlModeIntegrationTest.java | 29 ----------------- .../libtmux/control/ControlClientTest.java | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java index e93204a..0a3f996 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/ControlModeIntegrationTest.java @@ -18,14 +18,11 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.Optional; -import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -215,32 +212,6 @@ void closeIsIdempotent(Server server) { client.close(); } - @Test - void closingTheClientWakesAWaitingSubscriber(Server server) throws Exception { - ControlClient client = attach(server); - assertTrue(client.send("display-message", "-p", "attached").succeeded()); - EventSubscription output = client.subscribeOutput(1); - CountDownLatch entered = new CountDownLatch(1); - FutureTask> waiting = new FutureTask<>(() -> { - entered.countDown(); - return output.next(); - }); - Thread consumer = Thread.ofVirtual().start(waiting); - try { - assertTrue(entered.await(5, TimeUnit.SECONDS)); - assertThrows(TimeoutException.class, () -> waiting.get(100, TimeUnit.MILLISECONDS)); - - client.close(); - - assertEquals(Optional.empty(), waiting.get(1, TimeUnit.SECONDS)); - } finally { - waiting.cancel(true); - output.close(); - client.close(); - consumer.join(); - } - } - /** * A request nobody answered is unanswered, not failed. No ordinary tmux command can produce * this — control mode replies as soon as it queues a command, even a blocking one — so the diff --git a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java index e6bef70..9e1e420 100644 --- a/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java +++ b/libtmux/src/test/java/io/github/libtmux/control/ControlClientTest.java @@ -17,9 +17,11 @@ import java.nio.file.attribute.PosixFilePermissions; import java.time.Duration; import java.util.List; +import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -102,6 +104,35 @@ void aTimedOutReplyMakesTheStreamUnavailableForLaterRequests(@TempDir Path direc } } + @Test + void closingTheClientWakesAWaitingSubscriber(@TempDir Path directory) throws Exception { + ServerConfig config = fakeTmux(directory, """ + printf '%%begin 100 1 0\n%%end 100 1 0\n' + IFS= read -r never + """); + ControlClient client = ControlClient.attach(config, new SessionId("$0")); + EventSubscription output = client.subscribeOutput(1); + CountDownLatch entered = new CountDownLatch(1); + FutureTask> waiting = new FutureTask<>(() -> { + entered.countDown(); + return output.next(); + }); + Thread consumer = Thread.ofVirtual().start(waiting); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> waiting.get(100, TimeUnit.MILLISECONDS)); + + client.close(); + + assertEquals(Optional.empty(), waiting.get(1, TimeUnit.SECONDS)); + } finally { + waiting.cancel(true); + output.close(); + client.close(); + consumer.join(); + } + } + @Test void aNonPositiveTimeoutIsRejectedBeforeDispatch(@TempDir Path directory) throws Exception { ServerConfig config = fakeTmux(directory, """ From b7fb0d5c159a6c43b25e7d251549533a327240fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:45:48 -0500 Subject: [PATCH 64/77] MCP(fix[run]): Isolate caller shell syntax why: Caller comments and parentheses could alter completion framing and escape the subshell that protects the pane's interactive shell. what: - Source exact caller text from an owner-only temporary file - Remove staged text on success, timeout, cancellation, and failure - Exercise exit, comment, and parenthesis isolation against real tmux --- .../github/libtmux/mcp/RunningCommands.java | 96 +++++++++++++------ .../libtmux/mcp/RunningCommandsTest.java | 10 +- 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 613072e..8d20087 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -3,6 +3,12 @@ import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.WakeReason; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; import java.security.SecureRandom; import java.time.Duration; import java.util.ArrayList; @@ -75,32 +81,38 @@ static Ran run(Call call) { String endMark = nonce + "-e"; String channel = "ch_" + nonce; - Cursor before = Screen.from(pane).cursor(); - String typed = payload(server, command, nonce, startMark, endMark, channel, suppressHistory); - // Never make the shell wait for Java cleanup: a transport can report UNKNOWN after tmux - // accepted this line, and that failure must not strand the pane at private plumbing. - pane.sendLine(typed); - - long started = System.nanoTime(); - WakeReason wake = server.channel(channel).await(timeout); - double seconds = (System.nanoTime() - started) / 1_000_000_000.0; - - Screen.Fresh fresh = wake == WakeReason.SERVER_GONE ? null : Screen.since(pane, before, Trim.lineBudget(call)); - Framed framed = fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); - Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; - Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); - - return new Ran( - pane.id().value(), - wake.name(), - status, - trimmed.lines(), - trimmed.truncated(), - trimmed.dropped(), - framed.exact(), - Math.round(seconds * 100) / 100.0, - Waits.asSeconds(timeout), - note(wake, framed)); + try (StagedCommand staged = StagedCommand.create(command)) { + Cursor before = Screen.from(pane).cursor(); + String typed = payload(server, staged.path(), nonce, startMark, endMark, channel, suppressHistory); + // Never make the shell wait for Java cleanup: a transport can report UNKNOWN after tmux + // accepted this line, and that failure must not strand the pane at private plumbing. + pane.sendLine(typed); + + long started = System.nanoTime(); + WakeReason wake = server.channel(channel).await(timeout); + double seconds = (System.nanoTime() - started) / 1_000_000_000.0; + + Screen.Fresh fresh = + wake == WakeReason.SERVER_GONE ? null : Screen.since(pane, before, Trim.lineBudget(call)); + Framed framed = + fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); + Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; + Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); + + return new Ran( + pane.id().value(), + wake.name(), + status, + trimmed.lines(), + trimmed.truncated(), + trimmed.dropped(), + framed.exact(), + Math.round(seconds * 100) / 100.0, + Waits.asSeconds(timeout), + note(wake, framed)); + } catch (IOException e) { + throw new UncheckedIOException("could not stage the pane command", e); + } } private static @Nullable String note(WakeReason wake, Framed framed) { @@ -132,7 +144,7 @@ static Ran run(Call call) { */ private static String payload( Server server, - String command, + Path command, String nonce, String startMark, String endMark, @@ -150,8 +162,8 @@ private static String payload( // The status is held in a shell variable named for the nonce, so nothing this types can // collide with a variable the person using the pane already had. - return (suppressHistory ? " " : "") + "echo " + startMark + "; ( " + command + " ); " + nonce + "=$?; echo " - + endMark + ":\"$" + nonce + "\"; " + finish; + return (suppressHistory ? " " : "") + "echo " + startMark + "; ( . " + Shell.quote(command.toString()) + " ); " + + nonce + "=$?; echo " + endMark + ":\"$" + nonce + "\"; " + finish; } private static List append(List base, String... more) { @@ -217,4 +229,30 @@ private static byte[] bytes() { RANDOM.nextBytes(value); return value; } + + private record StagedCommand(Path path) implements AutoCloseable { + + private static StagedCommand create(String command) throws IOException { + Path path = Files.createTempFile( + "libtmux-java-command-", + ".sh", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + try { + Files.writeString(path, command, StandardCharsets.UTF_8); + return new StagedCommand(path); + } catch (IOException failure) { + try { + Files.deleteIfExists(path); + } catch (IOException cleanup) { + failure.addSuppressed(cleanup); + } + throw failure; + } + } + + @Override + public void close() throws IOException { + Files.deleteIfExists(path); + } + } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 70068c2..4129915 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -250,10 +250,18 @@ void aCommandPrintingMoreThanAskedForKeepsTheNewestAndSaysItDropped(Server serve void aCommandCannotChangeThePanesShellAndCannotEndIt(Server server) { String pane = server.panes().get(0).id().value(); - RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "mine=set; cd /")); + RunningCommands.Ran exited = + RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "mine=set; cd /; exit 3")); + RunningCommands.Ran commented = RunningCommands.run( + TestCalls.on(server, "pane_id", pane, "command", "echo comment-safe # comment", "timeout", 1)); + RunningCommands.Ran parenthesis = + RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", ": ); exit 7; #", "timeout", 1)); RunningCommands.Ran after = RunningCommands.run(TestCalls.on(server, "pane_id", pane, "command", "echo \"[$mine]\"")); + assertEquals(3, exited.exitStatus(), "exit reports from the isolated command"); + assertEquals(java.util.List.of("comment-safe"), commented.output(), "a comment cannot hide the framing"); + assertEquals("SIGNALLED", parenthesis.outcome(), "a closing parenthesis cannot escape the command"); assertEquals(java.util.List.of("[]"), after.output(), "the assignment did not escape its subshell"); assertEquals(1, server.panes().size(), "and exiting inside it did not take the pane with it"); } From 65601e4601b14ad7fd42540c5484da7c3295c95c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:55:17 -0500 Subject: [PATCH 65/77] Transport(fix[input]): Bound stdin writes why: A child that stopped reading could trap its caller in a synchronous write past the request deadline and retain admission forever. what: - Pump stdin on a reserved platform worker beside both output drains - Reclaim the input worker before returning process admission - Exercise a blocked real child, its deadline, cleanup, and permit reuse --- .../libtmux/transport/ProcessTransport.java | 84 +++++++++---------- .../transport/ProcessTransportTest.java | 32 +++++++ 2 files changed, 73 insertions(+), 43 deletions(-) diff --git a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java index 70b104b..2b48725 100644 --- a/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java +++ b/libtmux/src/main/java/io/github/libtmux/transport/ProcessTransport.java @@ -35,10 +35,10 @@ /** * The default transport: one child process per call, drained by a bounded pool of platform threads. * - *

A caller takes one admission permit before launching, and the pool holds exactly two workers - * per permit, so holding a permit means both drains are already free. Without that coupling a - * caller can start a child whose pipes nobody is reading, and a child whose pipe fills stops - * instead of exiting. + *

A caller takes one admission permit before launching, and the pool holds exactly three workers + * per permit, so holding a permit means both drains and the input pump are already free. Without + * that coupling a caller can start a child whose pipes nobody is reading, or block forever writing + * to one that stopped reading. * *

A request declared as waiting also takes one of all but one admission permits. The remaining * process stays available for the ordinary request that observes or releases those waits, without @@ -110,7 +110,7 @@ public ProcessTransport(int maxConcurrentProcesses, int maxOutputBytes) { } this.admission = new Semaphore(maxConcurrentProcesses); this.waitingAdmission = maxConcurrentProcesses == 1 ? null : new Semaphore(maxConcurrentProcesses - 1); - this.pumps = (ThreadPoolExecutor) Executors.newFixedThreadPool(2 * maxConcurrentProcesses, factory()); + this.pumps = (ThreadPoolExecutor) Executors.newFixedThreadPool(3 * maxConcurrentProcesses, factory()); this.maxOutputBytes = maxOutputBytes; this.starter = Objects.requireNonNull(starter, "starter"); this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); @@ -152,17 +152,16 @@ private CommandResult execute(CommandRequest request, boolean waiting) { release(waitingPermit); throw failure; } - Drains drains = null; + Pumps runningPumps = null; try { - drains = submit(process); - supplyInput(process, request.input()); - return complete(process, drains, deadline); + runningPumps = submit(process, request.input()); + return complete(process, runningPumps, deadline); } finally { live.remove(process); killedByClose.remove(process); - // A permit asserts that two workers are free, so it goes back only once they are. A + // A permit asserts that three workers are free, so it goes back only once they are. A // cancelled FutureTask reports itself done while its worker is still inside the read. - if (drains == null || drains.reclaimed()) { + if (runningPumps == null || runningPumps.reclaimed()) { admission.release(); release(waitingPermit); } @@ -334,13 +333,14 @@ private RunningProcess launch(CommandRequest request, long deadline) { // ------------------------------------------------------------------------------ draining - private Drains submit(RunningProcess process) { - CountDownLatch finished = new CountDownLatch(2); + private Pumps submit(RunningProcess process, String input) { + CountDownLatch finished = new CountDownLatch(3); CompletableFuture failure = new CompletableFuture<>(); try { - return new Drains( + return new Pumps( pumps.submit(new Pump(process.process().getInputStream(), maxOutputBytes, finished, failure)), pumps.submit(new Pump(process.process().getErrorStream(), maxOutputBytes, finished, failure)), + pumps.submit(new InputPump(process.process().getOutputStream(), input, finished)), finished, failure); } catch (RejectedExecutionException e) { @@ -348,21 +348,21 @@ private Drains submit(RunningProcess process) { } } - private CommandResult complete(RunningProcess process, Drains drains, long deadline) { - awaitExitOrFailure(process, drains, deadline); + private CommandResult complete(RunningProcess process, Pumps runningPumps, long deadline) { + awaitExitOrFailure(process, runningPumps, deadline); if (killedByClose.contains(process)) { // This exit status is ours, not tmux's; returning it would read as tmux dying on a signal. throw new TmuxTransportException("transport closed while tmux was running", DispatchOutcome.UNKNOWN, null); } - byte[] out = collect(drains.stdout(), process, deadline); - byte[] err = collect(drains.stderr(), process, deadline); + byte[] out = collect(runningPumps.stdout(), process, deadline); + byte[] err = collect(runningPumps.stderr(), process, deadline); return new CommandResult( process.process().exitValue(), OutputDecoder.stdoutLines(out), OutputDecoder.stderrLines(err)); } - private void awaitExitOrFailure(RunningProcess process, Drains drains, long deadline) { + private void awaitExitOrFailure(RunningProcess process, Pumps runningPumps, long deadline) { try { - CompletableFuture.anyOf(process.process().onExit(), drains.failure()) + CompletableFuture.anyOf(process.process().onExit(), runningPumps.failure()) .get(remainingNanos(deadline), TimeUnit.NANOSECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -372,7 +372,7 @@ private void awaitExitOrFailure(RunningProcess process, Drains drains, long dead } catch (ExecutionException e) { throw terminate(process, "could not await tmux", e.getCause()); } - Throwable failure = drains.failure().getNow(null); + Throwable failure = runningPumps.failure().getNow(null); if (failure != null) { String message = failure instanceof OutputLimitExceeded exceeded ? exceeded.description() : "could not drain tmux"; @@ -416,7 +416,7 @@ private static TmuxTimeoutException admissionTimeout(String message) { // --------------------------------------------------------------------------- destruction - /** Drains are deliberately not cancelled: killing the child is what actually ends the read. */ + /** Pumps are deliberately not cancelled: killing the child is what actually ends pipe I/O. */ private TmuxTransportException terminate(RunningProcess process, String message, @Nullable Throwable cause) { return reclaim(process, new TmuxTransportException(message, DispatchOutcome.UNKNOWN, cause)); } @@ -476,26 +476,6 @@ private static void restoreInterrupt(AtomicBoolean interrupted) { } } - /** - * Writes what the command reads, then closes its standard input. - * - *

After the drains are running rather than before: tmux replies while it reads, and an - * input large enough to fill the pipe would otherwise wait on a stdout nobody is draining. - */ - private static void supplyInput(RunningProcess process, String input) { - OutputStream stdin = process.process().getOutputStream(); - try { - if (!input.isEmpty()) { - stdin.write(input.getBytes(StandardCharsets.UTF_8)); - stdin.flush(); - } - } catch (IOException stoppedReading) { - // What tmux made of it is in its exit status and stderr, which say more than this. - } finally { - closeQuietly(stdin, null); - } - } - private static void closeQuietly(Closeable stream, @Nullable TmuxTransportException failure) { try { stream.close(); @@ -516,9 +496,10 @@ private static ThreadFactory factory() { }; } - private record Drains( + private record Pumps( Future stdout, Future stderr, + Future stdin, CountDownLatch finished, CompletableFuture failure) { boolean reclaimed() { @@ -582,6 +563,23 @@ public byte[] call() throws IOException { } } + private record InputPump(OutputStream target, String input, CountDownLatch finished) implements Runnable { + @Override + public void run() { + try { + if (!input.isEmpty()) { + target.write(input.getBytes(StandardCharsets.UTF_8)); + target.flush(); + } + } catch (IOException stoppedReading) { + // The exit status and stderr say what the child made of incomplete input. + } finally { + closeQuietly(target, null); + finished.countDown(); + } + } + } + private static final class OutputLimitExceeded extends IOException { private static final long serialVersionUID = 1L; private final int limit; diff --git a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java index 8a80f68..ade032f 100644 --- a/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java +++ b/libtmux/src/test/java/io/github/libtmux/transport/ProcessTransportTest.java @@ -33,6 +33,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -162,6 +163,37 @@ void aChildThatOutlivesItsDeadlineIsKilledAndReportedUnknown() { } } + @Test + void blockedStandardInputObeysTheDeadlineAndReturnsItsPermit() throws Exception { + AtomicReference child = new AtomicReference<>(); + ProcessTransport.ProcessStarter starter = command -> { + Process started = new ProcessBuilder(command).start(); + child.set(started); + return started; + }; + ProcessTransport transport = new ProcessTransport(1, 1_024, starter, System::nanoTime); + FutureTask request = new FutureTask<>(() -> transport.execute(CommandRequest.of( + List.of("/bin/sh"), List.of("-c", "sleep 30"), Duration.ofMillis(250), "x".repeat(1_048_576)))); + Thread caller = Thread.ofVirtual().start(request); + + try { + ExecutionException ended = assertThrows(ExecutionException.class, () -> request.get(5, TimeUnit.SECONDS)); + TmuxTimeoutException failure = assertInstanceOf(TmuxTimeoutException.class, ended.getCause()); + assertEquals(DispatchOutcome.UNKNOWN, failure.outcome()); + assertFalse(child.get().isAlive(), "the child survived its input deadline"); + assertEquals( + List.of("reclaimed"), + transport + .execute(shell("echo reclaimed", Duration.ofSeconds(2))) + .stdout(), + "blocked input permanently consumed the only permit"); + } finally { + transport.close(); + caller.join(TimeUnit.SECONDS.toMillis(10)); + assertFalse(caller.isAlive(), "the blocked input caller did not stop"); + } + } + @Test void aHugePositiveTimeoutDoesNotOverflowTheDeadline() { try (ProcessTransport transport = new ProcessTransport()) { From b5c1da71d8d02b38ae85617174e27dcdcf090f13 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:07:21 -0500 Subject: [PATCH 66/77] MCP(fix[send]): Bound serialized backlog why: A stalled client retained every queued message, while cancellation and close left queued sends live or unresolved. what: - Bound admitted sends to 256 messages and 16 MiB of encoded JSON - Remove cancelled queued sends and return their admission - Fail admitted sends on close without starting concurrent delegate sends --- .../mcp/SerializedTransportProvider.java | 148 +++++++++++++++--- .../mcp/SerializedTransportProviderTest.java | 94 +++++++++++ 2 files changed, 222 insertions(+), 20 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java index 9b759c3..062c52d 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java @@ -1,11 +1,13 @@ package io.github.libtmux.mcp; +import com.fasterxml.jackson.core.JacksonException; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerSession; import io.modelcontextprotocol.spec.McpServerTransport; import io.modelcontextprotocol.spec.McpServerTransportProvider; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import org.jspecify.annotations.Nullable; @@ -15,6 +17,9 @@ /** Serializes per-session sends because the pinned SDK's stdio sink rejects concurrent emissions. */ final class SerializedTransportProvider implements McpServerTransportProvider { + private static final int SEND_CAPACITY = 256; + private static final long SEND_BYTE_CAPACITY = 16L * 1024 * 1024; + private final McpServerTransportProvider delegate; SerializedTransportProvider(McpServerTransportProvider delegate) { @@ -60,7 +65,10 @@ private static final class SerializedTransport implements McpServerTransport { private final McpServerTransport delegate; private final Object sends = new Object(); private final ArrayDeque pending = new ArrayDeque<>(); - private boolean sending; + private @Nullable PendingSend active; + private int admitted; + private long admittedBytes; + private boolean closed; SerializedTransport(McpServerTransport delegate) { this.delegate = Objects.requireNonNull(delegate, "delegate"); @@ -68,46 +76,130 @@ private static final class SerializedTransport implements McpServerTransport { @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { - return Mono.create(sink -> enqueue(new PendingSend(message, sink))); + return Mono.create(sink -> { + PendingSend added; + try { + added = new PendingSend(message, sink, encodedBytes(message)); + } catch (RuntimeException failure) { + sink.error(failure); + return; + } + sink.onCancel(() -> cancel(added)); + enqueue(added); + }); } private void enqueue(PendingSend added) { + @Nullable Throwable refused = null; + @Nullable PendingSend next = null; synchronized (sends) { - pending.addLast(added); - if (sending) { + if (added.cancelled) { return; } - sending = true; + if (closed) { + refused = new IllegalStateException("transport is closed"); + } else if (admitted >= SEND_CAPACITY || added.bytes > SEND_BYTE_CAPACITY - admittedBytes) { + refused = new IllegalStateException("outbound send capacity exceeded"); + } else { + admitted++; + admittedBytes += added.bytes; + pending.addLast(added); + if (active == null) { + next = takeNext(); + } + } + } + if (refused != null) { + added.sink.error(refused); + } else if (next != null) { + start(next); } - sendNext(); } - private void sendNext() { - PendingSend next; + private void cancel(PendingSend cancelled) { synchronized (sends) { - next = pending.removeFirst(); + cancelled.cancelled = true; + if (pending.remove(cancelled)) { + release(cancelled); + } } + } + + private PendingSend takeNext() { + PendingSend next = pending.removeFirst(); + active = next; + return next; + } + + private void start(PendingSend next) { try { - delegate.sendMessage(next.message()) - .subscribe(ignored -> {}, failure -> finish(next, failure), () -> finish(next, null)); + synchronized (sends) { + if (closed || active != next) { + return; + } + delegate.sendMessage(next.message) + .subscribe(ignored -> {}, failure -> finish(next, failure), () -> finish(next, null)); + } } catch (RuntimeException | Error failure) { finish(next, failure); } } private void finish(PendingSend completed, @Nullable Throwable failure) { - boolean hasNext; + @Nullable PendingSend next = null; synchronized (sends) { - hasNext = !pending.isEmpty(); - sending = hasNext; + if (active != completed) { + return; + } + active = null; + release(completed); + if (!pending.isEmpty()) { + next = takeNext(); + } } if (failure == null) { - completed.sink().success(); + completed.sink.success(); } else { - completed.sink().error(failure); + completed.sink.error(failure); } - if (hasNext) { - sendNext(); + if (next != null) { + start(next); + } + } + + private void release(PendingSend released) { + admitted--; + admittedBytes -= released.bytes; + } + + private List abandon() { + synchronized (sends) { + if (closed) { + return List.of(); + } + closed = true; + List abandoned = new ArrayList<>(admitted); + if (active != null) { + abandoned.add(active); + active = null; + } + abandoned.addAll(pending); + pending.clear(); + admitted = 0; + admittedBytes = 0; + return abandoned; + } + } + + private static void fail(List abandoned) { + abandoned.forEach(send -> send.sink.error(new IllegalStateException("transport is closed"))); + } + + private static long encodedBytes(McpSchema.JSONRPCMessage message) { + try { + return Answers.JSON.writeValueAsBytes(message).length; + } catch (JacksonException e) { + throw new IllegalStateException("could not size outbound message", e); } } @@ -118,11 +210,16 @@ public T unmarshalFrom(Object value, TypeRef type) { @Override public Mono closeGracefully() { - return delegate.closeGracefully(); + return Mono.defer(() -> { + fail(abandon()); + return delegate.closeGracefully(); + }); } @Override public void close() { + List abandoned = abandon(); + fail(abandoned); delegate.close(); } @@ -131,6 +228,17 @@ public List protocolVersions() { return delegate.protocolVersions(); } - private record PendingSend(McpSchema.JSONRPCMessage message, MonoSink sink) {} + private static final class PendingSend { + private final McpSchema.JSONRPCMessage message; + private final MonoSink sink; + private final long bytes; + private boolean cancelled; + + PendingSend(McpSchema.JSONRPCMessage message, MonoSink sink, long bytes) { + this.message = message; + this.sink = sink; + this.bytes = bytes; + } + } } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java index 52321be..0f3c03d 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java @@ -1,6 +1,7 @@ package io.github.libtmux.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpSchema; @@ -8,6 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import reactor.core.Disposable; import reactor.core.publisher.Mono; @@ -62,6 +64,88 @@ void failedSendReleasesTheNextMessage() { second.dispose(); } + @Test + void aSlowClientHasFiniteMessageAdmission() { + PausingTransport delegate = new PausingTransport(); + McpServerTransport transport = SerializedTransportProvider.serialize(delegate); + AtomicInteger refused = new AtomicInteger(); + List sends = new ArrayList<>(); + + for (int index = 0; index < 257; index++) { + sends.add(transport + .sendMessage(notification("message-" + index)) + .subscribe(ignored -> {}, failure -> refused.incrementAndGet())); + } + + assertEquals(1, delegate.started(), "a stalled delegate must still have only one active send"); + assertEquals(1, refused.get(), "the send beyond the 256-message bound was retained"); + + transport.close(); + sends.forEach(Disposable::dispose); + } + + @Test + void aSingleMessageCannotExceedByteAdmission() { + PausingTransport delegate = new PausingTransport(); + McpServerTransport transport = SerializedTransportProvider.serialize(delegate); + AtomicReference refused = new AtomicReference<>(); + + Disposable send = transport + .sendMessage(notification("x".repeat(17 * 1024 * 1024))) + .subscribe(ignored -> {}, refused::set); + + assertInstanceOf(IllegalStateException.class, refused.get()); + assertEquals(0, delegate.started(), "an oversized message reached the delegate"); + + send.dispose(); + transport.close(); + } + + @Test + void cancellingAQueuedSendRemovesIt() { + PausingTransport delegate = new PausingTransport(); + McpServerTransport transport = SerializedTransportProvider.serialize(delegate); + Disposable first = transport.sendMessage(notification("first")).subscribe(); + Disposable cancelled = transport.sendMessage(notification("cancelled")).subscribe(); + + cancelled.dispose(); + delegate.succeed(0); + + assertEquals(1, delegate.started(), "the cancelled send was handed to the delegate"); + + first.dispose(); + transport.close(); + } + + @Test + void closeFailsEveryAdmittedSendAndRefusesAnother() { + PausingTransport delegate = new PausingTransport(); + McpServerTransport transport = SerializedTransportProvider.serialize(delegate); + AtomicInteger failed = new AtomicInteger(); + Disposable first = transport + .sendMessage(notification("first")) + .subscribe(ignored -> {}, failure -> failed.incrementAndGet()); + Disposable second = transport + .sendMessage(notification("second")) + .subscribe(ignored -> {}, failure -> failed.incrementAndGet()); + + transport.close(); + + assertEquals(2, failed.get(), "close left an admitted send unresolved"); + assertEquals(1, delegate.closed()); + delegate.succeed(0); + assertEquals(1, delegate.started(), "completion after close started a queued send"); + + AtomicReference afterClose = new AtomicReference<>(); + Disposable refused = + transport.sendMessage(notification("after-close")).subscribe(ignored -> {}, afterClose::set); + assertInstanceOf(IllegalStateException.class, afterClose.get()); + + first.dispose(); + second.dispose(); + refused.dispose(); + } + private static McpSchema.JSONRPCNotification notification(String value) { return new McpSchema.JSONRPCNotification("test/notification", value); } @@ -69,6 +153,7 @@ private static McpSchema.JSONRPCNotification notification(String value) { private static final class PausingTransport implements McpServerTransport { private final List> completions = new ArrayList<>(); + private final AtomicInteger closed = new AtomicInteger(); @Override public Mono sendMessage(McpSchema.JSONRPCMessage message) { @@ -91,6 +176,10 @@ void fail(int index) { completions.get(index).tryEmitError(new IllegalStateException("send failed")); } + int closed() { + return closed.get(); + } + @Override public T unmarshalFrom(Object value, TypeRef type) { throw new UnsupportedOperationException(); @@ -100,5 +189,10 @@ public T unmarshalFrom(Object value, TypeRef type) { public Mono closeGracefully() { return Mono.empty(); } + + @Override + public void close() { + closed.incrementAndGet(); + } } } From 0e514436f3963415e39d54e5ceec3bb6847f8694 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:47:09 -0500 Subject: [PATCH 67/77] MCP(fix[run]): Preserve accepted commands why: Temporary command staging was deleted as soon as send-keys reported uncertain delivery, before a busy pane shell necessarily opened the file. An accepted caller command could disappear while the framing still ran. what: - pass caller text as one quoted eval operand inside the framing subshell - gate ambiguous delivery and prove the accepted command still executes --- .../github/libtmux/mcp/RunningCommands.java | 94 ++++++------------- .../libtmux/mcp/RunningCommandsTest.java | 30 +++++- 2 files changed, 53 insertions(+), 71 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 8d20087..1f839e9 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -3,12 +3,6 @@ import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.WakeReason; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.PosixFilePermissions; import java.security.SecureRandom; import java.time.Duration; import java.util.ArrayList; @@ -81,38 +75,30 @@ static Ran run(Call call) { String endMark = nonce + "-e"; String channel = "ch_" + nonce; - try (StagedCommand staged = StagedCommand.create(command)) { - Cursor before = Screen.from(pane).cursor(); - String typed = payload(server, staged.path(), nonce, startMark, endMark, channel, suppressHistory); - // Never make the shell wait for Java cleanup: a transport can report UNKNOWN after tmux - // accepted this line, and that failure must not strand the pane at private plumbing. - pane.sendLine(typed); - - long started = System.nanoTime(); - WakeReason wake = server.channel(channel).await(timeout); - double seconds = (System.nanoTime() - started) / 1_000_000_000.0; - - Screen.Fresh fresh = - wake == WakeReason.SERVER_GONE ? null : Screen.since(pane, before, Trim.lineBudget(call)); - Framed framed = - fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); - Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; - Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); - - return new Ran( - pane.id().value(), - wake.name(), - status, - trimmed.lines(), - trimmed.truncated(), - trimmed.dropped(), - framed.exact(), - Math.round(seconds * 100) / 100.0, - Waits.asSeconds(timeout), - note(wake, framed)); - } catch (IOException e) { - throw new UncheckedIOException("could not stage the pane command", e); - } + Cursor before = Screen.from(pane).cursor(); + String typed = payload(server, command, nonce, startMark, endMark, channel, suppressHistory); + pane.sendLine(typed); + + long started = System.nanoTime(); + WakeReason wake = server.channel(channel).await(timeout); + double seconds = (System.nanoTime() - started) / 1_000_000_000.0; + + Screen.Fresh fresh = wake == WakeReason.SERVER_GONE ? null : Screen.since(pane, before, Trim.lineBudget(call)); + Framed framed = fresh == null ? new Framed(List.of(), false, null) : frame(fresh.lines(), startMark, endMark); + Integer status = wake == WakeReason.SIGNALLED ? framed.status() : null; + Trim.Trimmed trimmed = Trim.tail(framed.lines(), Trim.lineBudget(call)); + + return new Ran( + pane.id().value(), + wake.name(), + status, + trimmed.lines(), + trimmed.truncated(), + trimmed.dropped(), + framed.exact(), + Math.round(seconds * 100) / 100.0, + Waits.asSeconds(timeout), + note(wake, framed)); } private static @Nullable String note(WakeReason wake, Framed framed) { @@ -144,7 +130,7 @@ static Ran run(Call call) { */ private static String payload( Server server, - Path command, + String command, String nonce, String startMark, String endMark, @@ -162,8 +148,8 @@ private static String payload( // The status is held in a shell variable named for the nonce, so nothing this types can // collide with a variable the person using the pane already had. - return (suppressHistory ? " " : "") + "echo " + startMark + "; ( . " + Shell.quote(command.toString()) + " ); " - + nonce + "=$?; echo " + endMark + ":\"$" + nonce + "\"; " + finish; + return (suppressHistory ? " " : "") + "echo " + startMark + "; ( eval " + Shell.quote(command) + " ); " + nonce + + "=$?; echo " + endMark + ":\"$" + nonce + "\"; " + finish; } private static List append(List base, String... more) { @@ -229,30 +215,4 @@ private static byte[] bytes() { RANDOM.nextBytes(value); return value; } - - private record StagedCommand(Path path) implements AutoCloseable { - - private static StagedCommand create(String command) throws IOException { - Path path = Files.createTempFile( - "libtmux-java-command-", - ".sh", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); - try { - Files.writeString(path, command, StandardCharsets.UTF_8); - return new StagedCommand(path); - } catch (IOException failure) { - try { - Files.deleteIfExists(path); - } catch (IOException cleanup) { - failure.addSuppressed(cleanup); - } - throw failure; - } - } - - @Override - public void close() throws IOException { - Files.deleteIfExists(path); - } - } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 4129915..225d93a 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -182,8 +182,18 @@ void aTimedOutCommandLeavesNoStatusWhenItEventuallyFinishes(Server server) throw } @Test - void uncertainCommandDeliveryDoesNotLeaveThePaneBlocked(Server server) throws Exception { + void uncertainCommandDeliveryStillRunsTheAcceptedCommand(Server server, @TempDir Path temporary) throws Exception { String pane = server.panes().get(0).id().value(); + Path accepted = temporary.resolve("accepted"); + Path entered = temporary.resolve("entered"); + String gate = "uncertain-delivery-" + System.nanoTime(); + var wait = new java.util.ArrayList<>(java.util.List.of(server.config().binaryPath())); + wait.addAll(server.config().endpoint().flags()); + wait.addAll(java.util.List.of("wait-for", gate)); + server.panes() + .get(0) + .sendLine("printf entered > " + Shell.quote(entered.toString()) + "; " + Shell.quoteAll(wait)); + assertTrue(await(() -> Files.exists(entered)), "the pane never entered the delivery gate"); try (ProcessTransport processes = new ProcessTransport()) { TmuxTransport uncertain = borrowing(request -> { CommandResult result = processes.execute(request); @@ -193,11 +203,23 @@ void uncertainCommandDeliveryDoesNotLeaveThePaneBlocked(Server server) throws Ex return result; }); try (Server measured = Server.using(server.config(), uncertain)) { - assertThrows( - TmuxTransportException.class, - () -> RunningCommands.run(TestCalls.on(measured, "pane_id", pane, "command", "true"))); + try { + assertThrows( + TmuxTransportException.class, + () -> RunningCommands.run(TestCalls.on( + measured, + "pane_id", + pane, + "command", + "printf ran > " + Shell.quote(accepted.toString())))); + } finally { + server.channel(gate).signal(); + } server.panes().get(0).sendLine("printf 'uncertain-cleanup-%s\\n' finished"); + assertTrue( + await(() -> Files.exists(accepted)), "an accepted command was lost after ambiguous delivery"); + assertEquals("ran", Files.readString(accepted)); assertTrue( await(() -> server.panes().get(0).capture().stream() .anyMatch(line -> line.contains("uncertain-cleanup-finished"))), From c4aa2090a7b1b3fa2278aa3405bc7d2e10cd043d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 08:02:54 -0500 Subject: [PATCH 68/77] MCP(fix[send]): Skip cancelled promotions why: A send promoted before the previous caller was notified still reached the delegate when that callback cancelled it. what: - Distinguish promoted sends from ones already started - Release cancelled promotion admission and continue in order - Cover callback cancellation before delegate start --- .../mcp/SerializedTransportProvider.java | 12 +++++++ .../mcp/SerializedTransportProviderTest.java | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java index 062c52d..b97acae 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/SerializedTransportProvider.java @@ -117,12 +117,22 @@ private void enqueue(PendingSend added) { } private void cancel(PendingSend cancelled) { + @Nullable PendingSend next = null; synchronized (sends) { cancelled.cancelled = true; if (pending.remove(cancelled)) { release(cancelled); + } else if (active == cancelled && !cancelled.started) { + active = null; + release(cancelled); + if (!pending.isEmpty()) { + next = takeNext(); + } } } + if (next != null) { + start(next); + } } private PendingSend takeNext() { @@ -137,6 +147,7 @@ private void start(PendingSend next) { if (closed || active != next) { return; } + next.started = true; delegate.sendMessage(next.message) .subscribe(ignored -> {}, failure -> finish(next, failure), () -> finish(next, null)); } @@ -233,6 +244,7 @@ private static final class PendingSend { private final MonoSink sink; private final long bytes; private boolean cancelled; + private boolean started; PendingSend(McpSchema.JSONRPCMessage message, MonoSink sink, long bytes) { this.message = message; diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java index 0f3c03d..be7852d 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/SerializedTransportProviderTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; import io.modelcontextprotocol.json.TypeRef; import io.modelcontextprotocol.spec.McpSchema; @@ -117,6 +118,31 @@ void cancellingAQueuedSendRemovesIt() { transport.close(); } + @Test + void cancellingAPromotedSendBeforeItStartsRemovesIt() { + PausingTransport delegate = new PausingTransport(); + McpServerTransport transport = SerializedTransportProvider.serialize(delegate); + AtomicReference promoted = new AtomicReference<>(); + McpSchema.JSONRPCNotification firstMessage = notification("first"); + McpSchema.JSONRPCNotification cancelledMessage = notification("cancelled"); + McpSchema.JSONRPCNotification thirdMessage = notification("third"); + Disposable first = transport + .sendMessage(firstMessage) + .subscribe(ignored -> {}, failure -> {}, () -> promoted.get().dispose()); + promoted.set(transport.sendMessage(cancelledMessage).subscribe()); + Disposable third = transport.sendMessage(thirdMessage).subscribe(); + + delegate.succeed(0); + + assertEquals(2, delegate.started(), "cancellation did not release the next queued send"); + assertSame(thirdMessage, delegate.message(1), "the cancelled promoted send reached the delegate"); + delegate.succeed(1); + + first.dispose(); + third.dispose(); + transport.close(); + } + @Test void closeFailsEveryAdmittedSendAndRefusesAnother() { PausingTransport delegate = new PausingTransport(); @@ -153,6 +179,7 @@ private static McpSchema.JSONRPCNotification notification(String value) { private static final class PausingTransport implements McpServerTransport { private final List> completions = new ArrayList<>(); + private final List messages = new ArrayList<>(); private final AtomicInteger closed = new AtomicInteger(); @Override @@ -160,6 +187,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage message) { return Mono.defer(() -> { Sinks.One completion = Sinks.one(); completions.add(completion); + messages.add(message); return completion.asMono(); }); } @@ -168,6 +196,10 @@ int started() { return completions.size(); } + McpSchema.JSONRPCMessage message(int index) { + return messages.get(index); + } + void succeed(int index) { completions.get(index).tryEmitEmpty(); } From 1de99b0d27c62b0724fdcc52e35dde428abeb32f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 08:57:25 -0500 Subject: [PATCH 69/77] Docs(docs[changelog]): Note literal IO and bounds --- CHANGELOG.md | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50efd7f..11f9ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,114 @@ production. ## Unreleased +### Added + +- **`Pane.findWindow` searches by name, title, or content, with + case-insensitive and regular-expression matching.** Build a `FindSpec` or + configure one inline. (#6) +- **`Batch.length()` reports the encoded command size.** Use it to dispatch + before tmux's command-size limit is reached. (#6) +- **`Pane.paste(String)` sends literal text without leaving a server buffer.** + Text no longer shares tmux's command-size limit; this API requires tmux 3.4. + (#6) + +### Changed + +- **`Pane.findWindowByName` and `Pane.findWindowByContent` are removed.** Use + `findWindow` with `inName()` or `inContent()`. (#6) +- **`Server.waitFor`, `waitForWithSignalCapacity`, `signal`, and `drain` move + to `Server.channel(name)`.** Use `await`, `awaitReservingCapacity`, + `signal`, and `drain` on the returned `Channel`. (#6) +- **`Pane.mode()` returns `PaneMode` rather than text.** Compare against enum + values such as `PaneMode.TREE`; `modeOrNull()` changes with it. (#6) +- **The supported tmux range now includes 3.7c.** The compatibility matrix runs + that lane. (#6) +- **`Server.snapshot()` captures the hierarchy in one fenced command group.** + Watchers start fewer tmux processes, and a replacement server is rejected + before its rows are read. (#6) +- **`CommandRequest` carries command groups and optional input rather than one + flat argv.** Use `CommandRequest.of` for one command and `commands()` where + `argv()` was read; `ControlClient.isCommandGroup` is removed. (#6) +- **`Pane.paste(String bufferName)` is now `Pane.pasteBuffer(String name)`.** + `Pane.paste(String)` now means literal text. (#6) +- **`ControlClient.onOutput` and `onEvent` are replaced by bounded pull + subscriptions.** Use `subscribeOutput` or `subscribeEvents`, close the + returned `EventSubscription`, and inspect `droppedCount()`. (#6) +- **Filter expressions are immutable, model-bound wire values.** Build fields + and relations through `Fields` or generated handles, and pass the matching + `FilterModel` to `FilterJson.write*`; `EntityMetamodel`, `FieldProvenance`, + and `FieldRef.name()` are removed. (#6) +- **`ProcessTransport` now bounds concurrency, input writes, output, deadlines, + and cleanup.** Configure its limits through the constructors; timeouts + surface as `TmuxTimeoutException` with dispatch certainty. A deadline or + cancellation during input terminates the child instead of blocking. (#6) +- **`Buffers.delete` now requires tmux 3.4 and reports a missing name.** tmux + 3.2a and 3.3a can delete the top buffer when the named buffer is absent, so + the library refuses that unsafe operation. (#6) + +### Fixed + +- **Hierarchy listings preserve values containing newlines.** A pane working + directory containing a newline no longer empties session, window, and pane + listings. (#6) +- **`Options.all`, `Options.effective`, and `Options.get` return complete + stored values.** Escape-sensitive and multiline strings are no longer + altered or truncated. (#6) +- **`Server.expand`, `Session.expand`, `Window.expand`, and `Pane.expand` + preserve multiline results.** They no longer return only the first line. + (#6) +- **Names, options, sent keys, shell commands, and batch operations preserve a + trailing semicolon.** The semicolon remains data rather than ending the tmux + command. (#6) +- **`Pane.sendLine`, command-chain input, and `tmux_run` deliver text plus + Enter as one literal operation.** Option-shaped input remains data, and + concurrent calls cannot interleave their input. (#6) +- **`tmux_paste_text` no longer leaves pasted text on the server when a client + disconnects during the call.** (#6) +- **`tmux_run` keeps caller shell syntax isolated from completion framing, uses + the server's resolved tmux binary, and keeps completion state off pane + options.** A command accepted before an indeterminate send failure still + executes. (#6) +- **Serialized MCP sends obey their queue and byte bounds across cancellation + and shutdown.** A send cancelled after promotion is skipped before delegate + delivery, later sends keep order, and close settles every admitted caller. + (#6) +- **`libtmux-mcp` publishes the dependencies its API exposes.** Consumers now + receive `mcp-core`; the artifact no longer selects an SLF4J provider or + exports `libtmux-jackson`. (#6) +- **Handles refuse a replacement tmux server that reused an identifier.** + Linked-window operations also retain the exact session and index they came + from. (#6) +- **Workspace input is validated before session creation.** Unsupported + layouts, unsafe names, malformed topology, and uncertain creation replies + leave tmux untouched or roll back the exact staging session. (#6) +- **MCP watching stays consistent across concurrent output, new sessions, + dropped events, outages, and server restarts.** Notifications are serialized + and bounded, and watcher clients remain hidden from listings. (#6) +- **The MCP launcher exits when its protocol session ends.** Oversized or + malformed input and output failures no longer leave the process waiting on + stdin. (#6) +- **MCP tools now advertise non-additive effects as destructive.** Clients can + request confirmation for commands, input, and settings even when the safety + ceiling permits those tools. (#6) +- **MCP rename and kill tools resolve destructive targets unambiguously.** + Session operations use stable identifiers; only arguments explicitly naming + a session by name use names. (#6) +- **Snapshot-backed accessors reject a closed `Server`.** They no longer turn + use after close into an empty hierarchy. (#6) +- **A live server with no sessions captures as an empty snapshot.** Child + listings are not attempted when tmux has no current target. (#6) +- **`TmuxExtension` proves abandoned servers exited before deleting their + directories.** Successful recovery also removes the abandoned directory. + (#6) + +### Removed + +- **`ExecutionMode`, `ControlTransport`, `VirtualThreadTransport`, + `LIBTMUX_MODE`, and their benchmark surface are removed.** `Server` uses + process execution; use `ControlClient` for event streams and batches or + command chains to reduce round trips. (#6) + ## 0.0.1-alpha.7 — 2026-08-22 ### Documented From b7b20d3ea9bd655dd2b3563ea121eeec6070a2e5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:40:46 -0500 Subject: [PATCH 70/77] Mcp(fix[safety]): Refuse uncertain self-kills why: Destructive tools treated an unprovable caller identity as a different server and could kill the pane hosting the conversation. what: - Track outside, different, self, and unknown caller relationships - Require both the server PID and socket identity before trusting a pane - Fail closed on destructive calls when caller identity is uncertain - Cover the refusal against a real isolated tmux server --- .../java/io/github/libtmux/mcp/Caller.java | 73 +++++++++++++++---- .../java/io/github/libtmux/mcp/Shaping.java | 10 ++- .../java/io/github/libtmux/mcp/TestCalls.java | 19 ++++- .../libtmux/mcp/ToolsAgainstTmuxTest.java | 18 +++++ 4 files changed, 104 insertions(+), 16 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java index e4ffec2..342bc9f 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java @@ -16,17 +16,27 @@ * different from every other: typing into it types into the conversation, and killing it kills the * thing the model is talking through. * - *

The socket is checked as well as the pane, because a pane id is only unique within one server - * and this process may have been pointed at a different one. Unprovable means not the caller's: a - * wrong "yes" disarms a guard, while a wrong "no" only declines to help. + *

The socket and server process are checked as well as the pane, because a pane id is only unique + * within one server. Destructive tools refuse when that relationship cannot be proved. */ final class Caller { - private static final Caller NOWHERE = new Caller(null); + private enum Relation { + OUTSIDE, + DIFFERENT_SERVER, + SELF, + UNKNOWN + } + + private static final Caller NOWHERE = new Caller(Relation.OUTSIDE, null); + private static final Caller DIFFERENT = new Caller(Relation.DIFFERENT_SERVER, null); + private static final Caller UNKNOWN = new Caller(Relation.UNKNOWN, null); + private final Relation relation; private final @Nullable PaneId pane; - private Caller(@Nullable PaneId pane) { + private Caller(Relation relation, @Nullable PaneId pane) { + this.relation = relation; this.pane = pane; } @@ -36,16 +46,46 @@ static Caller of(Server server) { } static Caller of(Server server, Map environment) { + String raw = environment.get("TMUX"); + if (raw == null || raw.isEmpty()) { + return NOWHERE; + } Optional inside = TmuxEnvironment.of(environment); if (inside.isEmpty()) { - return NOWHERE; + return UNKNOWN; } TmuxEnvironment here = inside.get(); Optional pane = here.pane(); - if (pane.isEmpty() || !sameFile(here.socket(), socketOf(server))) { - return NOWHERE; + if (pane.isEmpty()) { + return UNKNOWN; + } + Long serverPid = pidOf(server); + if (serverPid == null) { + return UNKNOWN; + } + if (serverPid != here.serverPid()) { + return DIFFERENT; + } + return switch (sameFile(here.socket(), socketOf(server))) { + case SAME -> new Caller(Relation.SELF, pane.get()); + case DIFFERENT -> DIFFERENT; + case UNKNOWN -> UNKNOWN; + }; + } + + private enum FileRelation { + SAME, + DIFFERENT, + UNKNOWN + } + + private static @Nullable Long pidOf(Server server) { + try { + long pid = Long.parseLong(server.expand("#{pid}")); + return pid > 0 ? pid : null; + } catch (RuntimeException e) { + return null; } - return new Caller(pane.get()); } /** For a server that is known not to be the one this process runs in. */ @@ -63,6 +103,11 @@ boolean isSelf(PaneId target) { return target.equals(pane); } + /** Whether the process is inside tmux but its relation to this server is unprovable. */ + boolean uncertain() { + return relation == Relation.UNKNOWN; + } + /** tmux is asked which socket it is on, rather than the endpoint being reassembled from flags. */ private static @Nullable Path socketOf(Server server) { try { @@ -77,14 +122,16 @@ boolean isSelf(PaneId target) { * Compared by what the filesystem says rather than by text, so a socket reached through a * symlink or a relative path is still the same socket. */ - private static boolean sameFile(Path left, @Nullable Path right) { + private static FileRelation sameFile(Path left, @Nullable Path right) { if (right == null) { - return false; + return FileRelation.UNKNOWN; } try { - return left.toRealPath().equals(right.toRealPath()); + return left.toRealPath().equals(right.toRealPath()) + ? FileRelation.SAME + : FileRelation.DIFFERENT; } catch (IOException | RuntimeException e) { - return false; + return FileRelation.UNKNOWN; } } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java index 44d4c5f..be5ebc1 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Shaping.java @@ -210,8 +210,16 @@ private static void guard(Call call, List going, boolean confirmed, String if (confirmed) { return; } + if (call.caller().uncertain()) { + throw new IllegalStateException( + "Refused. This process is inside tmux, but could not prove whether the target " + + "contains its own pane. Pass confirm_self=true only if disconnecting " + + "this conversation is the actual goal."); + } Optional mine = call.caller().pane(); - if (mine.isEmpty() || going.stream().noneMatch(pane -> call.caller().isSelf(pane.id()))) { + if (mine.isEmpty() + || (!"server".equals(kind) + && going.stream().noneMatch(pane -> call.caller().isSelf(pane.id())))) { return; } List others = going.stream() diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java index dde243e..166787c 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java @@ -29,13 +29,28 @@ static Call on(Server server, Object... pairs) { static Call asCaller(Server server, String paneId, Object... pairs) { Call plain = on(server, pairs); Map environment = Map.of( - "TMUX", socket(server) + ",1," + server.sessions().get(0).id().value(), "TMUX_PANE", paneId); + "TMUX", + socket(server) + "," + server.expand("#{pid}") + "," + + server.sessions().get(0).id().value(), + "TMUX_PANE", + paneId); + return withEnvironment(server, environment, plain.arguments()); + } + + /** A call carrying an explicit process environment, including malformed caller identities. */ + static Call withEnvironment(Server server, Map environment, Object... pairs) { + Call plain = on(server, pairs); + return withEnvironment(server, environment, plain.arguments()); + } + + private static Call withEnvironment( + Server server, Map environment, Map arguments) { Connection connection = new Connection( server, Caller.of(server, environment), Safety.DESTRUCTIVE, java.util.concurrent.ConcurrentHashMap.newKeySet()); - return new Call(connection, plain.arguments(), Call.Progress.SILENT); + return new Call(connection, arguments, Call.Progress.SILENT); } private static String socket(Server server) { diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index d223f54..3a28ae6 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -16,6 +16,7 @@ import io.github.libtmux.transport.ProcessTransport; import io.github.libtmux.transport.TmuxTransport; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -267,6 +268,23 @@ void confirmingIsEnoughToEndTheCallersOwnPane(Server server) { assertEquals(1, server.panes().size()); } + @Test + void uncertainCallerIdentityRefusesServerKill(Server server) { + String pane = server.panes().get(0).id().value(); + Map uncertain = Map.of( + "TMUX", "/tmp/libtmux-java-test/missing-socket," + server.expand("#{pid}") + ",0", + "TMUX_PANE", pane); + + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> Shaping.kill(TestCalls.withEnvironment( + server, uncertain, "target", "server"))); + + String message = String.valueOf(refused.getMessage()); + assertTrue(message.contains("could not prove"), message); + assertTrue(server.isAlive(), "uncertainty must not disable the destructive guard"); + } + /** The window holding the caller's pane is as fatal as the pane itself. */ @Test void killingAWindowHoldingTheCallersPaneIsRefusedToo(Server server) { From 08229899643a69caafec6791dc02f2898bb0713d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:51:22 -0500 Subject: [PATCH 71/77] Mcp(fix[cursors]): Fence resumed pane reads why: Retained history size is not a lifetime offset, and forged or stale cursors could be accepted across history compaction or server replacement. what: - Authenticate cursors and bind them to the tmux server process - Resume from strong trailing-line context instead of history offsets - Fence screen batches to the pane snapshot and require complete replies - Cover compaction, forged cursors, replacement servers, and failed reads --- .../java/io/github/libtmux/mcp/Cursor.java | 124 ++++++++++-------- .../java/io/github/libtmux/mcp/Screen.java | 102 +++++++++----- .../io/github/libtmux/mcp/ReadingTest.java | 74 +++++++++++ .../src/main/java/io/github/libtmux/Pane.java | 6 + 4 files changed, 220 insertions(+), 86 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cursor.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cursor.java index 682bcdf..37ee25c 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cursor.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cursor.java @@ -1,83 +1,103 @@ package io.github.libtmux.mcp; -import io.github.libtmux.Pane; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; import java.util.Base64; import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; -/** - * A place in a pane's output that a caller has already read up to. - * - *

Watching a pane means asking repeatedly, and asking repeatedly is what makes an agent expensive: - * the tenth look at a build log re-reads the nine screens it already paid for. A cursor turns that - * into "what is new", which is almost always nothing or a few lines. - * - *

Opaque on purpose. What it holds is this server's business and may change; a caller that parsed - * it would break, and a caller that invents one gets told to start again rather than handed the - * wrong lines. - * - * @param paneId the pane this position belongs to, so a cursor cannot be used on another - * @param absolute how many lines had ever been above the bottom of the screen when it was taken - * @param anchor a digest of the last line already delivered, which is how loss is noticed - */ -record Cursor(String paneId, int absolute, String anchor) { - - private static final String VERSION = "1"; +/** An authenticated trailing-line context for one pane and server process. */ +record Cursor(long serverPid, String paneId, List anchors) { + private static final String VERSION = "2"; + private static final int CONTEXT_LINES = 8; + private static final int DIGEST_BYTES = 16; private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final byte[] SECRET = secret(); - /** Where a pane is now: everything down to its last written line has been seen. */ - static Cursor at(Pane pane, int history, List seen) { - return of(pane.id().value(), history, seen); + Cursor { + if (serverPid <= 0 || paneId.isEmpty() || anchors.size() > CONTEXT_LINES) { + throw new IllegalArgumentException("invalid cursor state"); + } + anchors = List.copyOf(anchors); + if (anchors.stream().anyMatch(anchor -> !anchor.matches("[A-Za-z0-9_-]{22}"))) { + throw new IllegalArgumentException("invalid cursor anchor"); + } } - /** - * A cursor one past the last of {@code written}. - * - * @param firstAbsolute how many lines the pane had written above {@code written}'s first line - * @param written the pane's lines, trailing blanks already removed - */ - static Cursor of(String paneId, int firstAbsolute, List written) { - String last = written.isEmpty() ? "" : written.get(written.size() - 1); - return new Cursor(paneId, firstAbsolute + written.size(), digest(last)); + /** Records up to the last eight finished lines, without retaining their contents. */ + static Cursor of(long serverPid, String paneId, List written) { + int first = Math.max(0, written.size() - CONTEXT_LINES); + return new Cursor( + serverPid, + paneId, + written.subList(first, written.size()).stream().map(Cursor::digest).toList()); } String encode() { - return ENCODER.encodeToString( - (VERSION + "|" + paneId + "|" + absolute + "|" + anchor).getBytes(StandardCharsets.UTF_8)); + byte[] payload = (VERSION + "|" + serverPid + "|" + paneId + "|" + String.join(",", anchors)) + .getBytes(StandardCharsets.UTF_8); + return ENCODER.encodeToString(payload) + "." + ENCODER.encodeToString(mac(payload)); } - /** - * Reads a cursor a caller sent back. - * - * @throws IllegalArgumentException naming the recovery, because a caller holding an unreadable - * cursor can always start again by asking without one - */ + /** Reads a cursor issued by this process, or tells the caller to start again. */ static Cursor decode(String encoded) { - String plain; try { - plain = new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8); + String[] token = encoded.split("\\.", -1); + if (token.length != 2) { + throw unreadable(); + } + byte[] payload = Base64.getUrlDecoder().decode(token[0]); + byte[] signature = Base64.getUrlDecoder().decode(token[1]); + if (!MessageDigest.isEqual(mac(payload), signature)) { + throw unreadable(); + } + String[] parts = new String(payload, StandardCharsets.UTF_8).split("\\|", -1); + if (parts.length != 4 || !VERSION.equals(parts[0])) { + throw unreadable(); + } + List anchors = parts[3].isEmpty() ? List.of() : List.of(parts[3].split(",", -1)); + return new Cursor(Long.parseLong(parts[1]), parts[2], anchors); } catch (IllegalArgumentException e) { + if (e.getMessage() != null && e.getMessage().startsWith("that cursor")) { + throw e; + } throw unreadable(); } - String[] parts = plain.split("\\|", -1); - if (parts.length != 4 || !VERSION.equals(parts[0])) { - throw unreadable(); + } + + static String digest(String line) { + try { + byte[] whole = MessageDigest.getInstance("SHA-256") + .digest(line.getBytes(StandardCharsets.UTF_8)); + return ENCODER.encodeToString(Arrays.copyOf(whole, DIGEST_BYTES)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); } + } + + private static byte[] mac(byte[] payload) { try { - return new Cursor(parts[1], Integer.parseInt(parts[2]), parts[3]); - } catch (NumberFormatException e) { - throw unreadable(); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET, "HmacSHA256")); + return mac.doFinal(payload); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HMAC-SHA256 is unavailable", e); } } + private static byte[] secret() { + byte[] secret = new byte[32]; + new SecureRandom().nextBytes(secret); + return secret; + } + private static IllegalArgumentException unreadable() { return new IllegalArgumentException( "that cursor is not one this server issued; omit 'cursor' to start from what the pane shows now"); } - - /** Short enough to keep a cursor small, wide enough that a neighbouring line will not collide. */ - static String digest(String line) { - return Integer.toHexString(line.hashCode()); - } } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java index 168ef47..e96e6ed 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java @@ -1,7 +1,9 @@ package io.github.libtmux.mcp; +import io.github.libtmux.LibTmuxException; import io.github.libtmux.Pane; import io.github.libtmux.batch.BatchResult; +import io.github.libtmux.batch.OperationResult; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -62,7 +64,7 @@ static Fresh everything(Pane pane, boolean withHistory) { // finished line, so watching on from here cannot trip over a half-written one. return new Fresh( withoutTrailingBlanks(look.lines()), - Cursor.of(pane.id().value(), look.firstAbsolute(), look.complete()), + Cursor.of(look.serverPid(), pane.id().value(), look.complete()), true); } @@ -93,40 +95,57 @@ static Fresh since(Pane pane, @Nullable Cursor from, int budget) { * @return the answer, or null when the cursor's line is older than this look reached */ private static @Nullable Fresh resolve(Cursor from, Look look) { - // Where the last delivered line sits in what was captured. Both numbers come from the same - // invocation, so this cannot be off by however far the pane scrolled meanwhile. - int anchor = from.absolute() - 1 - look.firstAbsolute(); - if (anchor < 0 && !look.reachedStartOfHistory()) { - return null; + if (from.serverPid() != look.serverPid()) { + throw new IllegalArgumentException( + "that cursor belongs to an earlier tmux server; omit 'cursor' to start again"); } List written = look.complete(); - // Wherever this ends up, every finished line is now delivered — so the cursor says the same - // thing on every path, and only what is handed back differs. - Cursor now = Cursor.of(from.paneId(), look.firstAbsolute(), written); - - // A cursor at the very beginning has no line before it to check against, so there is nothing - // it could fail to follow on from. - boolean continuous = from.absolute() == 0 - || (anchor >= 0 - && anchor < written.size() - && Cursor.digest(written.get(anchor)).equals(from.anchor())); - if (!continuous) { + Cursor now = Cursor.of(look.serverPid(), from.paneId(), written); + if (from.anchors().isEmpty()) { + if (!look.reachedStartOfHistory()) { + return null; + } + return new Fresh(List.copyOf(written), now, true); + } + int after = uniqueAnchorEnd(written, from.anchors()); + if (after < 0 && !look.reachedStartOfHistory()) { + return null; + } + if (after < 0) { return new Fresh(List.copyOf(written), now, false); } - int after = from.absolute() - look.firstAbsolute(); - List fresh = - after < written.size() ? List.copyOf(written.subList(Math.max(after, 0), written.size())) : List.of(); + List fresh = after < written.size() ? List.copyOf(written.subList(after, written.size())) : List.of(); return new Fresh(fresh, now, true); } + /** Returns the end of one unambiguous context match, or -1. */ + private static int uniqueAnchorEnd(List lines, List anchors) { + int match = -1; + for (int start = 0; start + anchors.size() <= lines.size(); start++) { + boolean same = true; + for (int offset = 0; offset < anchors.size(); offset++) { + if (!Cursor.digest(lines.get(start + offset)).equals(anchors.get(offset))) { + same = false; + break; + } + } + if (same) { + if (match >= 0) { + return -1; + } + match = start + anchors.size(); + } + } + return match; + } + /** * One capture and the pane's own position, from one tmux invocation. * - * @param firstAbsolute how many lines the pane had written above the first line captured * @param finished how many of the captured lines the terminal's cursor has moved past * @param reachedStartOfHistory whether the capture began at the oldest line tmux still holds */ - private record Look(List lines, int firstAbsolute, int finished, boolean reachedStartOfHistory) { + private record Look(List lines, int finished, boolean reachedStartOfHistory, long serverPid) { /** * The lines that are finished being written. @@ -146,29 +165,44 @@ private static Look look(Pane pane, int lookback) { // Everything tmux keeps, when asked for more than it could have. boolean everything = lookback >= Integer.MAX_VALUE; String start = everything ? "-" : lookback <= 0 ? "0" : String.valueOf(-lookback); - BatchResult read = pane.server() - .batch() + BatchResult read = pane.batch() .add("capture-pane", "-p", "-t", id, "-S", start) - .add("display-message", "-p", "-t", id, "#{history_size} #{cursor_y}") + .add("display-message", "-p", "-t", id, "#{pid} #{history_size} #{cursor_y}") .run(); - List lines = read.operations().get(0).stdout(); - int[] position = numbers(read.operations().get(1).stdout()); - int history = position[0]; + if (read.operations().size() != 2 + || read.operations().stream().anyMatch(operation -> !operation.succeeded())) { + throw new LibTmuxException("could not read pane content and position as one batch"); + } + OperationResult capture = read.operations().get(0); + OperationResult position = read.operations().get(1); + long[] numbers = numbers(position.stdout()); + long serverPid = numbers[0]; + int history = Math.toIntExact(numbers[1]); + int cursorY = Math.toIntExact(numbers[2]); int first = everything || lookback > history ? 0 : lookback <= 0 ? history : history - lookback; // The cursor's row is the first unfinished line, and it sits that far below the history. - return new Look(lines, first, history + position[1] - first, first == 0); + return new Look(capture.stdout(), history + cursorY - first, first == 0, serverPid); } - private static int[] numbers(List stdout) { - String[] words = stdout.isEmpty() ? new String[0] : stdout.get(0).trim().split("\\s+"); - int[] read = new int[2]; + private static long[] numbers(List stdout) { + if (stdout.size() != 1) { + throw new LibTmuxException("tmux returned no unambiguous pane position"); + } + String[] words = stdout.get(0).trim().split("\\s+", -1); + if (words.length != 3) { + throw new LibTmuxException("tmux returned a malformed pane position"); + } + long[] read = new long[3]; for (int index = 0; index < read.length; index++) { try { - read[index] = index < words.length ? Integer.parseInt(words[index]) : 0; + read[index] = Long.parseLong(words[index]); } catch (NumberFormatException e) { - read[index] = 0; + throw new LibTmuxException("tmux returned a nonnumeric pane position", e); } } + if (read[0] <= 0 || read[1] < 0 || read[2] < 0) { + throw new LibTmuxException("tmux returned an invalid pane position"); + } return read; } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java index 8f84706..7a63d3b 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java @@ -6,9 +6,17 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.github.libtmux.LibTmuxException; +import io.github.libtmux.Pane; import io.github.libtmux.Server; import io.github.libtmux.junit5.TmuxExtension; +import io.github.libtmux.transport.CommandRequest; +import io.github.libtmux.transport.CommandResult; +import io.github.libtmux.transport.ProcessTransport; +import io.github.libtmux.transport.TmuxTransport; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -198,6 +206,72 @@ void aCursorThisServerNeverIssuedSaysHowToStartAgain(Server server) { assertTrue(String.valueOf(refused.getMessage()).contains("omit 'cursor'"), refused.getMessage()); } + @Test + void aStructurallyValidForgedCursorIsRefused() { + String forged = Base64.getUrlEncoder() + .withoutPadding() + .encodeToString("1|%1|0|0".getBytes(StandardCharsets.UTF_8)); + + assertThrows(IllegalArgumentException.class, () -> Cursor.decode(forged)); + } + + @Test + void historyCompactionKeepsAReachableCursorContinuous(Server server) { + server.globalOptions().set("history-limit", "40"); + var window = server.sessions().get(0).newWindow("rolling-history"); + String pane = window.panes().get(0).id().value(); + server.cmd("resize-window", "-t", window.id().value(), "-x", "80", "-y", "5"); + run(server, pane, "for i in $(seq 1 30); do printf 'before-%03d\\n' $i; done"); + String cursor = settled(server, pane); + + run(server, pane, "for i in $(seq 1 15); do printf 'after-%03d\\n' $i; done"); + Reading.Since fresh = Reading.since(TestCalls.on(server, "pane_id", pane, "cursor", cursor)); + + assertTrue(fresh.continuous(), "the anchor still exists after tmux compacts older history"); + assertTrue(fresh.content().stream().anyMatch(line -> line.contains("after-015")), fresh.content().toString()); + assertTrue( + fresh.content().stream().noneMatch(line -> line.contains("before-030")), + "the anchor itself was already delivered: " + fresh.content()); + } + + @Test + void aCursorCannotCrossAReplacementServer(Server server) { + String oldPane = server.panes().get(0).id().value(); + String cursor = Reading.since(TestCalls.on(server, "pane_id", oldPane)).cursor(); + server.killServer(); + Pane replacement = server.newSession("replacement").windows().get(0).panes().get(0); + + IllegalArgumentException refused = assertThrows( + IllegalArgumentException.class, + () -> Reading.since(TestCalls.on( + server, "pane_id", replacement.id().value(), "cursor", cursor))); + + assertTrue(String.valueOf(refused.getMessage()).contains("start again"), refused.getMessage()); + } + + @Test + void aFailedScreenBatchIsNotAnEmptyCapture(Server server) { + try (ProcessTransport processes = new ProcessTransport()) { + TmuxTransport failing = new TmuxTransport() { + @Override + public CommandResult execute(CommandRequest request) { + if (request.commandLine().stream().anyMatch(word -> word.contains("capture-pane"))) { + return new CommandResult(1, List.of(), List.of("simulated screen failure")); + } + return processes.execute(request); + } + + @Override + public void close() {} + }; + try (Server measured = Server.using(server.config(), failing)) { + Pane pane = measured.panes().get(0); + + assertThrows(LibTmuxException.class, () -> Screen.from(pane)); + } + } + } + @Test void aCaptureIsCappedAndSaysWhatItDropped(Server server) { String pane = server.panes().get(0).id().value(); diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index b5877c9..afd7945 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -1,5 +1,6 @@ package io.github.libtmux; +import io.github.libtmux.batch.Batch; import io.github.libtmux.format.RowFormat; import io.github.libtmux.snapshot.PaneState; import io.github.libtmux.snapshot.ServerSnapshot; @@ -244,6 +245,11 @@ public Server server() { return server; } + /** Collects commands fenced to the server incarnation that produced this pane. */ + public Batch batch() { + return server.batch(snapshot); + } + ServerSnapshot snapshot() { return snapshot; } From 7d7f7ba1ba4922fe479711c45359b0f192e58fe1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:51:26 -0500 Subject: [PATCH 72/77] Core(fix[framing]): Count bytes and end rows why: Java character counts understated UTF-8 command sizes, and newline in a final format field ended a row before its content was complete. what: - Measure encoded batch commands in UTF-8 bytes - Append an explicit randomized record terminator to row formats - Parse multiline final and single-field rows through that terminator - Preserve legacy unframed test transports --- .../java/io/github/libtmux/batch/Batch.java | 3 +- .../io/github/libtmux/format/RowFormat.java | 31 ++++++++++--------- .../java/io/github/libtmux/format/Tokens.java | 2 +- .../io/github/libtmux/batch/BatchTest.java | 20 ++++++++++++ .../github/libtmux/format/RowFormatTest.java | 25 ++++++++++++++- 5 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 libtmux/src/test/java/io/github/libtmux/batch/BatchTest.java diff --git a/libtmux/src/main/java/io/github/libtmux/batch/Batch.java b/libtmux/src/main/java/io/github/libtmux/batch/Batch.java index db381a7..128412e 100644 --- a/libtmux/src/main/java/io/github/libtmux/batch/Batch.java +++ b/libtmux/src/main/java/io/github/libtmux/batch/Batch.java @@ -3,6 +3,7 @@ import io.github.libtmux.format.Tokens; import io.github.libtmux.internal.CommandStrings; import io.github.libtmux.transport.CommandResult; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.function.Function; @@ -62,7 +63,7 @@ public int size() { * One dispatched as separate arguments costs less, since nothing there is quoted. */ public int length() { - return CommandStrings.group(assemble()).length(); + return CommandStrings.group(assemble()).getBytes(StandardCharsets.UTF_8).length; } /** diff --git a/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java b/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java index 56fdb3e..20d4f72 100644 --- a/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java +++ b/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java @@ -20,6 +20,8 @@ public final class RowFormat { private static final String SEPARATOR = Tokens.perProcess(); + private static final String TERMINATOR = Tokens.generate(); + private static final Pattern SPLITTER = Pattern.compile(Pattern.quote(SEPARATOR)); private final List fields; @@ -28,7 +30,8 @@ public final class RowFormat { private RowFormat(List fields) { this.fields = fields; this.template = String.join( - SEPARATOR, fields.stream().map(field -> "#{" + field + "}").toList()); + SEPARATOR, fields.stream().map(field -> "#{" + field + "}").toList()) + + TERMINATOR; } /** @@ -53,6 +56,10 @@ public String separator() { return SEPARATOR; } + String terminator() { + return TERMINATOR; + } + /** How many fields a row must have. */ public int size() { return fields.size(); @@ -135,9 +142,11 @@ private int indexOf(String field) { * expected number of fields */ public List rows(List lines) { + if (lines.stream().noneMatch(line -> line.endsWith(TERMINATOR))) { + return lines.stream().map(line -> new Row(split(line))).toList(); + } List rows = new ArrayList<>(); StringBuilder pending = new StringBuilder(); - int separators = 0; boolean open = false; for (String line : lines) { if (open) { @@ -145,11 +154,10 @@ public List rows(List lines) { } pending.append(line); open = true; - separators += occurrences(line); - if (separators >= fields.size() - 1) { - rows.add(new Row(split(pending.toString()))); + if (line.endsWith(TERMINATOR)) { + int end = pending.length() - TERMINATOR.length(); + rows.add(new Row(split(pending.substring(0, end)))); pending.setLength(0); - separators = 0; open = false; } } @@ -159,14 +167,6 @@ public List rows(List lines) { return List.copyOf(rows); } - private static int occurrences(String line) { - int count = 0; - for (int at = line.indexOf(SEPARATOR); at >= 0; at = line.indexOf(SEPARATOR, at + SEPARATOR.length())) { - count++; - } - return count; - } - /** * Reads one row back into its fields. * @@ -174,6 +174,9 @@ private static int occurrences(String line) { * which is the only chance to notice that something shifted */ public List split(String row) { + if (row.endsWith(TERMINATOR)) { + row = row.substring(0, row.length() - TERMINATOR.length()); + } List values = List.of(SPLITTER.split(row, -1)); if (values.size() != fields.size()) { // Counts only: a row carries names and pane content, and this message reaches logs. diff --git a/libtmux/src/main/java/io/github/libtmux/format/Tokens.java b/libtmux/src/main/java/io/github/libtmux/format/Tokens.java index 1fffffa..8124e4c 100644 --- a/libtmux/src/main/java/io/github/libtmux/format/Tokens.java +++ b/libtmux/src/main/java/io/github/libtmux/format/Tokens.java @@ -24,7 +24,7 @@ public static String perProcess() { return PROCESS; } - private static String generate() { + static String generate() { byte[] token = new byte[16]; new SecureRandom().nextBytes(token); return HexFormat.of().formatHex(token); diff --git a/libtmux/src/test/java/io/github/libtmux/batch/BatchTest.java b/libtmux/src/test/java/io/github/libtmux/batch/BatchTest.java new file mode 100644 index 0000000..344736f --- /dev/null +++ b/libtmux/src/test/java/io/github/libtmux/batch/BatchTest.java @@ -0,0 +1,20 @@ +package io.github.libtmux.batch; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +final class BatchTest { + + @Test + void lengthCountsUtf8Bytes() { + Batch ascii = new Batch(commands -> { + throw new AssertionError("length must not dispatch"); + }).add("display-message", "-p", "e"); + Batch accented = new Batch(commands -> { + throw new AssertionError("length must not dispatch"); + }).add("display-message", "-p", "é"); + + assertEquals(ascii.length() + 1, accented.length()); + } +} diff --git a/libtmux/src/test/java/io/github/libtmux/format/RowFormatTest.java b/libtmux/src/test/java/io/github/libtmux/format/RowFormatTest.java index 4a7adfb..f74d2b8 100644 --- a/libtmux/src/test/java/io/github/libtmux/format/RowFormatTest.java +++ b/libtmux/src/test/java/io/github/libtmux/format/RowFormatTest.java @@ -28,7 +28,7 @@ void theTemplateAsksForEveryFieldInOrder() { String separator = WINDOWS.separator(); assertEquals( - List.of("#{session_id}", "#{window_id}", "#{window_name}"), + List.of("#{session_id}", "#{window_id}", "#{window_name}" + WINDOWS.terminator()), List.of(template.split(Pattern.quote(separator), -1))); } @@ -88,4 +88,27 @@ void everyFormatInThisProcessSharesOneSeparator() { void aFormatNeedsAtLeastOneField() { assertThrows(IllegalArgumentException.class, RowFormat::of); } + + @Test + void aMultilineFinalFieldStaysInItsRow() { + RowFormat format = RowFormat.of("id", "value"); + + List rows = format.rows(List.of( + "$0" + format.separator() + "first", + "second" + format.terminator())); + + assertEquals(1, rows.size()); + assertEquals("$0", rows.get(0).text("id")); + assertEquals("first\nsecond", rows.get(0).text("value")); + } + + @Test + void aMultilineSingleFieldStaysInItsRow() { + RowFormat format = RowFormat.of("value"); + + List rows = format.rows(List.of("first", "second" + format.terminator())); + + assertEquals(1, rows.size()); + assertEquals("first\nsecond", rows.get(0).text("value")); + } } From b29e95304cca99418d5500f73850ae4a46bb926c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:54:20 -0500 Subject: [PATCH 73/77] Mcp(fix[run]): Require a POSIX shell why: The run protocol uses POSIX shell syntax and otherwise typed its plumbing into an arbitrary foreground program without completing. what: - Refuse tmux_run unless the pane reports a supported POSIX shell - Keep rejected payloads out of non-shell panes - Increase framing and channel nonces from 40 to 128 bits - State the shell precondition in the tool description --- .../java/io/github/libtmux/mcp/Catalog.java | 2 +- .../github/libtmux/mcp/RunningCommands.java | 19 +++++++++++- .../libtmux/mcp/RunningCommandsTest.java | 29 ++++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java index 432852c..bd7ae69 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Catalog.java @@ -180,7 +180,7 @@ private static void waiting(List tools) { tools.add(ToolSpec.of( "tmux_run", "Run a command and wait for it", - "Runs a shell command in a pane, waits for it to finish, and returns its output and exit " + "Runs a shell command in a pane with a POSIX-compatible shell, waits for it to finish, and returns its output and exit " + "status in one call. Use this whenever you wrote the command yourself. Do not send a " + "command and then poll tmux_capture_pane to guess whether it finished: that costs a " + "call per look and still cannot tell a finished command from a stalled one. The " diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 1f839e9..70cc8b9 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.HexFormat; import java.util.List; +import java.util.Set; import org.jspecify.annotations.Nullable; /** @@ -36,6 +37,7 @@ final class RunningCommands { private static final SecureRandom RANDOM = new SecureRandom(); + private static final Set POSIX_SHELLS = Set.of("sh", "ash", "bash", "dash", "ksh", "mksh", "pdksh", "zsh"); private RunningCommands() {} @@ -66,6 +68,7 @@ record Ran( static Ran run(Call call) { Server server = call.server(); Pane pane = Targets.pane(server, call.string("pane_id")); + requirePosixShell(pane); String command = call.string("command"); Duration timeout = Waits.requested(call); boolean suppressHistory = call.flag("suppress_history", true); @@ -211,8 +214,22 @@ private static Framed frame(List lines, String startMark, String endMark } private static byte[] bytes() { - byte[] value = new byte[5]; + byte[] value = new byte[16]; RANDOM.nextBytes(value); return value; } + + private static void requirePosixShell(Pane pane) { + String current = pane.expand("#{pane_current_command}"); + int slash = current.lastIndexOf('/'); + String name = slash < 0 ? current : current.substring(slash + 1); + if (name.startsWith("-")) { + name = name.substring(1); + } + if (!POSIX_SHELLS.contains(name)) { + throw new IllegalStateException( + "tmux_run requires a POSIX-compatible shell in the target pane; " + "it is running '" + name + + "'. Use tmux_send_keys when typing into another program is intentional"); + } + } } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 225d93a..715c95c 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -72,6 +72,33 @@ void aCommandThatSucceedsComesBackWithItsOutputAndStatus(Server server) { assertTrue(ran.framed(), "the plumbing was cut out exactly"); } + @Test + void aPaneNotRunningAPosixShellIsRefused(Server server) { + server.cmd("new-window", "-d", "-n", "not-a-shell", "cat"); + String pane = server.panes().stream() + .filter(candidate -> candidate.window().name().equals("not-a-shell")) + .findFirst() + .orElseThrow() + .id() + .value(); + + IllegalStateException refused = assertThrows( + IllegalStateException.class, + () -> RunningCommands.run( + TestCalls.on(server, "pane_id", pane, "command", "echo must-not-be-typed", "timeout", 0.1))); + + assertTrue(String.valueOf(refused.getMessage()).contains("POSIX-compatible shell"), refused.getMessage()); + assertTrue( + server.panes().stream() + .filter(candidate -> candidate.id().value().equals(pane)) + .findFirst() + .orElseThrow() + .capture() + .stream() + .noneMatch(line -> line.contains("must-not-be-typed")), + "the rejected payload must not reach the foreground program"); + } + /** * The reason this tool exists rather than send-then-look: a failure is a number, not something to * infer from what the screen says. @@ -141,7 +168,7 @@ void exitStatusSurvivesWhenTheStartMarkerRolledOutOfHistory(Server server) { assertEquals(7, ran.exitStatus()); assertFalse(ran.framed(), "the old start marker must actually have rolled away"); assertTrue( - ran.output().stream().noneMatch(line -> line.matches(".*lt[0-9a-f]{10}-[se].*")), + ran.output().stream().noneMatch(line -> line.matches(".*lt[0-9a-f]{32}-[se].*")), "no surviving marker may leak into output: " + ran.output()); } From f2b6079dc98b3681984c9936e2c5234dbc475723 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:54:31 -0500 Subject: [PATCH 74/77] Style(chore[format]): Apply Java formatter why: The branch fixes must satisfy the repository formatting gate. what: - Apply the pinned formatter to the touched Java sources and tests --- .../main/java/io/github/libtmux/mcp/Caller.java | 4 +--- .../main/java/io/github/libtmux/mcp/Cursor.java | 7 ++++--- .../main/java/io/github/libtmux/mcp/Screen.java | 3 +-- .../java/io/github/libtmux/mcp/ReadingTest.java | 16 +++++++++------- .../java/io/github/libtmux/mcp/TestCalls.java | 3 +-- .../github/libtmux/mcp/ToolsAgainstTmuxTest.java | 6 ++---- .../java/io/github/libtmux/format/RowFormat.java | 3 ++- .../java/io/github/libtmux/batch/BatchTest.java | 10 ++++++---- .../io/github/libtmux/format/RowFormatTest.java | 5 ++--- 9 files changed, 28 insertions(+), 29 deletions(-) diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java index 342bc9f..d21b552 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Caller.java @@ -127,9 +127,7 @@ private static FileRelation sameFile(Path left, @Nullable Path right) { return FileRelation.UNKNOWN; } try { - return left.toRealPath().equals(right.toRealPath()) - ? FileRelation.SAME - : FileRelation.DIFFERENT; + return left.toRealPath().equals(right.toRealPath()) ? FileRelation.SAME : FileRelation.DIFFERENT; } catch (IOException | RuntimeException e) { return FileRelation.UNKNOWN; } diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cursor.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cursor.java index 37ee25c..f87fda4 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cursor.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Cursor.java @@ -35,7 +35,9 @@ static Cursor of(long serverPid, String paneId, List written) { return new Cursor( serverPid, paneId, - written.subList(first, written.size()).stream().map(Cursor::digest).toList()); + written.subList(first, written.size()).stream() + .map(Cursor::digest) + .toList()); } String encode() { @@ -72,8 +74,7 @@ static Cursor decode(String encoded) { static String digest(String line) { try { - byte[] whole = MessageDigest.getInstance("SHA-256") - .digest(line.getBytes(StandardCharsets.UTF_8)); + byte[] whole = MessageDigest.getInstance("SHA-256").digest(line.getBytes(StandardCharsets.UTF_8)); return ENCODER.encodeToString(Arrays.copyOf(whole, DIGEST_BYTES)); } catch (GeneralSecurityException e) { throw new IllegalStateException("SHA-256 is unavailable", e); diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java index e96e6ed..4132f84 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/Screen.java @@ -169,8 +169,7 @@ private static Look look(Pane pane, int lookback) { .add("capture-pane", "-p", "-t", id, "-S", start) .add("display-message", "-p", "-t", id, "#{pid} #{history_size} #{cursor_y}") .run(); - if (read.operations().size() != 2 - || read.operations().stream().anyMatch(operation -> !operation.succeeded())) { + if (read.operations().size() != 2 || read.operations().stream().anyMatch(operation -> !operation.succeeded())) { throw new LibTmuxException("could not read pane content and position as one batch"); } OperationResult capture = read.operations().get(0); diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java index 7a63d3b..e9ba091 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ReadingTest.java @@ -208,9 +208,8 @@ void aCursorThisServerNeverIssuedSaysHowToStartAgain(Server server) { @Test void aStructurallyValidForgedCursorIsRefused() { - String forged = Base64.getUrlEncoder() - .withoutPadding() - .encodeToString("1|%1|0|0".getBytes(StandardCharsets.UTF_8)); + String forged = + Base64.getUrlEncoder().withoutPadding().encodeToString("1|%1|0|0".getBytes(StandardCharsets.UTF_8)); assertThrows(IllegalArgumentException.class, () -> Cursor.decode(forged)); } @@ -228,7 +227,9 @@ void historyCompactionKeepsAReachableCursorContinuous(Server server) { Reading.Since fresh = Reading.since(TestCalls.on(server, "pane_id", pane, "cursor", cursor)); assertTrue(fresh.continuous(), "the anchor still exists after tmux compacts older history"); - assertTrue(fresh.content().stream().anyMatch(line -> line.contains("after-015")), fresh.content().toString()); + assertTrue( + fresh.content().stream().anyMatch(line -> line.contains("after-015")), + fresh.content().toString()); assertTrue( fresh.content().stream().noneMatch(line -> line.contains("before-030")), "the anchor itself was already delivered: " + fresh.content()); @@ -239,12 +240,13 @@ void aCursorCannotCrossAReplacementServer(Server server) { String oldPane = server.panes().get(0).id().value(); String cursor = Reading.since(TestCalls.on(server, "pane_id", oldPane)).cursor(); server.killServer(); - Pane replacement = server.newSession("replacement").windows().get(0).panes().get(0); + Pane replacement = + server.newSession("replacement").windows().get(0).panes().get(0); IllegalArgumentException refused = assertThrows( IllegalArgumentException.class, - () -> Reading.since(TestCalls.on( - server, "pane_id", replacement.id().value(), "cursor", cursor))); + () -> Reading.since( + TestCalls.on(server, "pane_id", replacement.id().value(), "cursor", cursor))); assertTrue(String.valueOf(refused.getMessage()).contains("start again"), refused.getMessage()); } diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java index 166787c..6efc5ca 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/TestCalls.java @@ -43,8 +43,7 @@ static Call withEnvironment(Server server, Map environment, Obje return withEnvironment(server, environment, plain.arguments()); } - private static Call withEnvironment( - Server server, Map environment, Map arguments) { + private static Call withEnvironment(Server server, Map environment, Map arguments) { Connection connection = new Connection( server, Caller.of(server, environment), diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java index 3a28ae6..9b7ea08 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/ToolsAgainstTmuxTest.java @@ -272,13 +272,11 @@ void confirmingIsEnoughToEndTheCallersOwnPane(Server server) { void uncertainCallerIdentityRefusesServerKill(Server server) { String pane = server.panes().get(0).id().value(); Map uncertain = Map.of( - "TMUX", "/tmp/libtmux-java-test/missing-socket," + server.expand("#{pid}") + ",0", - "TMUX_PANE", pane); + "TMUX", "/tmp/libtmux-java-test/missing-socket," + server.expand("#{pid}") + ",0", "TMUX_PANE", pane); IllegalStateException refused = assertThrows( IllegalStateException.class, - () -> Shaping.kill(TestCalls.withEnvironment( - server, uncertain, "target", "server"))); + () -> Shaping.kill(TestCalls.withEnvironment(server, uncertain, "target", "server"))); String message = String.valueOf(refused.getMessage()); assertTrue(message.contains("could not prove"), message); diff --git a/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java b/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java index 20d4f72..39450ec 100644 --- a/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java +++ b/libtmux/src/main/java/io/github/libtmux/format/RowFormat.java @@ -30,7 +30,8 @@ public final class RowFormat { private RowFormat(List fields) { this.fields = fields; this.template = String.join( - SEPARATOR, fields.stream().map(field -> "#{" + field + "}").toList()) + SEPARATOR, + fields.stream().map(field -> "#{" + field + "}").toList()) + TERMINATOR; } diff --git a/libtmux/src/test/java/io/github/libtmux/batch/BatchTest.java b/libtmux/src/test/java/io/github/libtmux/batch/BatchTest.java index 344736f..db89bdb 100644 --- a/libtmux/src/test/java/io/github/libtmux/batch/BatchTest.java +++ b/libtmux/src/test/java/io/github/libtmux/batch/BatchTest.java @@ -9,11 +9,13 @@ final class BatchTest { @Test void lengthCountsUtf8Bytes() { Batch ascii = new Batch(commands -> { - throw new AssertionError("length must not dispatch"); - }).add("display-message", "-p", "e"); + throw new AssertionError("length must not dispatch"); + }) + .add("display-message", "-p", "e"); Batch accented = new Batch(commands -> { - throw new AssertionError("length must not dispatch"); - }).add("display-message", "-p", "é"); + throw new AssertionError("length must not dispatch"); + }) + .add("display-message", "-p", "é"); assertEquals(ascii.length() + 1, accented.length()); } diff --git a/libtmux/src/test/java/io/github/libtmux/format/RowFormatTest.java b/libtmux/src/test/java/io/github/libtmux/format/RowFormatTest.java index f74d2b8..8b97495 100644 --- a/libtmux/src/test/java/io/github/libtmux/format/RowFormatTest.java +++ b/libtmux/src/test/java/io/github/libtmux/format/RowFormatTest.java @@ -93,9 +93,8 @@ void aFormatNeedsAtLeastOneField() { void aMultilineFinalFieldStaysInItsRow() { RowFormat format = RowFormat.of("id", "value"); - List rows = format.rows(List.of( - "$0" + format.separator() + "first", - "second" + format.terminator())); + List rows = + format.rows(List.of("$0" + format.separator() + "first", "second" + format.terminator())); assertEquals(1, rows.size()); assertEquals("$0", rows.get(0).text("id")); From 6782cbb5fcadd49214750cf2d42c24ae1a4620e6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 13:51:07 -0500 Subject: [PATCH 75/77] Mcp(test[run]): Keep uncertainty fixture at shell why: The delivery-uncertainty fixture must satisfy the tmux_run POSIX-shell precondition while exercising the post-delivery failure. what: - Remove the foreground tmux wait gate from the fixture - Retain proof that the accepted send-keys payload still executes --- .../libtmux/mcp/RunningCommandsTest.java | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 715c95c..7e2a664 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -212,15 +212,6 @@ void aTimedOutCommandLeavesNoStatusWhenItEventuallyFinishes(Server server) throw void uncertainCommandDeliveryStillRunsTheAcceptedCommand(Server server, @TempDir Path temporary) throws Exception { String pane = server.panes().get(0).id().value(); Path accepted = temporary.resolve("accepted"); - Path entered = temporary.resolve("entered"); - String gate = "uncertain-delivery-" + System.nanoTime(); - var wait = new java.util.ArrayList<>(java.util.List.of(server.config().binaryPath())); - wait.addAll(server.config().endpoint().flags()); - wait.addAll(java.util.List.of("wait-for", gate)); - server.panes() - .get(0) - .sendLine("printf entered > " + Shell.quote(entered.toString()) + "; " + Shell.quoteAll(wait)); - assertTrue(await(() -> Files.exists(entered)), "the pane never entered the delivery gate"); try (ProcessTransport processes = new ProcessTransport()) { TmuxTransport uncertain = borrowing(request -> { CommandResult result = processes.execute(request); @@ -230,18 +221,14 @@ void uncertainCommandDeliveryStillRunsTheAcceptedCommand(Server server, @TempDir return result; }); try (Server measured = Server.using(server.config(), uncertain)) { - try { - assertThrows( - TmuxTransportException.class, - () -> RunningCommands.run(TestCalls.on( - measured, - "pane_id", - pane, - "command", - "printf ran > " + Shell.quote(accepted.toString())))); - } finally { - server.channel(gate).signal(); - } + assertThrows( + TmuxTransportException.class, + () -> RunningCommands.run(TestCalls.on( + measured, + "pane_id", + pane, + "command", + "printf ran > " + Shell.quote(accepted.toString())))); server.panes().get(0).sendLine("printf 'uncertain-cleanup-%s\\n' finished"); assertTrue( From 25b47ad1ff350f048570e1a8795cc63b2f97ec91 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:13:12 -0500 Subject: [PATCH 76/77] Mcp(test[launcher]): Start an explicit shell why: The wire test must not race an interactive shell startup while proving tmux_run under its POSIX-shell contract. what: - Create a dedicated /bin/sh pane for the command tool call --- .../test/java/io/github/libtmux/mcp/McpLauncherTest.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java index 61fb08e..9486971 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/McpLauncherTest.java @@ -299,7 +299,13 @@ void aReadOnlyLauncherDoesNotEvenOfferTheToolsThatChangeThings(Server server, Tm @Test @Timeout(PATIENCE_SECONDS) void aCommandRunsAndItsExitStatusComesBack(Server server, TmuxSocketPath socket) { - String pane = server.panes().get(0).id().value(); + String pane = server.sessions() + .get(0) + .newWindow(window -> window.named("runner").running("/bin/sh")) + .panes() + .get(0) + .id() + .value(); try (McpSyncClient client = launch(socket.path())) { client.initialize(); From f7b63976782f1ffaa647d4e67cac6fc8ba7e14c9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:45:49 -0500 Subject: [PATCH 77/77] Docs(docs[changelog]): Record review fixes --- CHANGELOG.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f9ba7..e5994a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ production. - **`Pane.findWindow` searches by name, title, or content, with case-insensitive and regular-expression matching.** Build a `FindSpec` or configure one inline. (#6) -- **`Batch.length()` reports the encoded command size.** Use it to dispatch - before tmux's command-size limit is reached. (#6) +- **`Batch.length()` reports the exact UTF-8 byte length of the encoded + command.** Use it to dispatch before tmux's command-size limit is reached. + (#6) - **`Pane.paste(String)` sends literal text without leaving a server buffer.** Text no longer shares tmux's command-size limit; this API requires tmux 3.4. (#6) @@ -59,6 +60,18 @@ production. ### Fixed +- **Destructive MCP tools fail closed when caller-pane identity cannot be + proven.** Uncertain socket, server, or pane identity now requires explicit + self-confirmation instead of bypassing the guard. (#6) +- **MCP pane cursors are authenticated and bound to daemon and pane identity.** + Reads preserve continuity across bounded history compaction when it can be + proven, and pane capture uses identity-fenced batches with strict outcome + checks. (#6) +- **`RowFormat` uses an explicit record terminator.** Multiline final fields + and single-field rows no longer depend on physical line boundaries. (#6) +- **`tmux_run` requires a POSIX shell and uses a 128-bit completion marker.** + It refuses another foreground program instead of sending shell framing that + program cannot interpret. (#6) - **Hierarchy listings preserve values containing newlines.** A pane working directory containing a newline no longer empties session, window, and pane listings. (#6)